Merge pull request #1284 from kaitranntt/kai/feat/codex-auth-profile-isolation

feat(codex-auth): add ccsx auth profile isolation (two Codex accounts, one terminal each)
This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-17 19:22:45 -04:00
committed by GitHub
82 changed files with 11493 additions and 71 deletions
+164
View File
@@ -0,0 +1,164 @@
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"
# 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|:[^:/]*$||'
}
OLD_RAW=$(git show "${BASE}:docker/compose.yaml" \
| grep -m1 '^\s*image:' | sed 's/.*image:\s*//' | tr -d ' ')
NEW_RAW=$(grep -m1 '^\s*image:' docker/compose.yaml \
| sed 's/.*image:\s*//' | tr -d ' ')
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 name change
OLD_NET=$(git show "${BASE}:docker/compose.yaml" \
| grep 'name: ccs-net' | tr -d ' ' || echo "")
NEW_NET=$(grep 'name: ccs-net' docker/compose.yaml | tr -d ' ' || echo "")
if [[ "${OLD_NET}" != "${NEW_NET}" ]]; then
echo "[!] BREAKING: ccs-net network name changed"
BREAKING=1
fi
# 3. container_name change (if present in either version)
OLD_CN=$(git show "${BASE}:docker/compose.yaml" \
| grep 'container_name:' | tr -d ' ' || echo "")
NEW_CN=$(grep 'container_name:' docker/compose.yaml | tr -d ' ' || echo "")
if [[ "${OLD_CN}" != "${NEW_CN}" ]]; then
echo "[!] BREAKING: container_name changed: '${OLD_CN}' -> '${NEW_CN}'"
BREAKING=1
fi
# 4. Service key contract — Docker DNS on ccs-net uses the compose service
# KEY as the hostname (e.g. http://ccs:8317). Renaming or removing
# "ccs:" breaks every sibling container in the wild. Adding new service
# keys (e.g. a sidecar) does NOT affect the "ccs" DNS contract and is
# therefore allowed without a breaking-change marker.
#
# Extraction logic: find lines that look like top-level service keys
# (two-space indent + identifier + colon, NOT sub-keys like "image:").
# This matches YAML service keys without requiring an external YAML parser.
NEW_KEYS=$(awk '/^services:/{s=1;next} s && /^ [a-zA-Z0-9_-]+:/{print $1} /^[^ ]/{s=0}' \
docker/compose.yaml \
| tr -d ':' | sort | tr '\n' ' ' | sed 's/ $//')
if ! echo " ${NEW_KEYS} " | grep -q " ccs "; then
echo "[!] BREAKING: services.ccs key missing from docker/compose.yaml — sibling containers on ccs-net rely on DNS hostname 'ccs'"
BREAKING=1
fi
echo "breaking=${BREAKING}" >> "$GITHUB_OUTPUT"
- name: Require breaking-change commit marker if contract changed
if: steps.contract.outputs.breaking == '1'
run: |
set -euo pipefail
BASE="origin/${{ github.base_ref }}"
# Check all commit messages in the PR for feat! or fix! marker
HAS_BREAKING_MARKER=$(
git log "${BASE}"...HEAD --format="%s" \
| grep -cE "^(feat|fix)(\([^)]+\))?!:" || true
)
if [[ "${HAS_BREAKING_MARKER}" -eq 0 ]]; then
echo ""
echo "[X] Breaking change guard FAILED"
echo ""
echo " docker/compose.yaml was modified in a way that changes the stable"
echo " ccs-net contract (image name, network name, or container_name)."
echo ""
echo " These fields are relied upon by sibling containers. Changing them"
echo " without a breaking-change marker is a silent breaking change."
echo ""
echo " To proceed, rename at least one commit to use the feat! or fix!"
echo " breaking-change format:"
echo ""
echo " feat!: rename ccs service image to ghcr.io/owner/newname"
echo " fix!: align container_name with new naming convention"
echo ""
echo " See: https://www.conventionalcommits.org/en/v1.0.0/#specification"
exit 1
fi
echo "[OK] Breaking-change commit marker found — contract change is intentional"
- name: No contract-breaking changes detected
if: steps.contract.outputs.breaking == '0'
run: echo "[OK] No contract-breaking changes in docker/compose.yaml"
+14
View File
@@ -146,3 +146,17 @@ jobs:
env:
CCS_E2E_SKIP_BUILD: '1'
run: bun run test:e2e
compose-parity:
if: >-
contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association)
runs-on: [self-hosted, linux, x64, cliproxy]
name: compose-parity
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Assert compose parity (compose.yaml vs docker-compose.integrated.yml)
run: bash tests/docker/compose-parity.sh
+341 -12
View File
@@ -10,15 +10,31 @@ 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
@@ -36,7 +52,6 @@ jobs:
else
TARGET_TAG="${MANUAL_TAG}"
fi
echo "tag=${TARGET_TAG}" >> "$GITHUB_OUTPUT"
- name: Validate stable semver tag
@@ -46,7 +61,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,6 +69,7 @@ jobs:
uses: actions/checkout@v4
with:
ref: ${{ steps.target.outputs.tag }}
persist-credentials: false
- name: Set up QEMU
if: steps.tag.outputs.publish == 'true'
@@ -67,8 +82,6 @@ jobs:
- name: Derive image metadata
if: steps.tag.outputs.publish == 'true'
id: meta
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ steps.target.outputs.tag }}"
VERSION="${VERSION#v}"
@@ -105,7 +118,7 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push release image
- name: Build and push legacy dashboard image
if: steps.tag.outputs.publish == 'true'
uses: docker/build-push-action@v6
with:
@@ -113,16 +126,332 @@ jobs:
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
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
cache-from: type=gha
cache-to: type=gha,mode=max
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
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- 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.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
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 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: |
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: |
HTTP_CODE=$(curl -o /dev/null -s -w "%{http_code}" --max-time 10 http://127.0.0.1:18317/ || echo "000")
if [[ "${HTTP_CODE}" == "000" ]]; then
echo "[X] Could not reach CLIProxy on :8317 (connection failed)"
exit 1
fi
echo "[OK] CLIProxy port 8317 responded with HTTP ${HTTP_CODE}"
- name: Stop smoke test container
if: always()
run: |
docker stop "ccs-smoke-${{ github.run_id }}" 2>/dev/null || true
# ---------------------------------------------------------------------------
# Job 4: Promote mutable tags — runs ONLY after smoke tests pass
# Adds :latest, :<major>, :<minor> aliases pointing to the immutable digest.
#
# INTENTIONAL DESIGN (issue #1251 rc.1 soak — see docs/release-process.md):
# GitHub release events publish ONLY the immutable :<ver> tag. The mutable
# :latest / :<major> / :<minor> aliases STAY POINTED AT THE PRIOR STABLE
# RELEASE until an operator manually promotes after a soak window (~24h).
# Trade-off: zero-install users (curl ccs.kaitran.ca/docker-compose.yaml &&
# docker compose up -d) keep receiving last-known-good :latest during the
# soak; operators wanting the new version pull :<ver> directly.
#
# Operator promotion:
# gh workflow run promote-release.yml -f tag=v<X.Y.Z>
# (or equivalently: gh workflow run "Publish Docker Image" -f tag=v<X.Y.Z>
# -f promote_to_latest=true)
# ---------------------------------------------------------------------------
promote-mutable-tags:
name: Promote mutable tags (:latest / major / minor)
needs: [smoke-test, publish-integrated]
# Only promote on explicit operator dispatch — never on automatic release events.
# Release events publish the immutable :<ver> tag only (see publish-integrated).
if: |
needs.publish-integrated.outputs.publish == 'true' &&
github.event_name == 'workflow_dispatch' && inputs.promote_to_latest == true
runs-on: [self-hosted, linux, x64, cliproxy]
permissions:
contents: read
packages: write
id-token: write # for cosign re-sign of mutable aliases
steps:
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Promote :latest / major / minor aliases
env:
IMAGE_REF: ${{ needs.publish-integrated.outputs.image_ref }}
VERSION: ${{ needs.publish-integrated.outputs.version }}
run: |
OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')
IMAGE="ghcr.io/${OWNER_LOWER}/ccs"
MINOR="${VERSION%.*}"
MAJOR="${VERSION%%.*}"
echo "[i] Pointing mutable tags to ${IMAGE_REF}"
docker buildx imagetools create \
--tag "${IMAGE}:latest" \
--tag "${IMAGE}:${MINOR}" \
--tag "${IMAGE}:${MAJOR}" \
"${IMAGE_REF}"
echo "[OK] Promoted: :latest :${MINOR} :${MAJOR} → ${IMAGE_REF}"
+34
View File
@@ -0,0 +1,34 @@
name: Docs Quickstart Snippet Parity
on:
push:
paths:
- "docs/quickstart-snippet.md"
- "README.md"
- "docker/README.md"
- "tests/docs/quickstart-parity.sh"
- ".github/workflows/docs-parity.yml"
pull_request:
paths:
- "docs/quickstart-snippet.md"
- "README.md"
- "docker/README.md"
- "tests/docs/quickstart-parity.sh"
- ".github/workflows/docs-parity.yml"
jobs:
quickstart-parity:
name: Assert quickstart snippet matches in README.md and docker/README.md
if: >-
contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association)
|| github.event_name == 'push'
runs-on: [self-hosted, linux, x64, cliproxy]
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Run quickstart parity check
run: bash tests/docs/quickstart-parity.sh
+98
View File
@@ -0,0 +1,98 @@
name: Promote Stable Release to Docker Latest
# Manual workflow to promote a stable vX.Y.Z release to Docker mutable tags
# (:latest, :MAJOR, :MINOR) after the rc.1 soak window.
#
# Flow:
# 1. Validate that the input tag exists as a stable (non-prerelease) GitHub release.
# 2. Dispatch docker-release.yml with promote_to_latest=true for the given tag.
# This triggers the promote-mutable-tags job (gated on workflow_dispatch
# with promote_to_latest=true) which adds :latest/:MAJOR/:MINOR Docker aliases.
#
# Pre-conditions:
# - The stable tag must already exist as a GitHub release (created automatically
# by semantic-release when the PR merges to main).
# - The immutable :<ver> Docker image must already be published and smoke-tested
# (done automatically by docker-release.yml on the release: published event).
# - Operator has verified the immutable image is stable (24h soak recommended).
#
# See docs/release-process.md for the full soak + promote procedure.
on:
workflow_dispatch:
inputs:
tag:
description: >
Stable tag to promote, e.g. v7.80.0.
Must already exist as a GitHub stable release with the immutable
Docker :<ver> image already published and smoke-tested.
required: true
type: string
jobs:
promote:
name: Promote ${{ inputs.tag }} Docker mutable tags
runs-on: [self-hosted, linux, x64, cliproxy]
permissions:
contents: read # to verify the release
actions: write # to dispatch docker-release.yml
steps:
- name: Validate stable semver tag format
run: |
if [[ "${{ inputs.tag }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "[OK] Tag format valid: ${{ inputs.tag }}"
else
echo "[X] Expected vX.Y.Z format, got: ${{ inputs.tag }}"
echo " For rc tags use docker-release.yml directly with promote_to_latest=true."
exit 1
fi
- name: Verify release exists and is stable (not a prerelease)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
IS_PRERELEASE=$(gh release view "${{ inputs.tag }}" \
--repo "${{ github.repository }}" \
--json isPrerelease --jq '.isPrerelease')
if [[ "${IS_PRERELEASE}" == "true" ]]; then
echo "[X] Release ${{ inputs.tag }} is still a prerelease (isPrerelease=true)"
echo " Semantic-release publishes stable releases to main automatically."
echo " Check that the tag was created from a main merge, not a dev prerelease."
exit 1
fi
if [[ "${IS_PRERELEASE}" != "false" ]]; then
echo "[X] Could not read release state for ${{ inputs.tag }} (got: ${IS_PRERELEASE})"
echo " Verify the tag exists: gh release view ${{ inputs.tag }} --repo ${{ github.repository }}"
exit 1
fi
echo "[OK] Release ${{ inputs.tag }} is stable — proceeding with Docker mutable tag promotion"
- name: Verify immutable Docker image exists
run: |
TAG_SANS_V="${{ inputs.tag }}"
TAG_SANS_V="${TAG_SANS_V#v}"
OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')
IMAGE_REF="ghcr.io/${OWNER_LOWER}/ccs:${TAG_SANS_V}"
echo "[i] Verifying immutable image: ${IMAGE_REF}"
if ! docker manifest inspect "${IMAGE_REF}" > /dev/null 2>&1; then
echo "[X] Immutable image ${IMAGE_REF} not found in ghcr.io"
echo " Run docker-release.yml for this tag first, or wait for smoke tests to complete."
exit 1
fi
echo "[OK] Immutable image ${IMAGE_REF} confirmed in registry"
- name: Dispatch Docker mutable tag promotion
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
RUN_URL=$(gh workflow run "Publish Docker Image" \
--repo "${{ github.repository }}" \
--field "tag=${{ inputs.tag }}" \
--field "promote_to_latest=true" \
2>&1)
echo "[OK] Dispatched docker-release.yml with tag=${{ inputs.tag }} promote_to_latest=true"
echo "[i] Monitor the run at: https://github.com/${{ github.repository }}/actions/workflows/docker-release.yml"
echo "[i] After the run completes, verify with:"
echo " docker buildx imagetools inspect ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/ccs:latest"
@@ -0,0 +1,50 @@
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: |
curl -fsSL https://ccs.kaitran.ca/docker-compose.yaml -o /tmp/ccs-compose.yaml
echo "--- downloaded compose ---"
cat /tmp/ccs-compose.yaml
- name: Validate compose parses (docker compose config)
run: docker compose -f /tmp/ccs-compose.yaml config
- name: Run network-contract test against downloaded compose
if: ${{ github.event_name == 'workflow_dispatch' && inputs.do_up == true }}
run: |
set -euo pipefail
# network-contract.sh accepts the compose file as first argument.
# This exercises the exact file served at the canonical URL rather
# than the repo copy, ensuring URL-delivered compose works end-to-end.
# The script handles up, healthcheck wait, sibling DNS probes, and
# teardown (via EXIT trap) — no separate up/down steps needed.
bash tests/docker/network-contract.sh /tmp/ccs-compose.yaml
+17 -1
View File
@@ -3,7 +3,13 @@
*
* Branch-aware config:
* - dev branch: Uses dev release configuration (prerelease)
* - main branch: Uses production release configuration
* - main branch: Uses production release configuration (stable, npm @latest)
*
* RC soak window for Docker mutable tags is handled entirely in docker-release.yml:
* every release event publishes the immutable :<ver> Docker tag immediately;
* mutable :latest/:MAJOR/:MINOR tags require an explicit operator action via
* `gh workflow run promote-release.yml -f tag=vX.Y.Z` (workflow_dispatch).
* npm @latest is always set immediately on stable release — no rc soak needed.
*/
const currentBranch =
@@ -37,6 +43,9 @@ const releaseNotesGenerator = [
{ type: 'feat', section: 'Features' },
{ type: 'fix', section: 'Bug Fixes' },
{ type: 'hotfix', section: 'Hotfixes' },
// Breaking changes (feat! / fix!) surface under Features/Bug Fixes
// with a BREAKING CHANGE footer note — no separate section needed.
{ type: 'revert', section: 'Reverts' },
{ type: 'docs', section: 'Documentation' },
{ type: 'style', section: 'Styles' },
{ type: 'refactor', section: 'Code Refactoring' },
@@ -44,6 +53,9 @@ const releaseNotesGenerator = [
{ type: 'test', section: 'Tests' },
{ type: 'build', section: 'Build System' },
{ type: 'ci', section: 'CI' },
// chore commits are intentionally hidden from release notes (no section).
// "### Removed" sections come from feat!/fix! BREAKING CHANGE footers,
// not from a separate commit type.
],
},
},
@@ -87,6 +99,10 @@ const devConfig = {
};
// Production release configuration
// Every merge to main publishes a stable vX.Y.Z release immediately to npm @latest.
// Docker immutable :<ver> tag is pushed by docker-release.yml on the release: published event.
// Docker mutable :latest/:MAJOR/:MINOR tags require a separate manual promote step — see
// docs/release-process.md and promote-release.yml for the soak + promote procedure.
const productionConfig = {
branches: ['main'],
plugins: [
+16
View File
@@ -1,3 +1,19 @@
## [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).
## [7.79.1](https://github.com/kaitranntt/ccs/compare/v7.79.0...v7.79.1) (2026-05-14)
### Hotfixes
+12
View File
@@ -217,6 +217,18 @@ If you cannot run the full suite, that is still fine for early or docs-only PRs.
- Update `ui/src/` and any affected tests.
- Run UI validation from `ui/`.
### If you change the Docker network or service name
The `ccs-net` network name and the `ccs` service name in `docker/compose.yaml` are a **public contract**. Users attach their own containers to `ccs-net` and resolve CLIProxy at `http://ccs:8317`.
Changing either of these values is a **SemVer-major breaking change**. Before modifying `services.ccs.name` or `networks.ccs-net.name` in `docker/compose.yaml`:
- Open an issue to discuss the migration path.
- Update `docker/README.md`, `CHANGELOG.md`, and any docs that reference the stable names.
- Bump the major version (via a `feat!:` or `fix!:` commit with a `BREAKING CHANGE:` footer).
If you are unsure whether your change affects the contract, check `tests/docker/network-contract.sh` — the test will fail if the network or service DNS resolution breaks.
### If you change config, providers, or architecture
- Update the relevant docs in `docs/`.
+29 -11
View File
@@ -20,21 +20,25 @@ Anthropic-compatible APIs without config thrash.
</div>
## Why CCS
> **[Docker]** `ghcr.io/kaitranntt/ccs-dashboard:latest` is deprecated. Use `ghcr.io/kaitranntt/ccs:latest` instead. See [#1251](https://github.com/kaitranntt/ccs/issues/1251) and [docker/README.md](docker/README.md#choosing-an-image) for migration details. To wire a sibling container to CLIProxy, see [Connect your app to CLIProxy](docker/README.md#connect-your-app-to-cliproxy).
CCS gives you one stable command surface while letting you switch between:
<!-- quickstart-snippet-start -->
## Quick Start (Docker)
- multiple runtimes such as Claude Code, Factory Droid, and Codex CLI
- multiple Claude subscriptions and isolated account contexts
- OAuth providers like Codex, Kiro, Claude, Qwen, Kimi, and more, with legacy
Copilot compatibility for existing setups
- API and local-model profiles like GLM, Kimi, OpenRouter, Ollama, llama.cpp,
Novita, and Alibaba Coding Plan
With Docker installed:
The goal is simple: stop rewriting config files, stop breaking active sessions,
and move between providers in seconds.
```bash
curl -fsSL https://ccs.kaitran.ca/docker-compose.yaml -o docker-compose.yaml
docker compose up -d
```
## Quick Start
Dashboard at http://localhost:3000 · CLIProxy at http://localhost:8317.
Need a corporate-proxy alternative? Download directly:
`https://raw.githubusercontent.com/kaitranntt/ccs/main/docker/compose.yaml`
<!-- quickstart-snippet-end -->
## Install on Host (npm)
```bash
npm install -g @kaitranntt/ccs
@@ -51,6 +55,20 @@ ccs glm
ccs ollama
```
## Why CCS
CCS gives you one stable command surface while letting you switch between:
- multiple runtimes such as Claude Code, Factory Droid, and Codex CLI
- multiple Claude subscriptions and isolated account contexts
- OAuth providers like Codex, Kiro, Claude, Qwen, Kimi, and more, with legacy
Copilot compatibility for existing setups
- API and local-model profiles like GLM, Kimi, OpenRouter, Ollama, llama.cpp,
Novita, and Alibaba Coding Plan
The goal is simple: stop rewriting config files, stop breaking active sessions,
and move between providers in seconds.
## OpenAI-Compatible Routing
CCS can now bridge Claude Code into OpenAI-compatible providers through a local
+19 -10
View File
@@ -2,6 +2,7 @@
# =============================================================================
# Build stage: compile TypeScript and build UI
# Bun is used here for speed; npm lockfile is generated for the runtime stage.
# =============================================================================
FROM node:20-bookworm-slim AS build
@@ -35,29 +36,37 @@ RUN bun run build:all
# Validate build artifacts exist
RUN test -d dist && test -d lib && echo "[OK] Build artifacts validated"
# Generate a fresh npm lockfile from package.json immediately after build.
# This lockfile is ephemeral — never committed; bun.lock remains the dev lockfile.
# --ignore-scripts: only lockfile generation, no install side-effects needed here.
# postinstall (scripts/postinstall.js) is a user-facing setup script; it must NOT
# run during the Docker build (it writes to ~/.ccs/ which does not exist here).
RUN --mount=type=cache,target=/root/.npm \
npm install --package-lock-only --ignore-scripts
# =============================================================================
# Runtime stage: minimal production image
# Runtime stage: Node-only, no Bun
# =============================================================================
FROM node:20-bookworm-slim AS runtime
SHELL ["/bin/bash", "-lc"]
# Pin bun version for reproducible builds
ARG BUN_VERSION=1.2.21
ENV BUN_INSTALL=/usr/local/bun
ENV PATH="$BUN_INSTALL/bin:/home/node/.opencode/bin:$PATH"
# opencode installs its binary to /home/node/.opencode/bin — keep on PATH
ENV PATH="/home/node/.opencode/bin:$PATH"
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates unzip \
&& rm -rf /var/lib/apt/lists/*
# Install specific bun version
RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}"
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production --ignore-scripts
# Copy the ephemeral lockfile generated in the build stage (never committed).
# --ignore-scripts: postinstall writes ~/.ccs/ config which is not needed inside
# the Docker image; runtime deps (bcrypt v6+, express, etc.) have no native
# compilation steps requiring postinstall.
COPY --from=build /app/package.json /app/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev --ignore-scripts
COPY docker/entrypoint.sh /usr/local/bin/ccs-entrypoint
RUN chmod +x /usr/local/bin/ccs-entrypoint
+21 -1
View File
@@ -2,6 +2,15 @@ FROM eceasy/cli-proxy-api:latest
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 +18,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
+244 -29
View File
@@ -11,11 +11,45 @@ Persistent config, restart on reboot.
</div>
> **[Deprecation]** `ghcr.io/kaitranntt/ccs-dashboard:latest` is deprecated.
> Migrate to `ghcr.io/kaitranntt/ccs:latest`. See [Migration](#migration-from-ccs-dashboardlatest) below.
<br>
## Preferred: `ccs docker`
<!-- quickstart-snippet-start -->
## Quick Start (Docker)
The CLI now ships a first-class Docker command suite for the integrated CCS + CLIProxy stack:
With Docker installed:
```bash
curl -fsSL https://ccs.kaitran.ca/docker-compose.yaml -o docker-compose.yaml
docker compose up -d
```
Dashboard at http://localhost:3000 · CLIProxy at http://localhost:8317.
Need a corporate-proxy alternative? Download directly:
`https://raw.githubusercontent.com/kaitranntt/ccs/main/docker/compose.yaml`
<!-- quickstart-snippet-end -->
---
## Choosing an image
| Tag | Use | Approx. size | Status |
|---|---|---|---|
| `ghcr.io/kaitranntt/ccs:latest` | CCS + CLIProxy, no AI CLIs bundled | < 350 MB | **Recommended** |
| `ghcr.io/kaitranntt/ccs-dashboard:latest` | Legacy all-in-one image | > 600 MB | **Deprecated** — migrate to `ccs:latest`. Sunset after 2 releases. See [#1251](https://github.com/kaitranntt/ccs/issues/1251) |
`ccs:latest` also publishes pinned version tags (`ccs:<major>.<minor>.<patch>`, `ccs:<major>.<minor>`, `ccs:<major>`) for reproducible deployments.
**Need claude-code, gemini-cli, grok-cli, or opencode?** Run those tools in a sibling container attached to `ccs-net` — see [Connect your app to CLIProxy](#connect-your-app-to-cliproxy). This keeps each tool independently versioned and prevents supply-chain bloat in the CLIProxy image.
---
## Power-user: `ccs docker`
The CLI ships a first-class Docker command suite for the integrated CCS + CLIProxy stack:
```bash
ccs docker up
@@ -145,28 +179,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 +216,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 +440,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.
@@ -389,3 +585,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>
```
+60
View File
@@ -0,0 +1,60 @@
# 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: ${CCS_IMAGE:-ghcr.io/kaitranntt/ccs:latest}
restart: unless-stopped
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 >= 500) 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
+2
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
echo "[WARN] ghcr.io/kaitranntt/ccs-dashboard is deprecated. Migrate to ghcr.io/kaitranntt/ccs:latest. See https://github.com/kaitranntt/ccs/issues/1251" >&2
ccs_home_dir="${CCS_HOME_DIR:-/home/node/.ccs}"
mkdir -p "$ccs_home_dir"
+168
View File
@@ -0,0 +1,168 @@
# 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`. A shared `config.toml` is linked via symlink so model/provider settings
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
```
## 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 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 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`.
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)
~/.codex/
└── config.toml # Single shared model/provider config
```
## Caveats
### Windows symlinks
On Windows, creating symlinks requires Developer Mode or elevated privileges.
If symlink creation fails, CCS falls back to copying `config.toml`. In this case,
changes to `~/.codex/config.toml` are **not** automatically reflected in the profile —
you must re-run `ccsx auth create <name> --force` to refresh the copy.
### `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.
+15
View File
@@ -0,0 +1,15 @@
<!-- quickstart-snippet-start -->
## Quick Start (Docker)
With Docker installed:
```bash
curl -fsSL https://ccs.kaitran.ca/docker-compose.yaml -o docker-compose.yaml
docker compose up -d
```
Dashboard at http://localhost:3000 · CLIProxy at http://localhost:8317.
Need a corporate-proxy alternative? Download directly:
`https://raw.githubusercontent.com/kaitranntt/ccs/main/docker/compose.yaml`
<!-- quickstart-snippet-end -->
+106
View File
@@ -0,0 +1,106 @@
# CCS Release Process
CCS uses a decoupled release model: every merge to `main` immediately publishes
a stable npm `@latest` release and an immutable Docker `:<ver>` tag. Docker
mutable tags (`:latest`, `:<MAJOR>`, `:<MINOR>`) require a separate manual
promote step after an operator-verified soak window. This decouples the npm
ecosystem from the Docker stability gate.
## Phase 1 — Automatic stable release (on every merge to `main`)
1. A PR is merged into `main` with a conventional commit (`feat:`, `fix:`, etc.).
2. `release.yml` triggers semantic-release, which reads `.releaserc.cjs`.
3. Because `main` is a stable channel, semantic-release cuts a GitHub release
tagged `vX.Y.Z` and publishes the npm package to the `@latest` dist-tag
immediately. No rc channel, no soak delay on npm.
4. `docker-release.yml` triggers on the `release: published` event and:
- Validates the tag as stable semver (`vX.Y.Z`).
- Builds the integrated image for `linux/amd64` and `linux/arm64`.
- Pushes **only the immutable** `ghcr.io/kaitranntt/ccs:X.Y.Z` tag.
- Signs the image with cosign (keyless OIDC).
- Runs smoke tests (`smoke-test` job).
- Mutable tags (`:latest`, `:<MAJOR>`, `:<MINOR>`) are **not** added at
this stage — `promote-mutable-tags` only runs on explicit
`workflow_dispatch` with `promote_to_latest=true`.
## Phase 2 — Manual promotion to Docker mutable tags (rc.1 soak window)
After the immutable `:<ver>` Docker image has soaked (typically 24 h with no
reported issues), the operator promotes mutable tags:
1. Verify the immutable image is healthy:
```bash
docker pull ghcr.io/kaitranntt/ccs:X.Y.Z
docker run --rm -p 3000:3000 -p 8317:8317 ghcr.io/kaitranntt/ccs:X.Y.Z
# check http://localhost:3000 and http://localhost:8317
```
2. Optionally verify the cosign signature:
```bash
cosign verify \
--certificate-identity-regexp "https://github.com/kaitranntt/ccs/.github/workflows/docker-release.yml" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/kaitranntt/ccs:X.Y.Z
```
3. Run the `promote-release` workflow via GitHub Actions UI or CLI:
```bash
gh workflow run promote-release.yml \
--field tag=vX.Y.Z
```
This dispatches `docker-release.yml` with `promote_to_latest=true`, which
triggers the `promote-mutable-tags` job to add `:latest`, `:<MAJOR>`, and
`:<MINOR>` via `docker buildx imagetools create`.
Alternatively, dispatch `docker-release.yml` directly:
```bash
gh workflow run "Publish Docker Image" \
--field tag=vX.Y.Z \
--field promote_to_latest=true
```
## Why npm and Docker have different soak windows
- **npm `@latest`**: Published immediately on every `main` merge. npm users who
pin a version are unaffected; users who run `npm install -g @kaitranntt/ccs`
get the latest immediately. Rollback is `npm install -g @kaitranntt/ccs@X.Y.Z`.
- **Docker `:latest`**: Promoted only after operator confirmation. Users who
pull `:latest` or run `docker pull` without a pinned tag are shielded from
a bad image. The immutable `:<ver>` tag is always available for pinned usage
from the moment of release.
## Verifying the promotion
```bash
# Confirm :latest points to the promoted digest
docker buildx imagetools inspect ghcr.io/kaitranntt/ccs:latest
# Confirm npm @latest updated (happens automatically at Phase 1)
npm view @kaitranntt/ccs dist-tags
```
## Rollback
If a promoted release is found to be bad:
```bash
# Repoint :latest to the previous known-good immutable tag
docker buildx imagetools create \
--tag ghcr.io/kaitranntt/ccs:latest \
ghcr.io/kaitranntt/ccs:PREVIOUS.VERSION
# For npm, publish a fix as a new patch release (do not unpublish)
# Unpublishing npm packages causes downstream breakage for pinned consumers.
```
## Branch / tag taxonomy
| Branch | Semantic-release channel | npm dist-tag | Docker tag (on release event) | Docker mutable (on promote) |
|--------|--------------------------|--------------|-------------------------------|------------------------------|
| `main` | stable | `@latest` | `:<ver>` (immutable, immediate) | `:latest`, `:<MAJOR>`, `:<MINOR>` (after soak) |
| `dev` | `dev` prerelease | `@dev` | not published | not published |
+1
View File
@@ -22,6 +22,7 @@ const slowTests = [
'tests/integration/cursor-daemon-lifecycle.test.ts',
'tests/integration/logging-request-context.test.ts',
'tests/integration/proxy/daemon-lifecycle.test.ts',
'tests/integration/web-server/codex-profiles-endpoint.test.ts',
'tests/unit/commands/persist-command-handler.test.ts',
'tests/unit/hooks/browser-mcp-advanced-interactions.test.ts',
'tests/unit/hooks/browser-mcp-downloads-and-files.test.ts',
+10
View File
@@ -68,6 +68,16 @@ function resolveCcsxpCodexHome() {
return path.join(os.homedir(), '.codex');
}
// H5: CCS_CODEX_PROFILE is ignored by ccsxp. The ccsx auth profile system
// (src/codex-auth/) is intentionally NOT consulted here — ccsxp serves the
// cliproxy round-robin pool, not per-user-account profiles. Emit a one-line
// notice so users who set CCS_CODEX_PROFILE in their shell don't get confused
// when ccsxp silently ignores it and overwrites CODEX_HOME below.
if (process.env.CCS_CODEX_PROFILE) {
process.stderr.write(
"[i] CCS_CODEX_PROFILE is ignored by ccsxp; profile applies to native 'codex' only.\n"
);
}
process.env.CODEX_HOME = resolveCcsxpCodexHome();
// ccsxp is the Codex + cliproxy shortcut. Keep the native Codex history root,
+106
View File
@@ -0,0 +1,106 @@
/**
* 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);
}
/**
* 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 ─────────────────────────────────
// 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) {
try {
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`
);
}
}
} catch (resolverErr) {
const msg = errorMessage(resolverErr);
if (isCodexAuthProfileResolutionError(resolverErr)) {
process.stderr.write(`[X] codex-auth: ${msg}\n`);
return 1;
}
process.stderr.write(`[X] codex-auth: profile resolution failed (${msg})\n`);
return 1;
}
}
// ── delegate to CCS ─────────────────────────────────────────────────────
// require() is evaluated AFTER env mutations above. CCS manages its own
// process lifecycle (spawns codex, pipes stdio, calls process.exit).
// Return -1 so the entry script knows NOT to call process.exit().
require('../ccs');
return -1; // CCS is in control — entry must not call process.exit()
}
+5 -2
View File
@@ -1,2 +1,5 @@
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
require('../ccs');
import { main } from './codex-runtime-router';
// -1 means CCS has taken over the process lifecycle; do not exit.
main(process.argv).then((code) => {
if (code >= 0) process.exit(code);
});
+36
View File
@@ -0,0 +1,36 @@
import * as fs from 'fs';
import { createLogger } from '../services/logging';
import { decodeIdToken } from './decode-id-token';
import type { CodexAccountIdentity } from './types';
const logger = createLogger('codex-auth:identity');
interface AuthJson {
tokens?: {
id_token?: string;
};
}
/**
* Read auth.json from disk and extract display-safe identity fields.
* Returns {} on any error (missing file, bad JSON, missing token, decode failure).
* Never throws.
*/
export function decodeAccountIdentity(authJsonPath: string): CodexAccountIdentity {
try {
const raw = fs.readFileSync(authJsonPath, 'utf8');
const parsed = JSON.parse(raw) as AuthJson;
const idToken = parsed?.tokens?.id_token;
if (typeof idToken !== 'string' || idToken.length === 0) {
return {};
}
return decodeIdToken(idToken);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(
'codex-auth.identity.decode-failed',
`Failed to decode account identity from ${authJsonPath}: ${msg}`
);
return {};
}
}
@@ -0,0 +1,261 @@
/**
* Dashboard service for codex-auth profile summary.
*
* Reads the profile registry, decodes each profile's auth.json JWT,
* resolves the active profile via env precedence, and returns the
* API response shape for GET /api/codex/profiles.
*
* Security: tokens NEVER appear in the returned object. Only display-safe
* fields (email, plan, accountId) are extracted from JWT. auth.json is
* read/decoded and then discarded.
*
* Cache: 5-second in-memory cache reduces full registry/auth reads during
* dashboard polling, but each call still stats the registry so corruption or
* edits are surfaced immediately instead of serving stale success.
*/
import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from '../services/logging';
import { decodeAccountIdentity } from './codex-account-identity';
import { hasStructurallyValidIdToken } from './decode-id-token';
import { getCodexAuthRegistryPath, getCodexInstancesDir } from './codex-profile-paths';
import { CODEX_PROFILE_SCHEMA_VERSION } from './types';
import { CodexProfileRegistry } from './codex-profile-registry';
import type { CodexProfileData, CodexProfileMetadata } from './types';
const logger = createLogger('codex-auth:dashboard');
// ── Response types ──────────────────────────────────────────────────────────
export interface CodexAuthProfileEntry {
name: string;
codexHome: string;
email: string | null;
plan: string | null;
accountId: string | null;
lastUsed: string | null;
authValid: boolean;
}
export interface CodexAuthActiveProfile {
name: string | null;
source: 'default' | 'env' | 'explicit-codex-home';
codexHome: string;
}
export interface CodexAuthProfilesSummary {
active: CodexAuthActiveProfile | null;
default: string | null;
profiles: CodexAuthProfileEntry[];
}
// ── Cache ───────────────────────────────────────────────────────────────────
let cache: {
value: CodexAuthProfilesSummary;
expiresAt: number;
registrySignature: string;
} | null = null;
const TTL_MS = 5000;
/**
* Invalidate the in-process cache so the next call re-reads from disk.
* Useful for Phase 2 CLI commands running in the same process as the dashboard.
* Out-of-process invocations rely on the 5s TTL.
*/
export function invalidateCodexAuthProfilesCache(): void {
cache = null;
}
// ── Registry helpers ────────────────────────────────────────────────────────
function getRegistryCacheSignature(): string {
const registryPath = getCodexAuthRegistryPath();
try {
const stat = fs.statSync(registryPath);
return ['present', stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(':');
} catch (err) {
if ((err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
return 'missing';
}
const msg = err instanceof Error ? err.message : String(err);
logger.warn('codex-auth.dashboard.registry-stat-failed', `Registry stat failed: ${msg}`);
throw new Error('Codex auth profile registry could not be checked safely');
}
}
function readRegistry(): CodexProfileData {
const registryPath = getCodexAuthRegistryPath();
if (!fs.existsSync(registryPath)) {
return { version: CODEX_PROFILE_SCHEMA_VERSION, default: null, profiles: {} };
}
try {
const registry = new CodexProfileRegistry(registryPath);
const profiles: Record<string, CodexProfileMetadata> = {};
for (const name of registry.listProfiles()) {
profiles[name] = registry.getProfile(name);
}
return {
version: CODEX_PROFILE_SCHEMA_VERSION,
default: registry.getDefault(),
profiles,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn('codex-auth.dashboard.registry-read-failed', `Registry read failed: ${msg}`);
throw new Error(`Codex auth profile registry could not be read safely: ${msg}`);
}
}
// ── Profile entry builder ───────────────────────────────────────────────────
function buildProfileEntry(name: string): CodexAuthProfileEntry {
const codexHome = path.join(getCodexInstancesDir(), name);
const authJsonPath = path.join(codexHome, 'auth.json');
let authValid = false;
let email: string | null = null;
let plan: string | null = null;
let accountId: string | null = null;
try {
if (fs.existsSync(authJsonPath)) {
// decodeAccountIdentity never throws; returns {} on any error
const identity = decodeAccountIdentity(authJsonPath);
authValid = Object.keys(identity).length > 0 || _hasStructurallyValidIdToken(authJsonPath);
email = identity.email ?? null;
plan = identity.plan_type ?? null;
accountId = identity.account_id ?? null;
logger.debug('codex-auth.dashboard.decoded', 'Decoded auth profile summary', {
profileName: name,
hasEmail: email !== null,
hasPlan: plan !== null,
hasAccountId: accountId !== null,
});
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(
'codex-auth.dashboard.decode-error',
`Failed to decode auth for profile=${name}: ${msg}`
);
}
return {
name,
codexHome,
email,
plan,
accountId,
// lastUsed is set below by caller from registry metadata
lastUsed: null,
authValid,
};
}
/**
* Check whether auth.json has a parseable JWT payload, even if it has no
* display fields. A non-empty but malformed token is not valid auth.
* This sets authValid=true for valid-but-sparse tokens.
*/
function _hasStructurallyValidIdToken(authJsonPath: string): boolean {
try {
const raw = fs.readFileSync(authJsonPath, 'utf8');
const parsed = JSON.parse(raw) as { tokens?: { id_token?: string } };
const idToken = parsed?.tokens?.id_token;
return typeof idToken === 'string' && hasStructurallyValidIdToken(idToken);
} catch {
return false;
}
}
// ── Active resolution ───────────────────────────────────────────────────────
function resolveActive(registry: CodexProfileData): CodexAuthActiveProfile | null {
const instancesDir = getCodexInstancesDir();
// Precedence 1: explicit $CODEX_HOME set by env (ccsxp, manual)
const codexHome = (process.env.CODEX_HOME ?? '').trim();
if (codexHome) {
// Attempt reverse-map: does any registered profile's codexHome match?
const matchedName =
Object.keys(registry.profiles).find((name) => path.join(instancesDir, name) === codexHome) ??
null;
return {
name: matchedName,
source: 'explicit-codex-home',
codexHome,
};
}
// Precedence 2: $CCS_CODEX_PROFILE env var
const profileEnv = (process.env.CCS_CODEX_PROFILE ?? '').trim();
if (profileEnv) {
if (!Object.prototype.hasOwnProperty.call(registry.profiles, profileEnv)) {
logger.warn(
'codex-auth.dashboard.stale-env-profile',
'Ignoring CCS_CODEX_PROFILE because it is not registered',
{ profileName: profileEnv }
);
return null;
}
return {
name: profileEnv,
source: 'env',
codexHome: path.join(instancesDir, profileEnv),
};
}
// Precedence 3: registry default
const defaultProfile = registry.default;
if (defaultProfile) {
return {
name: defaultProfile,
source: 'default',
codexHome: path.join(instancesDir, defaultProfile),
};
}
// Precedence 4: no active profile (legacy ~/.codex mode)
return null;
}
// ── Core builder ────────────────────────────────────────────────────────────
async function buildSummary(): Promise<CodexAuthProfilesSummary> {
const registry = readRegistry();
const active = resolveActive(registry);
const profiles: CodexAuthProfileEntry[] = Object.entries(registry.profiles).map(
([name, meta]) => {
const entry = buildProfileEntry(name);
entry.lastUsed = meta.last_used ?? null;
return entry;
}
);
return {
active,
default: registry.default,
profiles,
};
}
// ── Public API ──────────────────────────────────────────────────────────────
/**
* Returns the codex-auth profiles summary, using a 5s in-memory cache.
* Tokens are never included in the returned object.
*/
export async function getCodexAuthProfilesSummary(): Promise<CodexAuthProfilesSummary> {
const now = Date.now();
const registrySignature = getRegistryCacheSignature();
if (cache && cache.expiresAt > now && cache.registrySignature === registrySignature) {
return cache.value;
}
const value = await buildSummary();
cache = { value, expiresAt: now + TTL_MS, registrySignature };
return value;
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Help text for `ccsx auth` command tree.
* ASCII-only. Includes ccsxp scope clarifier (H5).
*/
export function printCodexAuthHelp(): void {
process.stdout.write(`CCS Concurrent Codex Account Management
Usage
ccsx auth <command> [options]
Commands
create <name> Create a new Codex profile (idempotent)
login <name> Run \`codex login\` against the profile (auto-creates if missing)
switch <name> Set the persistent default Codex profile
use <name> Emit shell-eval exports to activate a profile in this shell only
show [name] List profiles or show details for one
remove <name> Delete a profile (auth.json + profile dir + registry entry)
import-default <name> Migrate legacy ~/.codex/auth.json into a new profile
Shell activation (per terminal)
bash/zsh: eval "$(ccsx auth use work)"
fish: ccsx auth use work | source
pwsh: ccsx auth use work | Invoke-Expression
Examples
ccsx auth create work
ccsx auth login work # OAuth in browser
ccsx auth create personal
ccsx auth login personal
eval "$(ccsx auth use work)" # terminal A
eval "$(ccsx auth use personal)" # terminal B
codex # each terminal uses its own account
ccsx auth show
ccsx auth switch personal # change persistent default
ccsx auth remove old --yes
Options
--yes, -y Skip confirmation (remove)
--force Re-link config.toml (create) | override default check (remove) |
overwrite existing profile (import-default)
--json JSON output (show)
--shell <s> Override shell detection (use): bash|zsh|fish|pwsh|cmd
--with-history Copy history.jsonl + sessions/ too (import-default, default: off)
--force-while-running Allow import-default even if Codex is running (risky)
Notes
Auth state (auth.json) and history.jsonl are isolated per profile.
config.toml is shared via symlink to ~/.codex/config.toml.
Default profile (switch) is persistent across shells.
Active profile (use) is per-terminal via CODEX_HOME / CCS_CODEX_PROFILE.
Note: This feature applies only to native \`codex\`. \`ccsxp\` ignores
CCS_CODEX_PROFILE and uses its own cliproxy pool.
`);
}
export function printCodexAuthUseHelp(): void {
process.stdout.write(`ccsx auth use — Activate a Codex profile in the current shell
Usage
ccsx auth use <name> [--shell <bash|zsh|fish|pwsh|cmd>]
Description
Emits shell-evalable export statements to stdout. Use within eval "$(...)"
only. Output to stdout is shell-evaluatable; do not pipe to other commands.
All errors and informational messages go to stderr so the eval is never
contaminated.
Shell evaluation
bash/zsh: eval "$(ccsx auth use work)"
fish: ccsx auth use work | source
pwsh: ccsx auth use work | Invoke-Expression
cmd: (not supported natively; use PowerShell)
Options
--shell <s> Override auto-detected shell: bash|zsh|fish|pwsh|cmd
Note: This profile applies only to native \`codex\`. \`ccsxp\` ignores
CCS_CODEX_PROFILE and uses its own cliproxy pool.
`);
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Codex auth command router.
*
* Exports runCodexAuth(argv) which routes argv[0] (the subcommand) to
* the appropriate handler. Returns an exit code (0 = success, non-zero = error).
*
* Phase 3 wires this into src/bin/codex-runtime.ts for argv[2]==='auth'.
*/
import { CodexProfileRegistry } from './codex-profile-registry';
import { printCodexAuthHelp } from './codex-auth-help';
import {
handleCreateCodex,
handleLoginCodex,
handleSwitchCodex,
handleUseCodex,
handleShowCodex,
handleRemoveCodex,
handleImportDefaultCodex,
} from './commands/index';
import type { CodexCommandContext } from './commands/types';
const packageJson = require('../../package.json') as { version: string };
/**
* Route a `ccsx auth <subcommand> [...args]` invocation.
*
* @param argv - Arguments after `auth`, e.g. ['create', 'work'] or ['--help']
* @returns Exit code (0 success, 1 user error, 2+ system error)
*/
export async function runCodexAuth(argv: string[]): Promise<number> {
const [subcommand, ...rest] = argv;
// Help / no-arg
if (!subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help') {
printCodexAuthHelp();
return 0;
}
// Version passthrough
if (subcommand === '--version' || subcommand === '-v') {
process.stdout.write(`ccsx auth ${packageJson.version}\n`);
return 0;
}
const registry = new CodexProfileRegistry();
const ctx: CodexCommandContext = {
registry,
version: packageJson.version,
};
try {
switch (subcommand) {
case 'create':
await handleCreateCodex(ctx, rest);
return 0;
case 'login':
await handleLoginCodex(ctx, rest);
return 0;
case 'switch':
await handleSwitchCodex(ctx, rest);
return 0;
case 'use':
await handleUseCodex(ctx, rest);
return 0;
case 'show':
await handleShowCodex(ctx, rest);
return 0;
case 'remove':
await handleRemoveCodex(ctx, rest);
return 0;
case 'import-default':
await handleImportDefaultCodex(ctx, rest);
return 0;
default:
process.stderr.write(`[X] Unknown command: ${subcommand}\n`);
process.stderr.write(` ccsx auth --help\n`);
return 1;
}
} catch (err) {
// Unhandled errors from handlers (e.g. process.exit called inside)
// These should be rare — handlers use exitWithError() which calls process.exit
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`[X] Unexpected error in ccsx auth ${subcommand}: ${msg}\n`);
return 1;
}
}
+121
View File
@@ -0,0 +1,121 @@
import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from '../services/logging';
import { getSharedCodexConfigPath } from './codex-profile-paths';
const logger = createLogger('codex-auth:symlink');
export interface EnsureSharedConfigSymlinkOptions {
overwriteRegularFile?: boolean;
}
/**
* Ensure <profileDir>/config.toml points at the shared ~/.codex/config.toml.
* Self-healing: recreates stale or missing symlinks. If symlink creation is
* unavailable, copies the shared config so the profile still has settings.
*
* @param profileDir - The per-profile directory (will be created if missing).
* @param sharedConfigPath - Override for the shared target path (used in tests
* to avoid touching real ~/.codex/config.toml). Defaults to getSharedCodexConfigPath().
*/
export function ensureSharedConfigSymlink(
profileDir: string,
sharedConfigPath?: string,
options: EnsureSharedConfigSymlinkOptions = {}
): void {
const targetPath = sharedConfigPath ?? getSharedCodexConfigPath();
const linkPath = path.join(profileDir, 'config.toml');
// Ensure profile directory exists
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
// Ensure shared config target parent directory exists
fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o700 });
// Create empty shared config if it doesn't exist — Codex will populate on first run
if (!fs.existsSync(targetPath)) {
fs.writeFileSync(targetPath, '', { mode: 0o600 });
logger.stage('dispatch', 'codex.shared-config.created', 'Created empty shared config.toml', {
path: targetPath,
});
}
// Inspect whatever currently exists at the link path
let existingStat: fs.Stats | null = null;
try {
existingStat = fs.lstatSync(linkPath);
} catch {
// ENOENT — nothing there yet, proceed to create
}
if (existingStat !== null) {
if (existingStat.isSymbolicLink()) {
const currentTarget = fs.readlinkSync(linkPath);
if (currentTarget === targetPath) {
// Already correct — idempotent return
logger.stage('dispatch', 'codex.symlink.ok', 'Shared config symlink already correct', {
link: linkPath,
});
return;
}
// Stale symlink — remove and re-create
fs.unlinkSync(linkPath);
logger.stage('dispatch', 'codex.symlink.repaired', 'Replaced stale symlink', {
link: linkPath,
was: currentTarget,
now: targetPath,
});
} else if (
options.overwriteRegularFile ||
isRegularConfigCopyUnmodified(linkPath, targetPath)
) {
// Explicit repair or unchanged fallback copy — replace with a shared symlink when possible.
process.stderr.write(
`[!] codex-auth: overwriting regular file at ${linkPath} with symlink to shared config.toml\n`
);
fs.unlinkSync(linkPath);
} else {
process.stderr.write(
`[!] codex-auth: preserving existing regular config.toml at ${linkPath}; ` +
`use ccsx auth create <name> --force to refresh it.\n`
);
logger.warn('codex-auth.symlink-regular-file-preserved', 'Preserved regular config.toml', {
link: linkPath,
target: targetPath,
});
return;
}
}
try {
fs.symlinkSync(targetPath, linkPath);
logger.stage('dispatch', 'codex.symlink.created', 'Created shared config symlink', {
link: linkPath,
target: targetPath,
});
} catch (err) {
copySharedConfigFallback(targetPath, linkPath, err);
}
}
function isRegularConfigCopyUnmodified(linkPath: string, targetPath: string): boolean {
try {
return fs.readFileSync(linkPath, 'utf8') === fs.readFileSync(targetPath, 'utf8');
} catch {
return false;
}
}
function copySharedConfigFallback(targetPath: string, linkPath: string, err: unknown): void {
fs.copyFileSync(targetPath, linkPath);
fs.chmodSync(linkPath, 0o600);
process.stderr.write(
`[!] codex-auth: symlink unavailable; copied shared config.toml to ${linkPath}. ` +
`Config edits won't propagate automatically.\n`
);
logger.warn('codex-auth.symlink-copy-fallback', 'Copied shared config after symlink failure', {
link: linkPath,
target: targetPath,
error: err instanceof Error ? err.message : String(err),
});
}
+33
View File
@@ -0,0 +1,33 @@
import * as path from 'path';
import * as os from 'os';
import { getCcsDir } from '../utils/config-manager';
import { getCodexProfileNameError } from './types';
export function getCodexAuthRegistryPath(): string {
return path.join(getCcsDir(), 'codex-profiles.yaml');
}
export function getCodexInstancesDir(): string {
return path.join(getCcsDir(), 'codex-instances');
}
export function resolveCodexProfileDir(name: string): string {
const nameError = getCodexProfileNameError(name);
if (nameError) {
throw new Error(nameError);
}
const instancesDir = path.resolve(getCodexInstancesDir());
const profileDir = path.resolve(path.join(instancesDir, name));
if (profileDir !== instancesDir && profileDir.startsWith(`${instancesDir}${path.sep}`)) {
return profileDir;
}
throw new Error('Profile directory resolved outside codex-instances.');
}
// Uses os.homedir() intentionally — this is the upstream Codex location,
// not a CCS-owned path. Tests must override the shared config path explicitly.
export function getSharedCodexConfigPath(): string {
return path.join(os.homedir(), '.codex', 'config.toml');
}
+369
View File
@@ -0,0 +1,369 @@
import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'js-yaml';
import * as lockfile from 'proper-lockfile';
import { createLogger } from '../services/logging';
import { getCodexAuthRegistryPath } from './codex-profile-paths';
import { getCcsDirSource } from '../utils/config-manager';
import { CODEX_PROFILE_SCHEMA_VERSION, getCodexProfileNameError } from './types';
import type { CodexProfileData, CodexProfileMetadata } from './types';
const logger = createLogger('codex-auth:registry');
const REGISTRY_LOCK_STALE_MS = 10000;
const REGISTRY_LOCK_RETRIES = 40;
const REGISTRY_LOCK_RETRY_DELAY_MS = 50;
function emptyRegistry(): CodexProfileData {
return { version: CODEX_PROFILE_SCHEMA_VERSION, default: null, profiles: {} };
}
export class CodexProfileRegistryReadError extends Error {
constructor(message: string) {
super(message);
this.name = 'CodexProfileRegistryReadError';
}
}
export function validateCodexProfileRegistryData(parsed: unknown): CodexProfileData {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('registry YAML root is not an object');
}
const data = parsed as Partial<CodexProfileData>;
if (!data.profiles || typeof data.profiles !== 'object' || Array.isArray(data.profiles)) {
throw new Error('registry YAML is missing an object profiles map');
}
if (data.default !== undefined && data.default !== null && typeof data.default !== 'string') {
throw new Error('registry YAML default must be a string or null');
}
if (typeof data.default === 'string') {
assertValidProfileName(data.default);
}
const profiles: Record<string, CodexProfileMetadata> = {};
for (const [name, profile] of Object.entries(data.profiles)) {
assertValidProfileName(name);
profiles[name] = validateProfileMetadata(name, profile);
}
if (
typeof data.default === 'string' &&
!Object.prototype.hasOwnProperty.call(profiles, data.default)
) {
throw new Error('registry YAML default profile is missing from profiles map');
}
return {
version: typeof data.version === 'string' ? data.version : CODEX_PROFILE_SCHEMA_VERSION,
default: data.default ?? null,
profiles,
};
}
function assertValidProfileName(name: string): void {
const nameError = getCodexProfileNameError(name);
if (nameError) {
throw new Error(`registry YAML contains invalid profile name "${name}": ${nameError}`);
}
}
function validateProfileMetadata(name: string, profile: unknown): CodexProfileMetadata {
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
throw new Error(`registry YAML profile "${name}" must be an object`);
}
const meta = profile as Partial<CodexProfileMetadata>;
if (meta.type !== 'codex') {
throw new Error(`registry YAML profile "${name}" must have type "codex"`);
}
if (typeof meta.created !== 'string') {
throw new Error(`registry YAML profile "${name}" must have a string created timestamp`);
}
if (meta.last_used !== null && typeof meta.last_used !== 'string') {
throw new Error(
`registry YAML profile "${name}" must have a string or null last_used timestamp`
);
}
if (meta.email !== undefined && typeof meta.email !== 'string') {
throw new Error(`registry YAML profile "${name}" email must be a string`);
}
if (
meta.plan_type !== undefined &&
meta.plan_type !== null &&
typeof meta.plan_type !== 'string'
) {
throw new Error(`registry YAML profile "${name}" plan_type must be a string or null`);
}
if (meta.account_id !== undefined && typeof meta.account_id !== 'string') {
throw new Error(`registry YAML profile "${name}" account_id must be a string`);
}
return meta as CodexProfileMetadata;
}
function registryDisplayPath(registryPath: string): string {
const [source] = getCcsDirSource();
const defaultRegistryPath = path.resolve(getCodexAuthRegistryPath());
if (path.resolve(registryPath) !== defaultRegistryPath) {
return path.basename(registryPath);
}
if (source === 'default') {
return process.platform === 'win32'
? '%USERPROFILE%\\.ccs\\codex-profiles.yaml'
: '~/.ccs/codex-profiles.yaml';
}
if (source === 'CCS_HOME' || source === 'scoped:CCS_HOME') {
return '$CCS_HOME/.ccs/codex-profiles.yaml';
}
if (source === 'CCS_DIR' || source === 'scoped:CCS_DIR') {
return '$CCS_DIR/codex-profiles.yaml';
}
return 'codex-profiles.yaml';
}
function safeRegistryReadMessage(err: unknown): string {
if ((err as { name?: unknown } | undefined)?.name === 'YAMLException') {
return 'registry YAML could not be parsed';
}
return err instanceof Error ? err.message : String(err);
}
function sleepSync(ms: number): void {
try {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
} catch {
const end = Date.now() + ms;
while (Date.now() < end) {
// Fall back for runtimes without Atomics.wait.
}
}
}
function isLockContentionError(err: unknown): boolean {
return (err as NodeJS.ErrnoException | undefined)?.code === 'ELOCKED';
}
/**
* Registry for codex auth profiles stored at ~/.ccs/codex-profiles.yaml.
*
* Writes are guarded by a registry-directory lock around the read-modify-write
* cycle, then persisted atomically with tmp file + POSIX rename.
*
* Constructor accepts an optional registryPath for test isolation.
*/
export class CodexProfileRegistry {
private readonly registryPath: string;
constructor(registryPath?: string) {
this.registryPath = registryPath ?? getCodexAuthRegistryPath();
this._cleanOrphanTmpFiles();
}
// ── private read/write ──────────────────────────────────────────────────
private _read(): CodexProfileData {
if (!fs.existsSync(this.registryPath)) {
return emptyRegistry();
}
try {
const raw = fs.readFileSync(this.registryPath, 'utf8');
return validateCodexProfileRegistryData(yaml.load(raw));
} catch (err) {
const msg = safeRegistryReadMessage(err);
const displayPath = registryDisplayPath(this.registryPath);
logger.warn(
'codex-auth.registry.read-failed',
`Registry at ${displayPath} could not be read safely; refusing empty-state rewrite: ${msg}`
);
throw new CodexProfileRegistryReadError(
`Codex profile registry at ${displayPath} could not be read safely: ${msg}. Refusing to rewrite it.`
);
}
}
private _write(data: CodexProfileData): void {
const dir = path.dirname(this.registryPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
// Unique tmp suffix avoids collisions on concurrent writes and orphan leaks
const tmpPath = `${this.registryPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
try {
fs.writeFileSync(tmpPath, yaml.dump(data, { indent: 2, lineWidth: -1 }), {
mode: 0o600,
});
fs.renameSync(tmpPath, this.registryPath);
} catch (err) {
if (fs.existsSync(tmpPath)) {
try {
fs.unlinkSync(tmpPath);
} catch {
// best-effort cleanup
}
}
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to write codex profile registry: ${msg}`);
}
}
private _withRegistryWriteLock<T>(callback: () => T): T {
const dir = path.dirname(this.registryPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
let release: (() => void) | undefined;
let lastLockError: unknown;
for (let attempt = 0; attempt <= REGISTRY_LOCK_RETRIES; attempt++) {
try {
release = lockfile.lockSync(dir, { stale: REGISTRY_LOCK_STALE_MS }) as () => void;
break;
} catch (err) {
if (!isLockContentionError(err) || attempt === REGISTRY_LOCK_RETRIES) {
throw err;
}
lastLockError = err;
sleepSync(REGISTRY_LOCK_RETRY_DELAY_MS);
}
}
if (!release) {
const msg = lastLockError instanceof Error ? lastLockError.message : 'unknown lock error';
throw new Error(`Failed to acquire codex profile registry lock: ${msg}`);
}
try {
return callback();
} finally {
try {
release();
} catch {
// Best-effort release.
}
}
}
// Best-effort cleanup of orphan tmp files older than 1 hour (H3 mitigation).
private _cleanOrphanTmpFiles(): void {
const dir = path.dirname(this.registryPath);
const base = path.basename(this.registryPath);
if (!fs.existsSync(dir)) return;
try {
const oneHourAgo = Date.now() - 60 * 60 * 1000;
for (const entry of fs.readdirSync(dir)) {
if (!entry.startsWith(`${base}.tmp.`)) continue;
const full = path.join(dir, entry);
try {
const stat = fs.statSync(full);
if (stat.mtimeMs < oneHourAgo) {
fs.unlinkSync(full);
}
} catch {
// ignore per-file errors
}
}
} catch {
// ignore cleanup failure silently
}
}
// ── CRUD ────────────────────────────────────────────────────────────────
createProfile(name: string, meta: Partial<CodexProfileMetadata> = {}): void {
assertValidProfileName(name);
this._withRegistryWriteLock(() => {
const data = this._read();
if (data.profiles[name]) {
throw new Error(`Profile already exists: ${name}`);
}
data.profiles[name] = {
type: 'codex',
created: new Date().toISOString(),
last_used: null,
...meta,
} as CodexProfileMetadata;
this._write(data);
logger.stage('route', 'codex-auth.profile.created', 'Codex profile created', { name });
});
}
getProfile(name: string): CodexProfileMetadata {
assertValidProfileName(name);
const data = this._read();
const profile = data.profiles[name];
if (!profile) {
throw new Error(`Profile not found: ${name}`);
}
return profile;
}
updateProfile(name: string, partial: Partial<CodexProfileMetadata>): void {
assertValidProfileName(name);
this._withRegistryWriteLock(() => {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
data.profiles[name] = { ...data.profiles[name], ...partial } as CodexProfileMetadata;
this._write(data);
});
}
removeProfile(name: string, options: { forceDefault?: boolean } = {}): void {
assertValidProfileName(name);
this._withRegistryWriteLock(() => {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
if (data.default === name && Object.keys(data.profiles).length > 1 && !options.forceDefault) {
throw new Error('Cannot remove default profile while other profiles exist without --force');
}
delete data.profiles[name];
if (data.default === name) {
data.default = null;
}
this._write(data);
logger.stage('cleanup', 'codex-auth.profile.deleted', 'Codex profile removed', { name });
});
}
listProfiles(): string[] {
return Object.keys(this._read().profiles);
}
hasProfile(name: string): boolean {
if (getCodexProfileNameError(name)) return false;
return Object.prototype.hasOwnProperty.call(this._read().profiles, name);
}
// ── Default pointer ──────────────────────────────────────────────────────
getDefault(): string | null {
return this._read().default;
}
setDefault(name: string): void {
assertValidProfileName(name);
this._withRegistryWriteLock(() => {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
data.default = name;
this._write(data);
});
}
clearDefault(): void {
this._withRegistryWriteLock(() => {
const data = this._read();
data.default = null;
this._write(data);
});
}
touchProfile(name: string): void {
this.updateProfile(name, { last_used: new Date().toISOString() });
}
}
+188
View File
@@ -0,0 +1,188 @@
/**
* 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 } 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} (re-linking config.toml)`));
_ensureSymlinkSafe(profileDir, true);
console.log(ok(`Profile config.toml re-linked.`));
console.log(` Profile dir: ${profileDir}`);
} else {
_ensureSymlinkSafe(profileDir);
console.log(info(`Profile already exists: ${profileName}`));
console.log(ok(`Profile config.toml is 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 });
_ensureSymlinkSafe(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);
}
}
async function _spawnLogin(
profileName: string,
profileDir: string,
ctx: CodexCommandContext
): Promise<void> {
const codexCli = detectCodexCli();
if (!codexCli) {
process.stderr.write(`[!] codex CLI not found — skipping auto-login.\n`);
process.stderr.write(` Install: npm i -g @openai/codex\n`);
process.stderr.write(` Then run: ccsx auth login ${profileName}\n`);
return;
}
console.log('');
console.log(`Next step: logging in to Codex...`);
console.log(` CODEX_HOME=${profileDir}`);
console.log('');
const loginResult = await new Promise<{ code: number; error?: string }>((resolve) => {
const child = childProcess.spawn(codexCli, ['login'], {
stdio: 'inherit',
env: { ...process.env, CODEX_HOME: profileDir },
windowsHide: true,
});
child.on('error', (err) => {
process.stderr.write(`[X] Failed to execute codex: ${err.message}\n`);
resolve({ code: ExitCode.BINARY_ERROR, error: err.message });
});
child.on('exit', (code) => {
resolve({ code: code ?? 1 });
});
});
if (loginResult.error) {
exitWithError(`codex login failed to start: ${loginResult.error}`, ExitCode.BINARY_ERROR);
return;
}
const authJsonPath = path.join(profileDir, 'auth.json');
if (loginResult.code === 0 && fs.existsSync(authJsonPath)) {
const identity = decodeAccountIdentity(authJsonPath);
ctx.registry.updateProfile(profileName, {
last_used: new Date().toISOString(),
email: identity.email,
plan_type: identity.plan_type ?? null,
account_id: identity.account_id,
});
const emailStr = identity.email ? ` as ${identity.email}` : '';
const planStr = identity.plan_type ? ` (plan: ${identity.plan_type})` : '';
console.log(ok(`Logged in${emailStr}${planStr}`));
} else if (loginResult.code === 0) {
process.stderr.write(
`[!] codex login exited cleanly but no auth.json. Skipping registry update.\n`
);
exitWithError('codex login completed without auth.json', ExitCode.AUTH_ERROR);
} else {
process.stderr.write(
`[!] Login cancelled or failed. Profile ${profileName} remains unauthenticated.\n`
);
process.stderr.write(` Retry: ccsx auth login ${profileName}\n`);
exitWithError('codex login failed', ExitCode.AUTH_ERROR);
}
}
@@ -0,0 +1,426 @@
/**
* codex-auth import-default command.
*
* Migrates legacy ~/.codex/auth.json into a named profile (non-destructive).
* Implements C3 torn-write protection: read-with-retry + JWT-shape validation
* + pgrep Codex-running detection + atomic write.
*
* Usage: ccsx auth import-default <name> [--with-history] [--force] [--force-while-running]
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as childProcess from 'child_process';
import { createLogger } from '../../services/logging';
import { ok } from '../../utils/ui';
import { initUI } from '../../utils/ui';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { resolveCodexProfileDir, ensureSharedConfigSymlink, decodeIdToken } from '../index';
import { hasStructurallyValidIdToken } from '../decode-id-token';
import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types';
import type { CodexCommandContext } from './types';
const logger = createLogger('codex-auth:cmd:import-default');
// Maximum retries for torn-write detection (C3)
const MAX_READ_RETRIES = 3;
const RETRY_DELAY_MS = 100;
// CLIProxy format marker (reject these with a clear message)
const CLIPROXY_TYPE_MARKER = 'type';
const IMPORT_DEFAULT_USAGE =
'ccsx auth import-default <name> [--with-history] [--force] [--force-while-running]';
// ── helpers ──────────────────────────────────────────────────────────────────
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Detect a running `codex` process via pgrep + ps validation (best-effort, never throws).
* Returns the PID string if found, null otherwise.
*/
function detectCodexRunning(): string | null {
try {
const result = childProcess.spawnSync('pgrep', ['-f', 'codex'], {
encoding: 'utf8',
timeout: 2000,
});
if (result.status !== 0 || !result.stdout || result.stdout.trim().length === 0) {
return null;
}
const pids = parsePgrepPids(result.stdout);
if (pids.length === 0) return null;
const psResult = childProcess.spawnSync(
'ps',
['-p', pids.join(','), '-o', 'pid=', '-o', 'command='],
{
encoding: 'utf8',
timeout: 2000,
}
);
if (psResult.status !== 0 || !psResult.stdout) return null;
return selectCodexPidFromPsOutput(psResult.stdout, pids);
} catch {
return null;
}
}
function parsePgrepPids(stdout: string): string[] {
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter((pid) => /^\d+$/.test(pid) && pid !== String(process.pid));
}
function selectCodexPidFromPsOutput(stdout: string, candidatePids: string[]): string | null {
const candidates = new Set(candidatePids);
for (const line of stdout.split(/\r?\n/)) {
const match = line.match(/^\s*(\d+)\s+(.+?)\s*$/);
if (!match) continue;
const [, pid, command] = match;
if (!pid || !command || !candidates.has(pid)) continue;
if (isLikelyCodexProcessCommand(command)) return pid;
}
return null;
}
function isLikelyCodexProcessCommand(command: string): boolean {
const executable = firstCommandToken(command);
if (isCodexExecutableToken(executable)) {
return true;
}
const normalized = command.replace(/\\/g, '/').toLowerCase();
if (normalized.includes('/@openai/codex/')) {
return true;
}
return command.split(/\s+/).some((token) => {
const tokenName = executableTokenBasename(token);
return tokenName === 'codex.js' || isCodexExecutableName(tokenName);
});
}
function firstCommandToken(command: string): string {
const trimmed = command.trim();
const quoted = trimmed.match(/^"([^"]+)"/);
if (quoted?.[1]) return quoted[1];
return trimmed.split(/\s+/)[0] ?? '';
}
function isCodexExecutableToken(token: string): boolean {
return isCodexExecutableName(executableTokenBasename(token));
}
function executableTokenBasename(token: string): string {
return path.basename(token.replace(/^["']|["']$/g, '').replace(/\\/g, '/')).toLowerCase();
}
function isCodexExecutableName(name: string): boolean {
return ['codex', 'codex.exe', 'codex.cmd', 'codex.ps1'].includes(name);
}
/**
* Read and validate auth.json with retry on torn-write (C3).
* Returns parsed auth JSON or throws on persistent failure.
*/
async function readAuthJsonSafe(authSrcPath: string): Promise<Record<string, unknown>> {
let lastErr: Error | null = null;
for (let attempt = 0; attempt < MAX_READ_RETRIES; attempt++) {
try {
const buf = fs.readFileSync(authSrcPath, 'utf8');
const parsed = JSON.parse(buf) as Record<string, unknown>;
// Reject cliproxy-format auth files
if (
typeof parsed[CLIPROXY_TYPE_MARKER] === 'string' &&
(parsed[CLIPROXY_TYPE_MARKER] === 'codex' ||
parsed[CLIPROXY_TYPE_MARKER] === 'anthropic' ||
parsed[CLIPROXY_TYPE_MARKER] === 'gemini')
) {
throw new Error(
`CLIPROXY_FORMAT: Source is a CLIProxy auth file (type="${parsed[CLIPROXY_TYPE_MARKER]}"); use \`ccs cliproxy ...\` instead.`
);
}
// Validate JWT shape: must have tokens.id_token with a parseable JWT payload.
const tokens = parsed['tokens'] as Record<string, unknown> | undefined;
if (tokens) {
const idToken = tokens['id_token'];
if (typeof idToken === 'string' && idToken.length > 0) {
if (!hasStructurallyValidIdToken(idToken)) {
// Torn write mid-JWT — retry
throw new Error('TORN_JWT: id_token payload is not parseable');
}
}
}
return parsed;
} catch (err) {
if (err instanceof Error && err.message.startsWith('CLIPROXY_FORMAT:')) {
throw err; // never retry CLIProxy format rejection
}
lastErr = err instanceof Error ? err : new Error(String(err));
logger.warn(
'codex-auth.import-default.torn-read',
`Read attempt ${attempt + 1}/${MAX_READ_RETRIES} failed: ${lastErr.message}`
);
if (attempt < MAX_READ_RETRIES - 1) {
await sleep(RETRY_DELAY_MS);
}
}
}
throw new Error(
`Failed to read a valid auth.json after ${MAX_READ_RETRIES} attempts: ${lastErr?.message ?? 'unknown'}`
);
}
/**
* Atomic copy: write to tmp.<pid>.<rand>, fsync, rename to dest.
* Preserves 0600 permissions.
*/
function atomicWriteFile(dest: string, content: string): void {
const tmpPath = `${dest}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
try {
fs.writeFileSync(tmpPath, content, { mode: 0o600 });
// fsync via close-and-reopen pattern (Bun/Node doesn't expose fd fsync easily)
const fd = fs.openSync(tmpPath, 'r');
fs.fsyncSync(fd);
fs.closeSync(fd);
fs.renameSync(tmpPath, dest);
} catch (err) {
try {
fs.unlinkSync(tmpPath);
} catch {
// best-effort cleanup
}
throw err;
}
}
/**
* Copy a file if it exists; silently skip if not.
* Returns 'copied' | 'missing'.
*/
function copyIfPresent(src: string, dest: string): 'copied' | 'missing' {
if (!fs.existsSync(src)) return 'missing';
const content = fs.readFileSync(src, 'utf8');
atomicWriteFile(dest, content);
return 'copied';
}
/**
* Recursively copy a directory if it exists.
* Returns count of files copied, or -1 if dir missing.
*/
function copyDirIfPresent(srcDir: string, destDir: string): number {
if (!fs.existsSync(srcDir)) return -1;
fs.mkdirSync(destDir, { recursive: true, mode: 0o700 });
let count = 0;
for (const entry of fs.readdirSync(srcDir)) {
const srcEntry = path.join(srcDir, entry);
const destEntry = path.join(destDir, entry);
const stat = fs.lstatSync(srcEntry);
if (stat.isDirectory()) {
count += copyDirIfPresent(srcEntry, destEntry);
} else if (stat.isFile()) {
fs.copyFileSync(srcEntry, destEntry);
count++;
}
}
return count;
}
// ── main command ──────────────────────────────────────────────────────────────
export interface ImportDefaultArgs {
name: string;
withHistory: boolean;
force: boolean;
forceWhileRunning: boolean;
}
function parseImportDefaultArgs(rawArgs: string[]): ImportDefaultArgs | null {
// --with-history, --force, --force-while-running are distinct flags
const parsed = parseArgs(rawArgs);
const unknownFlags = parsed.unknownFlags?.filter(
(flag) => flag !== '--with-history' && flag !== '--force-while-running'
);
rejectUnsupportedOptions({ ...parsed, unknownFlags }, IMPORT_DEFAULT_USAGE, { force: true });
const withHistory = rawArgs.includes('--with-history');
const forceWhileRunning = rawArgs.includes('--force-while-running');
if (!parsed.profileName) return null;
return {
name: parsed.profileName,
withHistory,
force: parsed.force ?? false,
forceWhileRunning,
};
}
export async function handleImportDefaultCodex(
ctx: CodexCommandContext,
rawArgs: string[]
): Promise<void> {
await initUI();
const args = parseImportDefaultArgs(rawArgs);
if (!args) {
console.log(`Usage: ${IMPORT_DEFAULT_USAGE}`);
exitWithError('Profile name required', ExitCode.PROFILE_ERROR);
return;
}
const nameError = getProfileNameError(args.name);
if (nameError) {
exitWithError(nameError, ExitCode.PROFILE_ERROR);
return;
}
// Resolve legacy Codex home — LEGACY_CODEX_HOME env allows test hermeticity (D decision)
const legacyCodexHome = process.env['LEGACY_CODEX_HOME'] ?? path.join(os.homedir(), '.codex');
const legacyAuthPath = path.join(legacyCodexHome, 'auth.json');
// Check legacy auth.json exists
if (!fs.existsSync(legacyAuthPath)) {
console.log(` Use \`ccsx auth login ${args.name}\` to authenticate a new profile instead.`);
exitWithError('No legacy auth.json', ExitCode.PROFILE_ERROR);
return;
}
const { registry } = ctx;
// Profile collision check
if (registry.hasProfile(args.name) && !args.force) {
console.log(` Use --force to overwrite (a .bak-<ts> backup will be created).`);
exitWithError('Profile already exists', ExitCode.PROFILE_ERROR);
return;
}
// Detect Codex running (C3)
const codexPid = detectCodexRunning();
if (codexPid && !args.forceWhileRunning) {
process.stderr.write(
`[!] Codex appears to be running (PID ${codexPid}). A token refresh may be in flight.\n`
);
process.stderr.write(
` Quit Codex first, then re-run import-default. Or pass --force-while-running to proceed anyway.\n`
);
exitWithError('Codex is running', ExitCode.PROFILE_ERROR);
return;
}
if (codexPid && args.forceWhileRunning) {
process.stderr.write(
`[!] Proceeding with Codex running (--force-while-running). Be aware a refresh may race.\n`
);
}
// Read + validate source (C3 torn-write protection)
let authData: Record<string, unknown>;
try {
authData = await readAuthJsonSafe(legacyAuthPath);
} catch (err) {
if (err instanceof Error && err.message.startsWith('CLIPROXY_FORMAT:')) {
exitWithError(err.message, ExitCode.PROFILE_ERROR);
return;
}
const msg = err instanceof Error ? err.message : String(err);
exitWithError(msg, ExitCode.GENERAL_ERROR);
return;
}
const profileDir = resolveCodexProfileDir(args.name);
// Create dir
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
const destAuthPath = path.join(profileDir, 'auth.json');
// Backup existing auth.json if --force overwrite
if (args.force && fs.existsSync(destAuthPath)) {
const bakPath = `${destAuthPath}.bak-${Date.now()}`;
fs.copyFileSync(destAuthPath, bakPath);
process.stderr.write(`[i] Backed up existing auth.json to ${path.basename(bakPath)}\n`);
}
// Atomic write (C3)
atomicWriteFile(destAuthPath, JSON.stringify(authData, null, 2));
logger.stage('dispatch', 'codex-auth.import-default.copied', 'Copied auth.json to profile', {
name: args.name,
dest: destAuthPath,
});
// Optional: copy history + sessions (D8 default false)
let historyStatus: string;
let sessionsStatus: string;
if (args.withHistory) {
const legacyHistoryPath = path.join(legacyCodexHome, 'history.jsonl');
const destHistoryPath = path.join(profileDir, 'history.jsonl');
const historyCopied = copyIfPresent(legacyHistoryPath, destHistoryPath);
historyStatus = historyCopied === 'copied' ? 'copied' : 'not present';
const legacySessionsDir = path.join(legacyCodexHome, 'sessions');
const destSessionsDir = path.join(profileDir, 'sessions');
const sessionCount = copyDirIfPresent(legacySessionsDir, destSessionsDir);
sessionsStatus = sessionCount >= 0 ? `copied ${sessionCount} files` : 'not present';
} else {
historyStatus = 'not requested';
sessionsStatus = 'not requested';
}
// Ensure shared config symlink (reuse Phase 1 helper)
try {
ensureSharedConfigSymlink(profileDir);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn('codex-auth.import-default.symlink-failed', 'Symlink creation failed', {
profileDir,
error: msg,
});
exitWithError(`Failed to prepare profile config.toml: ${msg}`, ExitCode.CONFIG_ERROR);
return;
}
// Decode email for display (best-effort)
const tokens = authData['tokens'] as Record<string, unknown> | undefined;
const idToken = typeof tokens?.['id_token'] === 'string' ? tokens['id_token'] : null;
const identity = idToken ? decodeIdToken(idToken) : {};
const emailDisplay = identity.email ?? '(unknown)';
// Register in registry
if (registry.hasProfile(args.name)) {
registry.updateProfile(args.name, {
last_used: new Date().toISOString(),
email: identity.email,
plan_type: identity.plan_type ?? null,
account_id: identity.account_id,
});
} else {
registry.createProfile(args.name, {
created: new Date().toISOString(),
last_used: new Date().toISOString(),
email: identity.email,
plan_type: identity.plan_type ?? null,
account_id: identity.account_id,
});
}
// Print summary
console.log(ok(`Imported legacy ${legacyAuthPath} -> profile '${args.name}'`));
console.log(` Email : ${emailDisplay}`);
console.log(` History : ${historyStatus}`);
console.log(` Sessions: ${sessionsStatus}`);
console.log(` Next : ccsx auth switch ${args.name}`);
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Barrel export for codex-auth command handlers.
*/
export { handleCreateCodex } from './create-command';
export { handleLoginCodex } from './login-command';
export { handleSwitchCodex } from './switch-command';
export { handleUseCodex } from './use-command';
export { handleShowCodex } from './show-command';
export { handleRemoveCodex } from './remove-command';
export { handleImportDefaultCodex } from './import-default-command';
export type { CodexCommandContext, CodexAuthArgs, CodexProfileOutput } from './types';
export {
parseArgs,
rejectUnsupportedOptions,
isValidCodexProfileName,
getProfileNameError,
formatRelativeTime,
} from './types';
+140
View File
@@ -0,0 +1,140 @@
/**
* codex-auth login command.
* Spawns `codex login` with CODEX_HOME pinned to the profile dir.
* Auto-creates profile if it doesn't exist yet.
* Updates registry with email/plan from JWT after successful login.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as childProcess from 'child_process';
import { createLogger } from '../../services/logging';
import { initUI, info, ok } from '../../utils/ui';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { resolveCodexProfileDir, ensureSharedConfigSymlink } from '../index';
import { decodeAccountIdentity } from '../codex-account-identity';
import { detectCodexCli } from '../../targets/codex-detector';
import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types';
import type { CodexProfileMetadata } from '../types';
import type { CodexCommandContext } from './types';
const logger = createLogger('codex-auth:cmd:login');
export async function handleLoginCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
await initUI();
const parsed = parseArgs(args);
rejectUnsupportedOptions(parsed, 'ccsx auth login <name>');
const { profileName } = parsed;
if (!profileName) {
console.log('Usage: ccsx auth login <name>');
exitWithError('Profile name required', ExitCode.PROFILE_ERROR);
return;
}
const nameError = getProfileNameError(profileName);
if (nameError) {
exitWithError(nameError, ExitCode.PROFILE_ERROR);
return;
}
const { registry } = ctx;
const profileDir = resolveCodexProfileDir(profileName);
// Auto-create profile if missing
if (!registry.hasProfile(profileName)) {
console.log(info(`Auto-creating profile ${profileName}`));
ensureProfileDirReady(profileDir);
registry.createProfile(profileName, {
created: new Date().toISOString(),
last_used: null,
});
}
const codexCli = detectCodexCli();
if (!codexCli) {
console.log('');
console.log('Install:');
console.log(' npm i -g @openai/codex');
console.log(' # or follow https://github.com/openai/codex#install');
console.log('');
console.log(`After installing, re-run:`);
console.log(` ccsx auth login ${profileName}`);
exitWithError('codex CLI not found', ExitCode.BINARY_ERROR);
return;
}
// Ensure profile dir exists (may have been deleted)
if (!fs.existsSync(profileDir)) {
ensureProfileDirReady(profileDir);
}
const authJsonPath = path.join(profileDir, 'auth.json');
const authJsonExisted = fs.existsSync(authJsonPath);
console.log(info(`Launching codex login for profile: ${profileName}`));
console.log(` CODEX_HOME=${profileDir}`);
console.log('');
const exitCode = await new Promise<number>((resolve) => {
const child = childProcess.spawn(codexCli, ['login'], {
stdio: 'inherit',
env: { ...process.env, CODEX_HOME: profileDir },
windowsHide: true,
});
child.on('error', (err) => {
process.stderr.write(`[X] Failed to execute codex: ${err.message}\n`);
logger.warn('codex-auth.login.spawn-error', 'Spawn failed', { error: err.message });
resolve(ExitCode.BINARY_ERROR);
});
child.on('exit', (code) => {
resolve(code ?? 1);
});
});
if (exitCode === 0 && fs.existsSync(authJsonPath)) {
const identity = decodeAccountIdentity(authJsonPath);
const now = new Date().toISOString();
const metadataUpdate: Partial<CodexProfileMetadata> = { last_used: now };
if (identity.email !== undefined) metadataUpdate.email = identity.email;
if (identity.plan_type !== undefined) metadataUpdate.plan_type = identity.plan_type;
if (identity.account_id !== undefined) metadataUpdate.account_id = identity.account_id;
registry.updateProfile(profileName, metadataUpdate);
const emailStr = identity.email ?? '<unknown>';
const planStr = identity.plan_type ? ` (plan: ${identity.plan_type})` : '';
console.log(ok(`Logged in as ${emailStr}${planStr}`));
console.log(` Profile: ${profileName}`);
console.log(` Updated: ${now}`);
} else if (exitCode === 0) {
process.stderr.write(
'[!] codex login exited cleanly but no auth.json. Skipping registry update.\n'
);
} else {
if (!authJsonExisted) {
process.stderr.write(
`[!] Login cancelled or failed. Profile ${profileName} remains unauthenticated.\n`
);
} else {
process.stderr.write('[!] Login failed. Previous credentials may still be valid.\n');
}
process.exit(ExitCode.AUTH_ERROR);
}
}
function ensureProfileDirReady(profileDir: string): void {
try {
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
ensureSharedConfigSymlink(profileDir);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn('codex-auth.login.config-repair-failed', 'Config repair failed', {
profileDir,
error: msg,
});
exitWithError(`Failed to prepare profile config.toml: ${msg}`, ExitCode.CONFIG_ERROR);
}
}
+241
View File
@@ -0,0 +1,241 @@
/**
* codex-auth remove command.
* Deletes profile dir + registry entry.
* Guards: refuses to remove the default when others exist (unless --force).
* Best-effort warning if CCS_CODEX_PROFILE points to it.
* --yes skips confirmation prompt.
*/
import * as fs from 'fs';
import * as path from 'path';
import { initUI, info, ok } from '../../utils/ui';
import { InteractivePrompt } from '../../utils/prompt';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { resolveCodexProfileDir } from '../codex-profile-paths';
import { decodeAccountIdentity } from '../codex-account-identity';
import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types';
import type { CodexCommandContext } from './types';
import type { CodexProfileMetadata } from '../types';
export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
await initUI();
const parsed = parseArgs(args);
rejectUnsupportedOptions(parsed, 'ccsx auth remove <name> [--yes|-y] [--force]', {
yes: true,
force: true,
});
const { profileName, yes, force } = parsed;
if (!profileName) {
console.log('Usage: ccsx auth remove <name> [--yes|-y] [--force]');
exitWithError('Profile name required', ExitCode.PROFILE_ERROR);
return;
}
const nameError = getProfileNameError(profileName);
if (nameError) {
exitWithError(nameError, ExitCode.PROFILE_ERROR);
return;
}
const { registry } = ctx;
if (!registry.hasProfile(profileName)) {
exitWithError(`Profile not found: ${profileName}`, ExitCode.PROFILE_ERROR);
return;
}
const allProfiles = registry.listProfiles();
const isDefault = registry.getDefault() === profileName;
// Default guard: refuse if others exist and no --force
if (isDefault && allProfiles.length > 1 && !force) {
const others = allProfiles.filter((n) => n !== profileName);
console.log(` Switch first: ccsx auth switch ${others[0]}`);
console.log(` Or override : ccsx auth remove ${profileName} --force`);
exitWithError('Cannot remove default profile', ExitCode.PROFILE_ERROR);
return;
}
// Active-env warning (best-effort — can only see current shell)
if (process.env.CCS_CODEX_PROFILE === profileName) {
process.stderr.write(`[!] CCS_CODEX_PROFILE in this shell points to "${profileName}".\n`);
process.stderr.write(` After removal, codex sessions in this shell will fail until you\n`);
const others = allProfiles.filter((n) => n !== profileName);
if (others.length > 0) {
process.stderr.write(
` run: eval "$(ccsx auth use ${others[0]})" or unset CCS_CODEX_PROFILE.\n`
);
} else {
process.stderr.write(` run: unset CCS_CODEX_PROFILE\n`);
}
}
const profileDir = resolveCodexProfileDir(profileName);
const authJsonPath = path.join(profileDir, 'auth.json');
const authExists = fs.existsSync(authJsonPath);
const dirExists = fs.existsSync(profileDir);
// Load cached email for impact summary
const meta = registry.getProfile(profileName);
const originalDefault = registry.getDefault();
let emailStr = meta.email ?? null;
if (!emailStr && authExists) {
const identity = decodeAccountIdentity(authJsonPath);
emailStr = identity.email ?? null;
}
// Ghost case: dir already gone
if (!dirExists) {
process.stderr.write(`[!] Profile dir was already missing; removing registry entry only.\n`);
try {
registry.removeProfile(profileName, { forceDefault: force });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
exitWithError(
`Profile registry update failed; profile dir was already missing.\n ${msg}`,
ExitCode.GENERAL_ERROR
);
return;
}
console.log(ok(`Profile removed: ${profileName}`));
return;
}
// Impact summary
console.log(`Profile "${profileName}" will be removed.`);
console.log(` Profile dir : ${profileDir}`);
console.log(` auth.json : ${authExists ? 'present (will be deleted)' : 'not found'}`);
console.log(` Email : ${emailStr ?? '<unknown>'}`);
console.log('');
// Confirm unless --yes
if (!yes) {
const confirmed = await InteractivePrompt.confirm('Delete this profile?', {
default: false,
});
if (!confirmed) {
console.log(info('Cancelled.'));
return;
}
}
const stagedDeleteDir = `${profileDir}.deleting.${process.pid}.${Math.random()
.toString(36)
.slice(2)}`;
const preservationDir = `${profileDir}.preserved.${process.pid}.${Math.random()
.toString(36)
.slice(2)}`;
try {
fs.renameSync(profileDir, stagedDeleteDir);
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code === 'EACCES') {
exitWithError('Permission denied', ExitCode.GENERAL_ERROR);
return;
}
throw err;
}
try {
fs.cpSync(stagedDeleteDir, preservationDir, { recursive: true, errorOnExist: true });
} catch (err) {
const restored = _restoreProfileDir(stagedDeleteDir, profileDir);
_removePathBestEffort(preservationDir);
const preservedPath = restored ? profileDir : stagedDeleteDir;
const msg = err instanceof Error ? err.message : String(err);
exitWithError(
`Profile data delete preparation failed; profile data was preserved at ${preservedPath}.\n ${msg}`,
ExitCode.GENERAL_ERROR
);
return;
}
try {
registry.removeProfile(profileName, { forceDefault: force });
} catch (err) {
const restored = _restoreProfileDir(stagedDeleteDir, profileDir);
_removePathBestEffort(preservationDir);
const preservedPath = restored ? profileDir : stagedDeleteDir;
const msg = err instanceof Error ? err.message : String(err);
exitWithError(
`Profile registry update failed; profile data was preserved at ${preservedPath}.\n ${msg}`,
ExitCode.GENERAL_ERROR
);
return;
}
try {
fs.rmSync(stagedDeleteDir, { recursive: true, force: true });
fs.rmSync(preservationDir, { recursive: true, force: true });
} catch (err) {
const restoreSource = fs.existsSync(preservationDir) ? preservationDir : stagedDeleteDir;
const restoredDir = _restoreProfileDir(restoreSource, profileDir);
const restoredRegistry = _restoreRegistryEntry(registry, profileName, meta, originalDefault);
if (restoredDir && restoreSource === preservationDir) {
_removePathBestEffort(stagedDeleteDir);
}
const preservedPath = restoredDir ? profileDir : stagedDeleteDir;
const msg = err instanceof Error ? err.message : String(err);
const registryNote = restoredRegistry
? 'Profile registry entry was restored.'
: 'Profile registry entry could not be restored automatically.';
exitWithError(
`Profile data delete failed; profile data was preserved at ${preservedPath}. ${registryNote}\n ${msg}`,
ExitCode.GENERAL_ERROR
);
return;
}
console.log(ok(`Profile removed: ${profileName}`));
}
function _removePathBestEffort(targetPath: string): void {
try {
fs.rmSync(targetPath, { recursive: true, force: true });
} catch {
// best-effort cleanup after data has already been preserved elsewhere
}
}
function _restoreRegistryEntry(
registry: CodexCommandContext['registry'],
profileName: string,
meta: CodexProfileMetadata,
originalDefault: string | null
): boolean {
try {
if (registry.hasProfile(profileName)) {
registry.updateProfile(profileName, meta);
} else {
registry.createProfile(profileName, meta);
}
if (originalDefault === profileName) {
registry.setDefault(profileName);
}
return true;
} catch {
process.stderr.write(
`[!] Profile data delete failed and automatic registry restore failed for "${profileName}".\n`
);
return false;
}
}
function _restoreProfileDir(stagedDeleteDir: string, profileDir: string): boolean {
try {
if (fs.existsSync(stagedDeleteDir) && !fs.existsSync(profileDir)) {
fs.renameSync(stagedDeleteDir, profileDir);
return true;
}
return fs.existsSync(profileDir);
} catch {
process.stderr.write(
`[!] Registry update failed and automatic restore failed. Profile data remains at ${stagedDeleteDir}.\n`
);
return false;
}
}
+127
View File
@@ -0,0 +1,127 @@
/**
* codex-auth show command.
* List mode: table of all profiles with STATE column.
* Detail mode: delegated to show-detail-view.ts.
* --json: machine-readable output.
* D14: active(missing) row at top when CCS_CODEX_PROFILE points to deleted profile.
*/
import * as fs from 'fs';
import * as path from 'path';
import { initUI, info, table } from '../../utils/ui';
import { resolveCodexProfileDir } from '../codex-profile-paths';
import { decodeAccountIdentity } from '../codex-account-identity';
import { showProfileDetail } from './show-detail-view';
import { parseArgs, rejectUnsupportedOptions, formatRelativeTime } from './types';
import type { CodexCommandContext, CodexProfileOutput } from './types';
import type { CodexAccountIdentity } from '../types';
export async function handleShowCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
await initUI();
const parsed = parseArgs(args);
rejectUnsupportedOptions(parsed, 'ccsx auth show [name] [--json]', { json: true });
const { profileName, json } = parsed;
if (profileName) {
return showProfileDetail(profileName, ctx, !!json);
}
return _showList(ctx, !!json);
}
// ── List view ─────────────────────────────────────────────────────────────────
function _showList(ctx: CodexCommandContext, json: boolean): void {
const { registry } = ctx;
const names = registry.listProfiles();
const defaultName = registry.getDefault();
const activeName = process.env.CCS_CODEX_PROFILE ?? null;
// D14: check if CCS_CODEX_PROFILE points to a deleted/missing profile
const activeMissing =
activeName !== null && activeName.length > 0 && !registry.hasProfile(activeName);
interface Row {
name: string;
email: string;
plan: string;
accountId: string | null;
lastUsed: string;
state: string;
missing?: boolean;
}
const rows: Row[] = [];
// D14: active(missing) row at top
if (activeMissing) {
rows.push({
name: activeName ?? '',
email: '<unknown>',
plan: '-',
accountId: null,
lastUsed: 'never',
state: 'active(missing)',
missing: true,
});
}
for (const name of names) {
const meta = registry.getProfile(name);
const states: string[] = [];
if (name === defaultName) states.push('default');
if (name === activeName) states.push('active');
const profileDir = resolveCodexProfileDir(name);
const authJsonPath = path.join(profileDir, 'auth.json');
const identity: CodexAccountIdentity = fs.existsSync(authJsonPath)
? decodeAccountIdentity(authJsonPath)
: {};
const email = meta.email ?? identity.email ?? '<unknown>';
const plan = meta.plan_type ?? identity.plan_type ?? '-';
const accountId = meta.account_id ?? identity.account_id ?? null;
const lastUsed = meta.last_used ? formatRelativeTime(new Date(meta.last_used)) : 'never';
rows.push({ name, email, plan, accountId, lastUsed, state: states.join(',') });
}
if (json) {
const profiles: CodexProfileOutput[] = rows.map((r) => {
const meta = r.missing ? null : registry.getProfile(r.name);
const profileDir = r.missing ? '' : resolveCodexProfileDir(r.name);
return {
name: r.name,
is_default: r.name === defaultName,
is_active: r.name === activeName,
created: meta?.created ?? '',
last_used: meta?.last_used ?? null,
email: r.email === '<unknown>' ? null : r.email,
plan: r.plan === '-' ? null : r.plan,
account_id: r.accountId,
profile_dir: profileDir,
auth_json_exists: r.missing ? false : fs.existsSync(path.join(profileDir, 'auth.json')),
auth_json_mtime: null,
config_toml_link_target: null,
};
});
console.log(JSON.stringify({ profiles }, null, 2));
return;
}
if (names.length === 0 && !activeMissing) {
console.log(info('No Codex profiles yet.'));
console.log(' Create one: ccsx auth create <name>');
return;
}
const count = names.length + (activeMissing ? 1 : 0);
console.log(`Codex Profiles (${count})`);
console.log('');
const header = ['NAME', 'EMAIL', 'PLAN', 'LAST_USED', 'STATE'];
const tableRows = [header, ...rows.map((r) => [r.name, r.email, r.plan, r.lastUsed, r.state])];
console.log(table(tableRows, { colWidths: [14, 26, 8, 14, 18] }));
console.log('');
console.log(info('Default persists across shells. Active is current shell only.'));
}
+121
View File
@@ -0,0 +1,121 @@
/**
* Detail view renderer for `ccsx auth show <name>`.
* Extracted from show-command.ts to keep files under 200 lines.
*/
import * as fs from 'fs';
import * as path from 'path';
import { table } from '../../utils/ui';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { resolveCodexProfileDir } from '../codex-profile-paths';
import { decodeAccountIdentity } from '../codex-account-identity';
import type { CodexCommandContext, CodexProfileOutput } from './types';
export function showProfileDetail(
profileName: string,
ctx: CodexCommandContext,
json: boolean
): void {
const { registry } = ctx;
if (!registry.hasProfile(profileName)) {
exitWithError(`Profile not found: ${profileName}`, ExitCode.PROFILE_ERROR);
return;
}
const meta = registry.getProfile(profileName);
const profileDir = resolveCodexProfileDir(profileName);
const authJsonPath = path.join(profileDir, 'auth.json');
const configTomlPath = path.join(profileDir, 'config.toml');
const authExists = fs.existsSync(authJsonPath);
let authMtime: string | null = null;
let identity = {
email: undefined as string | undefined,
plan_type: undefined as string | undefined,
account_id: undefined as string | undefined,
};
let authState = 'missing';
if (authExists) {
try {
const stat = fs.statSync(authJsonPath);
authMtime = stat.mtime.toISOString();
identity = decodeAccountIdentity(authJsonPath) as typeof identity;
authState = `present (mtime: ${new Date(authMtime).toLocaleString()})`;
} catch {
authState = 'present (unreadable)';
}
}
// Inspect config.toml symlink
let configTarget: string | null = null;
try {
const lstat = fs.lstatSync(configTomlPath);
if (lstat.isSymbolicLink()) {
configTarget = fs.readlinkSync(configTomlPath);
} else {
configTarget = `${configTomlPath} (regular file, not symlink)`;
}
} catch {
configTarget = null;
}
const isDefault = registry.getDefault() === profileName;
const isActive = process.env.CCS_CODEX_PROFILE === profileName;
const states: string[] = [];
if (isDefault) states.push('default');
if (isActive) states.push('active');
const stateStr = states.join(',');
const accountId = meta.account_id ?? identity.account_id ?? null;
const email = meta.email ?? identity.email ?? null;
const plan = meta.plan_type ?? identity.plan_type ?? null;
if (json) {
const out: CodexProfileOutput = {
name: profileName,
is_default: isDefault,
is_active: isActive,
created: meta.created,
last_used: meta.last_used ?? null,
email,
plan,
account_id: accountId,
profile_dir: profileDir,
auth_json_exists: authExists,
auth_json_mtime: authMtime,
config_toml_link_target: configTarget,
};
console.log(JSON.stringify(out, null, 2));
return;
}
const badge = stateStr ? ` (${stateStr})` : '';
console.log(`Codex Profile: ${profileName}${badge}`);
console.log('');
const rows: [string, string][] = [
['Name', profileName],
['Profile dir', profileDir],
['config.toml', configTarget ? `-> ${configTarget} (symlink)` : '(not linked)'],
['auth.json', authState],
['Email', email ?? (authExists ? '<invalid>' : '<unknown>')],
['Plan', plan ?? (authExists ? '<invalid>' : '<unknown>')],
['Account ID', accountId ?? '-'],
['Created', new Date(meta.created).toLocaleString()],
['Last used', meta.last_used ? new Date(meta.last_used).toLocaleString() : 'never'],
['CODEX_HOME (env)', process.env.CODEX_HOME ?? 'unset'],
['CCS_CODEX_PROFILE', process.env.CCS_CODEX_PROFILE ?? 'unset'],
];
console.log(table(rows, { colWidths: [20, 55] }));
// H4: warn if config.toml is a regular file (not symlink)
if (configTarget && configTarget.includes('regular file')) {
process.stderr.write(
`[!] config.toml is a regular file, not a symlink. Config changes won't propagate.\n`
);
process.stderr.write(` Run: ccsx auth create ${profileName} --force\n`);
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* codex-auth switch command.
* Sets the persistent default Codex profile in the registry.
*/
import { initUI, ok } from '../../utils/ui';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types';
import type { CodexCommandContext } from './types';
export async function handleSwitchCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
await initUI();
const parsed = parseArgs(args);
rejectUnsupportedOptions(parsed, 'ccsx auth switch <name>');
const { profileName } = parsed;
if (!profileName) {
console.log('Usage: ccsx auth switch <name>');
exitWithError('Profile name required', ExitCode.PROFILE_ERROR);
return;
}
const nameError = getProfileNameError(profileName);
if (nameError) {
exitWithError(nameError, ExitCode.PROFILE_ERROR);
return;
}
const { registry } = ctx;
if (!registry.hasProfile(profileName)) {
const available = registry.listProfiles();
const availableStr = available.length > 0 ? available.join(', ') : '<none>';
exitWithError(
`Profile not found: ${profileName}. Available: ${availableStr}`,
ExitCode.PROFILE_ERROR
);
return;
}
registry.setDefault(profileName);
const meta = registry.getProfile(profileName);
const emailStr = meta.email ? `\n Email: ${meta.email}` : '';
const planStr = meta.plan_type ? `\n Plan : ${meta.plan_type}` : '';
console.log(ok(`Default Codex profile: ${profileName}`));
if (emailStr) process.stdout.write(emailStr + '\n');
if (planStr) process.stdout.write(planStr + '\n');
console.log('');
console.log('[i] This is the persistent default. To use a different profile in the');
console.log(` current shell only, run: eval "$(ccsx auth use <other>)"`);
}
+128
View File
@@ -0,0 +1,128 @@
/**
* Shared types and utilities for codex-auth command handlers.
*/
import { color } from '../../utils/ui';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import type { CodexProfileRegistry } from '../codex-profile-registry';
import { getCodexProfileNameError, isValidCodexProfileName } from '../types';
// Re-export for convenience in command modules
export { formatRelativeTime } from '../../utils/time';
// ── Context ──────────────────────────────────────────────────────────────────
export interface CodexCommandContext {
registry: CodexProfileRegistry;
version: string;
}
// ── CLI args ─────────────────────────────────────────────────────────────────
export interface CodexAuthArgs {
profileName?: string;
yes?: boolean;
json?: boolean;
force?: boolean;
shell?: string;
unknownFlags?: string[];
seenOptions?: string[];
extraPositionals?: string[];
}
// ── Profile output shape (JSON mode) ─────────────────────────────────────────
export interface CodexProfileOutput {
name: string;
is_default: boolean;
is_active: boolean;
created: string;
last_used: string | null;
email: string | null;
plan: string | null;
account_id: string | null;
profile_dir: string;
auth_json_exists: boolean;
auth_json_mtime: string | null;
config_toml_link_target: string | null;
}
// ── Name validation ───────────────────────────────────────────────────────────
export { isValidCodexProfileName };
export const getProfileNameError = getCodexProfileNameError;
// ── Arg parsing ───────────────────────────────────────────────────────────────
export function parseArgs(args: string[]): CodexAuthArgs {
const result: CodexAuthArgs = { unknownFlags: [], seenOptions: [] };
const positional: string[] = [];
const markSeen = (flag: string) => result.seenOptions?.push(flag);
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--yes' || arg === '-y') {
markSeen('--yes');
result.yes = true;
} else if (arg === '--json') {
markSeen('--json');
result.json = true;
} else if (arg === '--force') {
markSeen('--force');
result.force = true;
} else if (arg === '--shell') {
markSeen('--shell');
result.shell = args[++i] ?? '';
} else if (arg.startsWith('--shell=')) {
markSeen('--shell');
result.shell = arg.slice('--shell='.length);
} else if (arg.startsWith('-') && arg !== '--') {
if (result.unknownFlags) result.unknownFlags.push(arg);
} else if (arg !== '--') {
positional.push(arg);
}
}
if (positional.length > 0) {
result.profileName = positional[0];
}
if (positional.length > 1) {
result.extraPositionals = positional.slice(1);
}
return result;
}
export interface AllowedCodexAuthOptions {
yes?: boolean;
json?: boolean;
force?: boolean;
shell?: boolean;
}
export function rejectUnsupportedOptions(
parsed: CodexAuthArgs,
usage: string,
allowed: AllowedCodexAuthOptions = {}
): void {
const unsupported = new Set(parsed.unknownFlags ?? []);
const seen = new Set(parsed.seenOptions ?? []);
if (seen.has('--yes') && !allowed.yes) unsupported.add('--yes');
if (seen.has('--json') && !allowed.json) unsupported.add('--json');
if (seen.has('--force') && !allowed.force) unsupported.add('--force');
if (seen.has('--shell') && !allowed.shell) unsupported.add('--shell');
const extraPositionals = parsed.extraPositionals ?? [];
if (unsupported.size > 0 || extraPositionals.length > 0) {
const flags = [...unsupported].join(', ');
process.stderr.write(`Usage: ${color(usage, 'command')}\n`);
const details = [
flags ? `Unknown options: ${flags}` : null,
extraPositionals.length > 0
? `Unexpected arguments: ${extraPositionals.map((arg) => `"${arg}"`).join(', ')}`
: null,
].filter(Boolean);
exitWithError(details.join('; '), ExitCode.GENERAL_ERROR);
}
}
+104
View File
@@ -0,0 +1,104 @@
/**
* codex-auth use command.
*
* STDOUT DISCIPLINE (C2, R4): stdout contains ONLY shell-evalable export
* statements. ALL errors, hints, and info messages go to STDERR so that
* `eval "$(ccsx auth use <name>)"` is never contaminated.
*
* Belt-and-suspenders for C2: the primary protection is
* `src/bin/codex-runtime-router.ts`, which dispatches `auth` subcommands
* BEFORE pre-dispatch runs at all. This IIFE is a fallback for any future
* code path (e.g., direct import from a different bin entry) that bypasses
* the router and would otherwise let pre-dispatch banners hit stdout.
*/
(function guardPreDispatch() {
const argv = process.argv;
// argv[2] is the first user arg to ccsx (e.g. "auth"), argv[3] is the subcommand
for (let i = 2; i < argv.length - 1; i++) {
if (argv[i] === 'auth' && argv[i + 1] === 'use') {
process.env.CCS_NO_PRE_DISPATCH = '1';
break;
}
}
})();
// ── End guard — safe to import CCS modules now ───────────────────────────────
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { resolveCodexProfileDir } from '../codex-profile-paths';
import { detectShell, formatExport } from '../shell-detect';
import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types';
import type { Shell } from '../shell-detect';
import type { CodexCommandContext } from './types';
const VALID_SHELLS = new Set<string>(['bash', 'zsh', 'fish', 'pwsh', 'cmd']);
export async function handleUseCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
const parsed = parseArgs(args);
rejectUnsupportedOptions(parsed, 'ccsx auth use <name> [--shell <bash|zsh|fish|pwsh|cmd>]', {
shell: true,
});
const { profileName, shell: shellOverride } = parsed;
// All errors → stderr, empty stdout
if (!profileName) {
process.stderr.write('[X] Profile name is required.\n');
process.stderr.write('Usage: ccsx auth use <name> [--shell <bash|zsh|fish|pwsh|cmd>]\n');
process.exit(ExitCode.PROFILE_ERROR);
return;
}
const nameError = getProfileNameError(profileName);
if (nameError) {
process.stderr.write(`[X] ${nameError}\n`);
process.exit(ExitCode.PROFILE_ERROR);
return;
}
if (shellOverride !== undefined && !VALID_SHELLS.has(shellOverride)) {
process.stderr.write(
`[X] Unsupported --shell value: "${shellOverride}". Valid: bash, zsh, fish, pwsh, cmd\n`
);
process.exit(ExitCode.GENERAL_ERROR);
return;
}
const { registry } = ctx;
if (!registry.hasProfile(profileName)) {
const available = registry.listProfiles();
const availableStr = available.length > 0 ? available.join(', ') : '<none>';
process.stderr.write(`[X] Profile not found: ${profileName}. Available: ${availableStr}\n`);
process.exit(ExitCode.PROFILE_ERROR);
return;
}
const profileDir = resolveCodexProfileDir(profileName);
const shell: Shell =
shellOverride !== undefined
? (shellOverride as Shell)
: detectShell(process.env, process.platform);
// ── STDOUT: only export statements ──────────────────────────────────────────
process.stdout.write(formatExport(shell, 'CODEX_HOME', profileDir) + '\n');
process.stdout.write(formatExport(shell, 'CCS_CODEX_PROFILE', profileName) + '\n');
// ── STDERR: human-readable hint ─────────────────────────────────────────────
process.stderr.write(`[i] Codex profile "${profileName}" active in this shell. Run: codex\n`);
if (shell === 'cmd') {
process.stderr.write('[i] Note: cmd.exe cannot eval output from a subprocess natively.\n');
process.stderr.write(
' Use PowerShell: ccsx auth use ' + profileName + ' | Invoke-Expression\n'
);
}
// Note: This profile applies only to native `codex`.
// `ccsxp` ignores CCS_CODEX_PROFILE and uses its own cliproxy pool.
}
// suppress unused import warning — exitWithError is available but we use process.exit
// for stdout purity in this command
void exitWithError;
+104
View File
@@ -0,0 +1,104 @@
import type { CodexAccountIdentity } from './types';
// JWT claim URI for OpenAI-specific auth data (nested object).
// Verified against real auth.json: chatgpt_plan_type and chatgpt_account_id
// live under this key, NOT at top level.
const OPENAI_AUTH_CLAIM = 'https://api.openai.com/auth';
const OPENAI_PROFILE_CLAIM = 'https://api.openai.com/profile';
const BASE64URL_SEGMENT_RE = /^[A-Za-z0-9_-]+$/;
interface OpenAIAuthClaim {
chatgpt_plan_type?: string;
chatgpt_account_id?: string;
}
interface OpenAIProfileClaim {
email?: string;
}
interface JwtPayload {
email?: string;
[OPENAI_AUTH_CLAIM]?: OpenAIAuthClaim;
[OPENAI_PROFILE_CLAIM]?: OpenAIProfileClaim;
[key: string]: unknown;
}
function base64urlDecode(str: string): string {
// Convert base64url to standard base64
const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
return Buffer.from(padded, 'base64').toString('utf8');
}
function isBase64UrlSegment(str: string): boolean {
return str.length > 0 && str.length % 4 !== 1 && BASE64URL_SEGMENT_RE.test(str);
}
function decodeJsonSegment(str: string): unknown {
return JSON.parse(base64urlDecode(str));
}
/**
* Decode the payload of a JWT id_token without signature verification.
* Returns only the display-safe fields: email, plan_type, account_id.
* Returns {} on any parse failure — never throws.
*
* Security note: signature is NOT verified. This is purely cosmetic data
* for dashboard display. Auth boundary is OS file perms on auth.json.
*/
export function decodeIdToken(idToken: string): CodexAccountIdentity {
try {
const payload = decodeJwtPayload(idToken);
if (!payload) return {};
const authClaim = payload[OPENAI_AUTH_CLAIM];
const profileClaim = payload[OPENAI_PROFILE_CLAIM];
// Email: prefer top-level, fall back to profile claim
const email = payload.email ?? profileClaim?.email;
const result: CodexAccountIdentity = {};
if (typeof email === 'string' && email.length > 0) {
result.email = email;
}
if (typeof authClaim?.chatgpt_plan_type === 'string') {
result.plan_type = authClaim.chatgpt_plan_type;
}
if (typeof authClaim?.chatgpt_account_id === 'string') {
result.account_id = authClaim.chatgpt_account_id;
}
return result;
} catch {
return {};
}
}
export function hasStructurallyValidIdToken(idToken: string): boolean {
return decodeJwtPayload(idToken) !== null;
}
function decodeJwtPayload(idToken: string): JwtPayload | null {
try {
const parts = idToken.split('.');
if (parts.length !== 3) {
return null;
}
if (!parts.every((part) => isBase64UrlSegment(part))) {
return null;
}
const header = decodeJsonSegment(parts[0] ?? '');
if (!header || typeof header !== 'object' || Array.isArray(header)) {
return null;
}
const payload = decodeJsonSegment(parts[1] ?? '');
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return null;
}
return payload as JwtPayload;
} catch {
return null;
}
}
+12
View File
@@ -0,0 +1,12 @@
export { CodexProfileRegistry } from './codex-profile-registry';
export {
getCodexAuthRegistryPath,
getCodexInstancesDir,
resolveCodexProfileDir,
getSharedCodexConfigPath,
} from './codex-profile-paths';
export { ensureSharedConfigSymlink } from './codex-config-symlink';
export { decodeAccountIdentity } from './codex-account-identity';
export { decodeIdToken } from './decode-id-token';
export type { CodexProfileMetadata, CodexProfileData, CodexAccountIdentity } from './types';
export { CODEX_PROFILE_SCHEMA_VERSION } from './types';
+194
View File
@@ -0,0 +1,194 @@
/**
* Synchronous hot-path resolver for the active codex auth profile. <5ms typical.
* Precedence: CCS_CODEX_PROFILE env → registry.default → null (legacy ~/.codex).
* Legacy fallback is allowed only when no explicit CCS_CODEX_PROFILE was requested.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'js-yaml';
import { getCodexAuthRegistryPath, resolveCodexProfileDir } from './codex-profile-paths';
import { getCcsDirSource } from '../utils/config-manager';
import { getCodexProfileNameError } from './types';
import { validateCodexProfileRegistryData } from './codex-profile-registry';
import type { CodexProfileData } from './types';
export interface ResolvedProfile {
name: string;
dir: string;
source: 'env' | 'default';
}
export class CodexAuthProfileResolutionError extends Error {
constructor(message: string) {
super(message);
this.name = 'CodexAuthProfileResolutionError';
}
}
function quoteDiagnosticValue(value: string): string {
const escaped = value
.replace(/[\x00-\x1f\x7f]/g, (char) => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
.replace(/'/g, "\\'");
return `'${escaped.length > 96 ? `${escaped.slice(0, 96)}...` : escaped}'`;
}
function registryDisplayPath(registryPath: string): string {
const [source] = getCcsDirSource();
if (source === 'default') {
return process.platform === 'win32'
? '%USERPROFILE%\\.ccs\\codex-profiles.yaml'
: '~/.ccs/codex-profiles.yaml';
}
if (source === 'CCS_HOME' || source === 'scoped:CCS_HOME') {
return '$CCS_HOME/.ccs/codex-profiles.yaml';
}
if (source === 'CCS_DIR' || source === 'scoped:CCS_DIR') {
return '$CCS_DIR/codex-profiles.yaml';
}
return registryPath;
}
function resolutionFailure(message: string, envName: string, displayEnvName: string): never {
const prefix = envName ? `CCS_CODEX_PROFILE=${displayEnvName} is set but ` : '';
throw new CodexAuthProfileResolutionError(
`${prefix}${message}. Refusing to fall back to ~/.codex.`
);
}
function assertValidProfileNameForResolution(
name: string,
envName: string,
displayEnvName: string
): void {
const nameError = getCodexProfileNameError(name);
if (nameError) {
resolutionFailure(
`profile name ${quoteDiagnosticValue(name)} is invalid: ${nameError}`,
envName,
displayEnvName
);
}
}
function assertValidProfileEntry(
name: string,
profiles: Record<string, unknown>,
envName: string,
displayEnvName: string,
displayRegistryPath: string
): void {
assertValidProfileNameForResolution(name, envName, displayEnvName);
const profile = profiles[name];
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
resolutionFailure(
`registry profile ${quoteDiagnosticValue(name)} at ${displayRegistryPath} is not a valid object`,
envName,
displayEnvName
);
}
const type = (profile as { type?: unknown }).type;
if (type !== 'codex') {
resolutionFailure(
`registry profile ${quoteDiagnosticValue(name)} at ${displayRegistryPath} is not a Codex profile`,
envName,
displayEnvName
);
}
}
/** @param env - Process env map; defaults to process.env. Injectable for tests. */
export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): ResolvedProfile | null {
const registryPath = getCodexAuthRegistryPath();
const envName = (env.CCS_CODEX_PROFILE ?? '').trim();
const displayEnvName = quoteDiagnosticValue(envName);
const displayRegistryPath = registryDisplayPath(registryPath);
// F4: silent fallback — no registry means no profiles, legacy mode
if (!fs.existsSync(registryPath)) {
if (envName) {
throw new CodexAuthProfileResolutionError(
`CCS_CODEX_PROFILE=${displayEnvName} is set but ${displayRegistryPath} does not exist. Refusing to fall back to ~/.codex.`
);
}
return null;
}
let parsed: unknown;
try {
const raw = fs.readFileSync(registryPath, 'utf8');
parsed = yaml.load(raw);
} catch (err) {
if (err instanceof CodexAuthProfileResolutionError) throw err;
const msg = `registry YAML could not be parsed at ${displayRegistryPath}`;
resolutionFailure(msg, envName, displayEnvName);
}
let registry: CodexProfileData;
try {
registry = validateCodexProfileRegistryData(parsed);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
resolutionFailure(
`registry at ${displayRegistryPath} is invalid: ${msg}`,
envName,
displayEnvName
);
}
const profiles = registry.profiles;
if (!profiles || typeof profiles !== 'object' || Array.isArray(profiles)) {
resolutionFailure(
`registry at ${displayRegistryPath} is missing a valid profiles map`,
envName,
displayEnvName
);
}
for (const profileName of Object.keys(profiles)) {
assertValidProfileEntry(profileName, profiles, envName, displayEnvName, displayRegistryPath);
}
// F2: explicit env override
if (envName) {
assertValidProfileNameForResolution(envName, envName, displayEnvName);
if (!Object.prototype.hasOwnProperty.call(profiles, envName)) {
throw new CodexAuthProfileResolutionError(
`CCS_CODEX_PROFILE=${displayEnvName} not found in registry. Refusing to fall back to ~/.codex.`
);
}
assertValidProfileEntry(envName, profiles, envName, displayEnvName, displayRegistryPath);
return {
name: envName,
dir: path.resolve(resolveCodexProfileDir(envName)),
source: 'env',
};
}
// F3: registry default
const defaultName = registry.default;
if (defaultName !== null) {
if (typeof defaultName !== 'string') {
resolutionFailure(
`registry default at ${displayRegistryPath} is not a valid profile name`,
envName,
displayEnvName
);
}
assertValidProfileNameForResolution(defaultName, envName, displayEnvName);
if (!Object.prototype.hasOwnProperty.call(profiles, defaultName)) {
resolutionFailure(
`registry default ${quoteDiagnosticValue(defaultName)} is missing from profiles map`,
envName,
displayEnvName
);
}
assertValidProfileEntry(defaultName, profiles, envName, displayEnvName, displayRegistryPath);
return {
name: defaultName,
dir: path.resolve(resolveCodexProfileDir(defaultName)),
source: 'default',
};
}
// F4: no profile configured
return null;
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Shell detection for codex-auth use command.
* Determines current shell to emit correct eval-safe export syntax.
*/
import * as childProcess from 'child_process';
export type Shell = 'bash' | 'zsh' | 'fish' | 'pwsh' | 'cmd';
/**
* Detect current shell from environment.
* On Windows: inspect explicit shell executable hints, else default to cmd.
* On Unix: inspect $SHELL suffix.
*/
export function detectShell(
env: NodeJS.ProcessEnv = process.env,
platform: string = process.platform,
parentProcessName?: string
): Shell {
if (platform === 'win32') {
return (
shellFromExecutable(env.SHELL) ??
shellFromExecutable(parentProcessName ?? detectParentProcessName(platform)) ??
shellFromExecutable(env.ComSpec ?? env.COMSPEC) ??
'cmd'
);
}
const sh = (env.SHELL ?? '').toLowerCase();
if (sh.endsWith('/fish')) return 'fish';
if (sh.endsWith('/zsh')) return 'zsh';
return 'bash'; // default for bash, sh, dash, ksh
}
function detectParentProcessName(platform: string): string | undefined {
if (platform !== 'win32' || !Number.isInteger(process.ppid) || process.ppid <= 0) {
return undefined;
}
const result = childProcess.spawnSync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`(Get-Process -Id ${process.ppid} -ErrorAction Stop).ProcessName`,
],
{ encoding: 'utf8', timeout: 1500, windowsHide: true }
);
if (result.status !== 0 || !result.stdout) return undefined;
return result.stdout.trim().split(/\r?\n/).pop()?.trim();
}
function shellFromExecutable(value: string | undefined): Shell | null {
if (!value) return null;
const base = value
.replace(/^["']|["']$/g, '')
.replace(/\\/g, '/')
.split('/')
.pop()
?.toLowerCase()
.replace(/\.(exe|cmd|ps1|bat)$/i, '');
switch (base) {
case 'fish':
case 'zsh':
case 'bash':
case 'cmd':
return base;
case 'pwsh':
case 'powershell':
return 'pwsh';
default:
return null;
}
}
/**
* Single-quote escape for POSIX shells (bash/zsh/fish).
* Closes the single-quote, inserts escaped quote, reopens.
*/
function posixSingleQuote(value: string): string {
return "'" + value.replace(/'/g, "'\\''") + "'";
}
/**
* Double-quote escape for PowerShell.
* Wraps in double quotes; escapes the PowerShell escape char first, doubles
* internal double quotes, and backtick-escapes $ to prevent interpolation.
*/
function pwshDoubleQuote(value: string): string {
return '"' + value.replace(/`/g, '``').replace(/"/g, '""').replace(/\$/g, '`$') + '"';
}
/**
* Quote a cmd.exe SET assignment. `set "KEY=value"` keeps command separators
* like &, |, <, and > inside the assignment instead of executing them.
*/
function cmdSetQuote(value: string): string {
return value.replace(/\^/g, '^^').replace(/%/g, '%%').replace(/"/g, '^"').replace(/!/g, '^^!');
}
/**
* Format a single env var export statement for the target shell.
* Used by use-command to emit eval-safe lines.
*/
export function formatExport(shell: Shell, key: string, value: string): string {
switch (shell) {
case 'fish':
return `set -gx ${key} ${posixSingleQuote(value)};`;
case 'pwsh':
return `$env:${key} = ${pwshDoubleQuote(value)}`;
case 'cmd':
return `set "${key}=${cmdSetQuote(value)}"`;
default:
// bash / zsh
return `export ${key}=${posixSingleQuote(value)}`;
}
}
+46
View File
@@ -0,0 +1,46 @@
export interface CodexProfileMetadata {
type: 'codex';
created: string;
last_used: string | null;
email?: string;
plan_type?: string | null;
account_id?: string;
}
export interface CodexProfileData {
version: string;
default: string | null;
profiles: Record<string, CodexProfileMetadata>;
}
export interface CodexAccountIdentity {
email?: string;
plan_type?: string;
account_id?: string;
}
export const CODEX_PROFILE_SCHEMA_VERSION = '1.0';
const RESERVED_CODEX_PROFILE_NAMES = new Set(['default', 'current']);
/**
* Profile name must match /^[a-z0-9][a-z0-9_-]{0,63}$/ and not be reserved.
* Rejects uppercase, path separators, leading dash/underscore, length >64.
*/
export function isValidCodexProfileName(name: string): boolean {
if (!name || name.length > 64) return false;
if (RESERVED_CODEX_PROFILE_NAMES.has(name)) return false;
if (name.includes('/') || name.includes('\\')) return false;
return /^[a-z0-9][a-z0-9_-]{0,63}$/.test(name);
}
export function getCodexProfileNameError(name: string): string | null {
if (!name) return 'Profile name is required.';
if (RESERVED_CODEX_PROFILE_NAMES.has(name)) return `Profile name "${name}" is reserved.`;
if (name.includes('/') || name.includes('\\'))
return 'Profile name must not contain path separators.';
if (name.length > 64) return 'Profile name must be 64 characters or fewer.';
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name))
return 'Profile name must match [a-z0-9][a-z0-9_-]{0,63}.';
return null;
}
+8
View File
@@ -59,6 +59,14 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise<b
}
}
// CCS_NO_PRE_DISPATCH guard — set by `ccsx auth use` to keep stdout clean
// for shell eval. Must be checked BEFORE autoMigrate/recovery, both of which
// write to stdout and would otherwise contaminate `eval "$(ccsx auth use <name>)"`.
// See: src/codex-auth/commands/use-command.ts (C2 in plan.md §Validation findings)
if (process.env.CCS_NO_PRE_DISPATCH === '1') {
return false;
}
// Auto-migrate to unified config format (silent if already migrated)
// Skip if user is explicitly running migrate command
if (firstArg !== 'migrate') {
+16
View File
@@ -9,6 +9,7 @@ import {
patchCodexConfig,
saveCodexRawConfig,
} from '../services/codex-dashboard-service';
import { getCodexAuthProfilesSummary } from '../../codex-auth/codex-auth-dashboard-service';
const router = Router();
const CODEX_CONFIG_ACCESS_ERROR =
@@ -28,6 +29,21 @@ router.get('/diagnostics', async (_req: Request, res: Response): Promise<void> =
}
});
// H6: email is PII — require localhost access when dashboard auth is disabled.
const CODEX_PROFILES_ACCESS_ERROR =
'Codex auth profiles endpoint requires localhost access when dashboard auth is disabled.';
router.get('/profiles', async (req: Request, res: Response): Promise<void> => {
if (!requireLocalAccessWhenAuthDisabled(req, res, CODEX_PROFILES_ACCESS_ERROR)) {
return;
}
try {
res.json(await getCodexAuthProfilesSummary());
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
router.get('/config/raw', async (_req: Request, res: Response): Promise<void> => {
try {
res.json(await getCodexRawConfig());
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env bash
# tests/docker/compose-parity.sh
#
# Asserts that docker/compose.yaml and docker/docker-compose.integrated.yml
# agree on the fields that form the stable network contract:
# - image name (without tag)
# - exposed host ports
# - named volume mounts
#
# This prevents drift between the public quickstart compose and the
# `ccs docker` CLI compose that is bundled with the package.
#
# Usage: bash tests/docker/compose-parity.sh
# (called from repo root)
#
set -euo pipefail
CANONICAL="docker/compose.yaml"
INTEGRATED="docker/docker-compose.integrated.yml"
fail=0
log() { printf '[i] %s\n' "$*"; }
ok() { printf '[OK] %s\n' "$*"; }
fail() { printf '[X] %s\n' "$*" >&2; fail=1; }
# ---------------------------------------------------------------------------
# Helper: extract image name (repo path without tag) for a service.
# Handles both plain image references and ${VAR:-default} shell variable
# syntax (e.g. image: ${CCS_IMAGE:-ghcr.io/kaitranntt/ccs:latest}).
# ---------------------------------------------------------------------------
image_name() {
local file="$1" service="$2"
# Match lines like:
# image: ghcr.io/owner/repo:tag
# image: ${CCS_IMAGE:-ghcr.io/owner/repo:tag}
grep -A 50 "^ ${service}:" "$file" \
| grep -m1 '^\s*image:' \
| sed 's/.*image:\s*//' \
| sed 's/\${[^:-]*:-\([^}]*\)}/\1/' \
| sed 's/:.*//' \
| tr -d ' '
}
# ---------------------------------------------------------------------------
# Helper: extract sorted list of internal container ports exposed
# ---------------------------------------------------------------------------
exposed_ports() {
local file="$1"
# Match port mappings: "HOST:CONTAINER" — extract CONTAINER port number
grep -E '^\s+- "[0-9]+:[0-9]+"' "$file" \
| sed 's/.*:\([0-9]*\)".*/\1/' \
| sort -n
}
# ---------------------------------------------------------------------------
# Helper: extract sorted list of named volume mount targets (container paths)
# ---------------------------------------------------------------------------
volume_targets() {
local file="$1"
# Match volume entries: - name:/container/path
grep -E '^\s+- [a-z_]+:/' "$file" \
| sed 's/.*:\(\/[^[:space:]]*\).*/\1/' \
| sort
}
# ---------------------------------------------------------------------------
# Expected image names (without tag) — source of truth for this assertion.
#
# canonical (docker/compose.yaml):
# Pulls from the public registry. Default image is ghcr.io/kaitranntt/ccs.
#
# integrated (docker/docker-compose.integrated.yml):
# Built locally from Dockerfile.integrated; the resulting image is tagged
# ccs-cliproxy (no registry prefix) so it stays separate from the public
# image but is still clearly a CCS-family image.
# ---------------------------------------------------------------------------
EXPECTED_CANONICAL_IMAGE="ghcr.io/kaitranntt/ccs"
EXPECTED_INTEGRATED_IMAGE="ccs-cliproxy"
# ---------------------------------------------------------------------------
# 1. Image name (repo without tag) — assert exact match against expected names
# ---------------------------------------------------------------------------
log "Checking image name parity..."
CANONICAL_IMAGE=$(image_name "$CANONICAL" "ccs")
INTEGRATED_IMAGE=$(image_name "$INTEGRATED" "ccs-cliproxy")
if [[ "${CANONICAL_IMAGE}" != "${EXPECTED_CANONICAL_IMAGE}" ]]; then
fail "Canonical image name mismatch — expected='${EXPECTED_CANONICAL_IMAGE}' got='${CANONICAL_IMAGE}'"
else
ok "Canonical image name matches expected (${CANONICAL_IMAGE})"
fi
if [[ "${INTEGRATED_IMAGE}" != "${EXPECTED_INTEGRATED_IMAGE}" ]]; then
fail "Integrated image name mismatch — expected='${EXPECTED_INTEGRATED_IMAGE}' got='${INTEGRATED_IMAGE}'"
else
ok "Integrated image name matches expected (${INTEGRATED_IMAGE})"
fi
# ---------------------------------------------------------------------------
# 2. Exposed ports — both must expose 3000 and 8317
# Matches both quoted ("HOST:CONTAINER") and unquoted (HOST:CONTAINER) forms,
# as well as variable-interpolated host ports like "${VAR:-3000}:3000".
# ---------------------------------------------------------------------------
log "Checking exposed port parity..."
port_exposed() {
local file="$1" port="$2"
# Match container port in: "anything:PORT" or anything:PORT (quoted or bare)
grep -E "(\"[^\"]*:${port}\"|[[:space:]]-[[:space:]]+[^\"]*:${port}[^0-9])" "$file" \
> /dev/null 2>&1
}
REQUIRED_PORTS=("3000" "8317")
for port in "${REQUIRED_PORTS[@]}"; do
IN_CANONICAL=0
IN_INTEGRATED=0
port_exposed "$CANONICAL" "$port" && IN_CANONICAL=1 || true
port_exposed "$INTEGRATED" "$port" && IN_INTEGRATED=1 || true
if [[ "$IN_CANONICAL" -eq 1 && "$IN_INTEGRATED" -eq 1 ]]; then
ok "Port ${port} exposed in both compose files"
elif [[ "$IN_CANONICAL" -eq 0 ]]; then
fail "Port ${port} missing from ${CANONICAL}"
else
fail "Port ${port} missing from ${INTEGRATED}"
fi
done
# ---------------------------------------------------------------------------
# 3. Named volume mount targets — both must mount /root/.ccs and /var/log/ccs
# ---------------------------------------------------------------------------
log "Checking volume mount parity..."
REQUIRED_MOUNTS=("/root/.ccs" "/var/log/ccs")
for mount in "${REQUIRED_MOUNTS[@]}"; do
if grep -q ":${mount}" "$CANONICAL" && grep -q ":${mount}" "$INTEGRATED"; then
ok "Volume mount ${mount} present in both compose files"
elif ! grep -q ":${mount}" "$CANONICAL"; then
fail "Volume mount ${mount} missing from ${CANONICAL}"
else
fail "Volume mount ${mount} missing from ${INTEGRATED}"
fi
done
# ---------------------------------------------------------------------------
# 4. Network name — canonical must define ccs-net; integrated inherits default
# ---------------------------------------------------------------------------
log "Checking ccs-net network definition..."
if grep -q "name: ccs-net" "$CANONICAL"; then
ok "ccs-net network defined in ${CANONICAL}"
else
fail "ccs-net network definition missing from ${CANONICAL}"
fi
# ---------------------------------------------------------------------------
# Result
# ---------------------------------------------------------------------------
if [[ "$fail" -ne 0 ]]; then
echo "" >&2
echo "[X] Compose parity check FAILED — update ${INTEGRATED} to match ${CANONICAL}" >&2
exit 1
fi
echo ""
ok "Compose parity check passed"
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env bash
# Unit tests for image-size.sh pass/fail logic using mock docker output.
# Does NOT require a real Docker daemon or pulled images.
# Run: bash tests/docker/image-size-logic.test.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT="${SCRIPT_DIR}/image-size.sh"
PASS=0
FAIL=0
run_test() {
local name="$1"
local expected_exit="$2"
shift 2
# Remaining args: env vars to set before calling the script
local actual_exit=0
(
# Override docker with a mock that returns a fixed size
eval "$@"
bash "$SCRIPT" "mock-image:tag" "$MAX_BYTES" > /dev/null 2>&1
) || actual_exit=$?
if [[ "$actual_exit" -eq "$expected_exit" ]]; then
echo "[OK] ${name}"
(( PASS++ )) || true
else
echo "[X] ${name}: expected exit ${expected_exit}, got ${actual_exit}"
(( FAIL++ )) || true
fi
}
# ------------------------------------------------------------------
# We mock docker by injecting a wrapper into PATH that echoes a fixed
# size value for `docker image inspect` and pretends inspect succeeds.
# ------------------------------------------------------------------
MOCK_DIR="$(mktemp -d)"
trap 'rm -rf "$MOCK_DIR"' EXIT
make_mock_docker() {
local size="$1"
cat > "${MOCK_DIR}/docker" <<EOF
#!/usr/bin/env bash
# Mock docker for image-size.sh tests
if [[ "\$1" == "image" && "\$2" == "inspect" ]]; then
echo "${size}"
exit 0
fi
# pull / other sub-commands: succeed silently
exit 0
EOF
chmod +x "${MOCK_DIR}/docker"
}
run_mock_test() {
local name="$1"
local expected_exit="$2"
local mock_size="$3"
local max_bytes="$4"
make_mock_docker "$mock_size"
local actual_exit=0
PATH="${MOCK_DIR}:${PATH}" bash "$SCRIPT" "mock-image:tag" "$max_bytes" > /dev/null 2>&1 || actual_exit=$?
if [[ "$actual_exit" -eq "$expected_exit" ]]; then
echo "[OK] ${name}"
(( PASS++ )) || true
else
echo "[X] ${name}: expected exit ${expected_exit}, got ${actual_exit}"
(( FAIL++ )) || true
fi
}
# ------------------------------------------------------------------
# Tests
# ------------------------------------------------------------------
echo ""
echo "Running image-size.sh unit tests..."
echo ""
# Pass: actual < budget
run_mock_test "pass when actual size < budget" 0 \
"300000000" "367001600"
# Pass: actual == budget (boundary)
run_mock_test "pass when actual size == budget (boundary)" 0 \
"367001600" "367001600"
# Fail: actual > budget by 1 byte
run_mock_test "fail when actual size exceeds budget by 1 byte" 1 \
"367001601" "367001600"
# Fail: actual is much larger than budget
run_mock_test "fail when actual size greatly exceeds budget" 1 \
"900000000" "629145600"
# Error: wrong arg count (no args)
actual_exit=0
bash "$SCRIPT" > /dev/null 2>&1 || actual_exit=$?
if [[ "$actual_exit" -ne 0 ]]; then
echo "[OK] fail when called with no args"
(( PASS++ )) || true
else
echo "[X] fail when called with no args: expected non-zero exit"
(( FAIL++ )) || true
fi
# Error: non-integer max-bytes
actual_exit=0
PATH="${MOCK_DIR}:${PATH}" bash "$SCRIPT" "mock-image:tag" "not-a-number" > /dev/null 2>&1 || actual_exit=$?
if [[ "$actual_exit" -ne 0 ]]; then
echo "[OK] fail when max-bytes is not an integer"
(( PASS++ )) || true
else
echo "[X] fail when max-bytes is not an integer: expected non-zero exit"
(( FAIL++ )) || true
fi
# ------------------------------------------------------------------
# --platform branch tests (multi-arch via imagetools)
# ------------------------------------------------------------------
# Mock docker that handles `docker buildx imagetools inspect` by echoing
# a fixed layer-size string (space-separated byte counts as imagetools does).
# The mock ignores the --format flag and just prints precomputed sizes.
# ------------------------------------------------------------------
make_mock_docker_platform() {
local layer_output="$1" # space-separated byte values, e.g. "100000000 50000000"
cat > "${MOCK_DIR}/docker" <<'MOCK_EOF'
#!/usr/bin/env bash
# Mock docker for --platform branch tests
if [[ "$1" == "buildx" && "$2" == "imagetools" && "$3" == "inspect" ]]; then
MOCK_EOF
# Inject the layer_output value into the mock script
printf ' echo "%s"\n' "$layer_output" >> "${MOCK_DIR}/docker"
cat >> "${MOCK_DIR}/docker" <<'MOCK_EOF'
exit 0
fi
# image inspect / pull / other sub-commands: succeed silently
exit 0
MOCK_EOF
chmod +x "${MOCK_DIR}/docker"
}
make_mock_docker_platform_fail() {
# imagetools inspect returns nothing (simulates buildx incompatibility)
cat > "${MOCK_DIR}/docker" <<'MOCK_EOF'
#!/usr/bin/env bash
if [[ "$1" == "buildx" && "$2" == "imagetools" && "$3" == "inspect" ]]; then
exit 1
fi
exit 0
MOCK_EOF
chmod +x "${MOCK_DIR}/docker"
}
run_platform_test() {
local name="$1"
local expected_exit="$2"
local max_bytes="$3"
# mock docker already set by caller
local actual_exit=0
PATH="${MOCK_DIR}:${PATH}" bash "$SCRIPT" "mock-image:tag" "$max_bytes" \
--platform linux/amd64 > /dev/null 2>&1 || actual_exit=$?
if [[ "$actual_exit" -eq "$expected_exit" ]]; then
echo "[OK] ${name}"
(( PASS++ )) || true
else
echo "[X] ${name}: expected exit ${expected_exit}, got ${actual_exit}"
(( FAIL++ )) || true
fi
}
echo ""
echo "Running --platform branch tests..."
echo ""
# --platform pass: two layers summing to 150 MB, budget 200 MB
make_mock_docker_platform "100000000 57671680"
run_platform_test "--platform: pass when platform-scoped size < budget" 0 "209715200"
# --platform fail: two layers summing to 250 MB, budget 200 MB
make_mock_docker_platform "150000000 112000000"
run_platform_test "--platform: fail when platform-scoped size > budget" 1 "209715200"
# --platform inspect failure → must exit 1 (REV5 regression guard)
make_mock_docker_platform_fail
run_platform_test "--platform: fail loudly when imagetools inspect fails (REV5 guard)" 1 "209715200"
# --platform returns "0" → must exit 1 (REV5 guard for zero-byte output)
make_mock_docker_platform "0"
run_platform_test "--platform: fail loudly when reported size is 0 (REV5 guard)" 1 "209715200"
# --platform returns empty string → must exit 1 (REV5 guard for empty output)
make_mock_docker_platform ""
run_platform_test "--platform: fail loudly when size output is empty (REV5 guard)" 1 "209715200"
# ------------------------------------------------------------------
# Summary
# ------------------------------------------------------------------
echo ""
echo "Results: ${PASS} passed, ${FAIL} failed"
echo ""
if [[ "$FAIL" -gt 0 ]]; then
exit 1
fi
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# Asserts that a Docker image does not exceed a given byte budget.
#
# Usage: image-size.sh <image:tag> <max-bytes> [--platform <platform>]
# Exit: 0 on pass, 1 on fail
#
# When --platform is given the manifest for that specific platform is inspected
# via `docker buildx imagetools inspect`, summing compressed layer sizes from
# the registry manifest. This avoids pulling the image locally for each arch
# and works on a multi-arch manifest list.
#
# Without --platform the locally cached image is inspected via
# `docker image inspect`, which only reports the host-native architecture.
#
# Examples:
# image-size.sh ghcr.io/kaitranntt/ccs:latest 367001600
# image-size.sh ghcr.io/kaitranntt/ccs:latest 367001600 --platform linux/amd64
# image-size.sh ghcr.io/kaitranntt/ccs:latest 367001600 --platform linux/arm64
set -euo pipefail
if [[ $# -lt 2 ]]; then
echo "[X] Usage: $0 <image:tag> <max-bytes> [--platform <platform>]" >&2
exit 1
fi
IMAGE="$1"
MAX_BYTES="$2"
PLATFORM=""
# Parse optional --platform flag
shift 2
while [[ $# -gt 0 ]]; do
case "$1" in
--platform)
PLATFORM="$2"
shift 2
;;
*)
echo "[X] Unknown argument: $1" >&2
exit 1
;;
esac
done
# Validate that max-bytes is a positive integer
if ! [[ "$MAX_BYTES" =~ ^[0-9]+$ ]]; then
echo "[X] max-bytes must be a positive integer, got: ${MAX_BYTES}" >&2
exit 1
fi
MAX_MB=$(( MAX_BYTES / 1048576 ))
if [[ -n "$PLATFORM" ]]; then
# Multi-arch path: sum compressed layer sizes from the registry manifest.
# `docker buildx imagetools inspect --format` returns the manifest JSON for
# the given platform. We sum the `size` field of each layer entry.
echo "[i] Inspecting ${IMAGE} for platform ${PLATFORM} via registry manifest..." >&2
ACTUAL_BYTES=$(
docker buildx imagetools inspect "${IMAGE}" \
--format "{{ range .Manifest.Layers }}{{ .Size }} {{ end }}" \
--raw 2>/dev/null \
| tr ' ' '\n' \
| awk 'NF && /^[0-9]+$/ { sum += $1 } END { print sum+0 }' \
2>/dev/null || echo ""
)
if [[ -z "$ACTUAL_BYTES" || "$ACTUAL_BYTES" == "0" ]]; then
# Fallback: try the platform-specific sub-manifest
echo "[i] Falling back to platform-scoped imagetools inspect..." >&2
ACTUAL_BYTES=$(
docker buildx imagetools inspect "${IMAGE}@$(
docker buildx imagetools inspect "${IMAGE}" \
--format "{{ range .Manifest.Manifests }}{{ if eq .Platform.OS \"$(echo "${PLATFORM}" | cut -d/ -f1)\" }}{{ if eq .Platform.Architecture \"$(echo "${PLATFORM}" | cut -d/ -f2)\" }}{{ .Digest }}{{ end }}{{ end }}{{ end }}" \
2>/dev/null | head -1
)" --format "{{ range .Manifest.Layers }}{{ .Size }} {{ end }}" 2>/dev/null \
| tr ' ' '\n' \
| awk 'NF && /^[0-9]+$/ { sum += $1 } END { print sum+0 }' \
2>/dev/null || echo ""
)
fi
if [[ -z "$ACTUAL_BYTES" || "$ACTUAL_BYTES" == "0" ]]; then
echo "[X] Could not determine size for ${IMAGE} platform=${PLATFORM}" >&2
echo " Possible causes: buildx version too old, manifest format unsupported, image not pushed yet" >&2
echo " Refusing to silently pass — fix the inspection or push the image first" >&2
exit 1
fi
else
# Local-image path: use docker image inspect (host architecture only)
if ! docker image inspect "$IMAGE" > /dev/null 2>&1; then
echo "[i] Pulling ${IMAGE}..." >&2
docker pull "$IMAGE" >&2
fi
ACTUAL_BYTES=$(docker image inspect "$IMAGE" --format='{{.Size}}' 2>/dev/null)
if [[ -z "$ACTUAL_BYTES" ]]; then
echo "[X] Could not inspect image: ${IMAGE}" >&2
exit 1
fi
fi
ACTUAL_MB=$(( ACTUAL_BYTES / 1048576 ))
LABEL="${IMAGE}${PLATFORM:+ (${PLATFORM})}"
if (( ACTUAL_BYTES > MAX_BYTES )); then
echo "[X] Image size check FAILED: ${LABEL}" >&2
echo " Actual: ${ACTUAL_BYTES} bytes (${ACTUAL_MB} MB)" >&2
echo " Budget: ${MAX_BYTES} bytes (${MAX_MB} MB)" >&2
echo " Excess: $(( ACTUAL_BYTES - MAX_BYTES )) bytes ($(( ACTUAL_MB - MAX_MB )) MB over budget)" >&2
exit 1
fi
echo "[OK] Image size check PASSED: ${LABEL}"
echo " Actual: ${ACTUAL_BYTES} bytes (${ACTUAL_MB} MB)"
echo " Budget: ${MAX_BYTES} bytes (${MAX_MB} MB)"
echo " Margin: $(( MAX_BYTES - ACTUAL_BYTES )) bytes ($(( MAX_MB - ACTUAL_MB )) MB remaining)"
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# tests/docker/network-contract.sh
#
# Verifies the stable ccs-net Docker network contract:
# - Network name: ccs-net
# - Service DNS: ccs
# - CLIProxy: http://ccs:8317
# - Dashboard: http://ccs:3000
#
# Requires: Docker with compose plugin, internet access to pull curlimages/curl
# Usage: bash tests/docker/network-contract.sh [compose-file] [image-ref]
# compose-file Path to compose file (default: docker/compose.yaml)
# image-ref Override the image used in the compose file (optional).
# When set, the compose stack is run with that image instead
# of whatever is pinned in the compose file.
# Called from repo root so the default path resolves.
#
set -euo pipefail
COMPOSE_FILE="${1:-docker/compose.yaml}"
IMAGE_OVERRIDE="${2:-}"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
log() { printf '[i] %s\n' "$*"; }
ok() { printf '[OK] %s\n' "$*"; }
err() { printf '[X] %s\n' "$*" >&2; }
# ---------------------------------------------------------------------------
# Bring stack up; register teardown on any exit
# ---------------------------------------------------------------------------
log "Bringing CCS stack up: $COMPOSE_FILE"
if [[ -n "$IMAGE_OVERRIDE" ]]; then
log "Overriding image with: $IMAGE_OVERRIDE"
CCS_IMAGE="$IMAGE_OVERRIDE" docker compose -f "$COMPOSE_FILE" up -d
else
docker compose -f "$COMPOSE_FILE" up -d
fi
cleanup() {
log "Tearing down stack..."
docker compose -f "$COMPOSE_FILE" down -v 2>/dev/null || true
}
trap cleanup EXIT
# ---------------------------------------------------------------------------
# Wait for healthcheck (max 90s) — use jq instead of python3 for CI portability
# ---------------------------------------------------------------------------
log "Waiting for healthcheck to pass (max 90s)..."
WAIT_MAX=45 # 45 x 2s = 90s
HEALTHY=0
for _i in $(seq 1 "$WAIT_MAX"); do
STATUS=$(
docker compose -f "$COMPOSE_FILE" ps --format json 2>/dev/null \
| jq -r 'if type == "array" then .[] else . end | select(.Service != null and (.Service | contains("ccs"))) | .Health // "unknown"' \
2>/dev/null | head -1 || echo "unknown"
)
STATUS="${STATUS:-unknown}"
if [ "$STATUS" = "healthy" ]; then
HEALTHY=1
break
fi
log "Health: $STATUS (attempt ${_i}/${WAIT_MAX})"
sleep 2
done
if [ "$HEALTHY" -ne 1 ]; then
err "Container did not become healthy within 90s"
exit 1
fi
ok "Container is healthy"
# ---------------------------------------------------------------------------
# Verify ccs-net network exists on the host
# ---------------------------------------------------------------------------
log "Inspecting ccs-net network..."
docker network inspect ccs-net >/dev/null
ok "ccs-net network exists"
# ---------------------------------------------------------------------------
# Verify DNS resolution from a sibling container on ccs-net
# ---------------------------------------------------------------------------
log "Testing http://ccs:8317 from sibling container..."
docker run --rm \
--network ccs-net \
curlimages/curl:latest \
-fsS --max-time 10 \
http://ccs:8317/ \
>/dev/null
ok "CLIProxy reachable at http://ccs:8317"
log "Testing http://ccs:3000 from sibling container..."
docker run --rm \
--network ccs-net \
curlimages/curl:latest \
-fsS --max-time 10 \
http://ccs:3000/ \
>/dev/null
ok "Dashboard reachable at http://ccs:3000"
# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
ok "network contract verified"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# quickstart-parity.sh
# Assert that README.md and docker/README.md both contain the canonical quickstart
# snippet verbatim (anchored by marker comments).
# Usage: bash tests/docs/quickstart-parity.sh (from repo root)
set -euo pipefail
SNIPPET=$(awk '/<!-- quickstart-snippet-start -->/,/<!-- quickstart-snippet-end -->/' docs/quickstart-snippet.md)
fail=0
for f in README.md docker/README.md; do
file_block=$(awk '/<!-- quickstart-snippet-start -->/,/<!-- quickstart-snippet-end -->/' "$f")
if ! diff -q <(printf '%s' "$SNIPPET") <(printf '%s' "$file_block") >/dev/null 2>&1; then
echo "[X] $f quickstart snippet drift detected" >&2
echo "--- canonical (docs/quickstart-snippet.md) ---" >&2
printf '%s\n' "$SNIPPET" >&2
echo "--- found in $f ---" >&2
printf '%s\n' "$file_block" >&2
fail=1
else
echo "[OK] $f snippet matches canonical"
fi
done
exit "$fail"
@@ -0,0 +1,129 @@
/**
* Integration tests: ccsxp independence from codex-auth profiles.
*
* Verifies that setting a codex-auth default profile does not affect ccsxp's
* own CODEX_HOME resolution. ccsxp unconditionally overwrites CODEX_HOME via
* resolveCcsxpCodexHome() — any prior CCS_CODEX_PROFILE value is ignored.
*
* Also verifies that the H5 stderr notice is emitted when CCS_CODEX_PROFILE
* is set inside ccsxp context.
*
* Cases:
* - codex-auth default set; resolveActiveProfile reads it; ccsxp resolver is
* independent (resolves its own path, not the codex-auth profile dir)
* - H5 notice: when CCS_CODEX_PROFILE is set and ccsxp-runtime path is hit,
* stderr notice is emitted
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
let tempDir: string;
let ccsHome: string;
const ORIG_CCS_HOME = process.env.CCS_HOME;
const ORIG_CCS_CODEX_PROFILE = process.env.CCS_CODEX_PROFILE;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-ccsxp-indep-'));
ccsHome = path.join(tempDir, 'ccs');
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true });
process.env.CCS_HOME = ccsHome;
// Ensure CCS_CODEX_PROFILE is unset at start of each test
delete process.env.CCS_CODEX_PROFILE;
});
afterEach(() => {
if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME;
else process.env.CCS_HOME = ORIG_CCS_HOME;
if (ORIG_CCS_CODEX_PROFILE === undefined) delete process.env.CCS_CODEX_PROFILE;
else process.env.CCS_CODEX_PROFILE = ORIG_CCS_CODEX_PROFILE;
fs.rmSync(tempDir, { recursive: true, force: true });
});
// ─────────────────────────────────────────────────────────────────────────────
describe('ccsxp independence — resolver isolation', () => {
it('resolveActiveProfile returns codex-auth profile dir; ccsxp path is a separate namespace', async () => {
// Create a codex-auth profile "work" and set it as default
const { CodexProfileRegistry } = await import('../../../src/codex-auth/codex-profile-registry');
const { resolveCodexProfileDir } = await import('../../../src/codex-auth/codex-profile-paths');
const registry = new CodexProfileRegistry();
const workDir = resolveCodexProfileDir('work');
fs.mkdirSync(workDir, { recursive: true, mode: 0o700 });
registry.createProfile('work', { created: new Date().toISOString(), last_used: null });
registry.setDefault('work');
// resolveActiveProfile should find "work" profile via registry default
const { resolveActiveProfile } = await import('../../../src/codex-auth/resolve-active-profile');
const resolved = resolveActiveProfile({});
expect(resolved).not.toBeNull();
expect(resolved?.name).toBe('work');
expect(resolved?.dir).toContain('work');
// The resolved dir is within CCS instances dir — not in ccsxp's pool
expect(resolved?.dir).toContain('codex-instances');
expect(resolved?.dir).not.toContain('cliproxy');
// ccsxp's pool path is separate — confirm namespace isolation
// ccsxp reads from ~/.ccs/cliproxy/auth/, codex-auth uses ~/.ccs/codex-instances/
// These are distinct trees that never overlap
const ccsxpPoolPath = path.join(ccsHome, '.ccs', 'cliproxy', 'auth');
const codexAuthPath = path.join(ccsHome, '.ccs', 'codex-instances');
expect(ccsxpPoolPath).not.toBe(codexAuthPath);
expect(resolved?.dir.startsWith(codexAuthPath)).toBe(true);
expect(resolved?.dir.startsWith(ccsxpPoolPath)).toBe(false);
});
});
describe('ccsxp independence — H5 stderr notice', () => {
it('emits H5 notice when CCS_CODEX_PROFILE is set and ccsxp-runtime resolves', async () => {
// H5: when ccsxp-runtime.ts loads with CCS_CODEX_PROFILE set in env,
// it emits: "[i] CCS_CODEX_PROFILE is ignored by ccsxp; profile applies to native 'codex' only"
// We test by directly calling the runtime function that emits this notice.
// Create codex-auth profile so the env var is "valid" from codex-auth's perspective
const { CodexProfileRegistry } = await import('../../../src/codex-auth/codex-profile-registry');
const { resolveCodexProfileDir } = await import('../../../src/codex-auth/codex-profile-paths');
const registry = new CodexProfileRegistry();
const profileDir = resolveCodexProfileDir('personal');
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
registry.createProfile('personal', { created: new Date().toISOString(), last_used: null });
// Set CCS_CODEX_PROFILE — this is what a user would have from eval "$(ccsx auth use personal)"
process.env.CCS_CODEX_PROFILE = 'personal';
// Capture stderr to verify notice
const stderrLines: string[] = [];
const origWrite = process.stderr.write.bind(process.stderr);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
process.stderr.write = (chunk: any, ...args: any[]): boolean => {
stderrLines.push(String(chunk));
return true;
};
try {
// Import and invoke the ccsxp notice function from Phase 3
// The notice is emitted by resolveCcsxpCodexHome or the ccsxp-runtime entry
// We test the resolveActiveProfile path for ccsxp context: when CODEX_HOME
// is being set by ccsxp unconditionally, CCS_CODEX_PROFILE is bypassed.
// The H5 notice is a stderr line emitted before CODEX_HOME override.
// Directly check: resolveActiveProfile with CCS_CODEX_PROFILE set still resolves
const { resolveActiveProfile } = await import(
'../../../src/codex-auth/resolve-active-profile'
);
const resolved = resolveActiveProfile({ CCS_CODEX_PROFILE: 'personal' });
// From codex-auth's perspective, CCS_CODEX_PROFILE='personal' is valid
expect(resolved?.name).toBe('personal');
} finally {
process.stderr.write = origWrite;
}
// ccsxp runtime unconditionally overwrites CODEX_HOME — verified by architecture.
// The H5 notice is emitted from src/bin/ccsxp-runtime.ts when CCS_CODEX_PROFILE
// is detected in env. We verify the contract here: the env var does NOT affect
// ccsxp's own path resolution (it always uses resolveCcsxpCodexHome()).
// Full H5 notice test is in ccsxp-runtime unit tests (phase-03 scope).
expect(process.env.CCS_CODEX_PROFILE).toBe('personal');
});
});
@@ -0,0 +1,222 @@
/**
* Integration tests for `ccsx auth import-default`.
*
* Uses real filesystem rooted at temp dirs. Sets LEGACY_CODEX_HOME env for
* test hermeticity. Verifies the full import pipeline end-to-end.
*
* Cases:
* - End-to-end import: registry entry + symlink + atomic write (no tmp leftovers)
* - Decoded email present in registry after import
* - --with-history copies history.jsonl + sessions/
* - Re-run without --force refuses; re-run with --force succeeds + backup created
*/
import { afterEach, beforeEach, describe, expect, it, spyOn, mock } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as childProcess from 'child_process';
// Build a minimal valid JWT for test fixtures
function makeJwt(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `${header}.${body}.fakesig`;
}
const TEST_EMAIL = 'integration@example.com';
const TEST_JWT = makeJwt({
email: TEST_EMAIL,
'https://api.openai.com/auth': {
chatgpt_plan_type: 'plus',
chatgpt_account_id: 'acct-integration-001',
},
});
const TEST_AUTH_JSON = JSON.stringify({ tokens: { id_token: TEST_JWT } }, null, 2);
let tempDir: string;
let ccsHome: string;
let legacyCodexHome: string;
const ORIG_CCS_HOME = process.env.CCS_HOME;
const ORIG_LEGACY = process.env.LEGACY_CODEX_HOME;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-import-integ-'));
ccsHome = path.join(tempDir, 'ccs');
legacyCodexHome = path.join(tempDir, 'legacy-codex');
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true });
fs.mkdirSync(legacyCodexHome, { recursive: true });
process.env.CCS_HOME = ccsHome;
process.env.LEGACY_CODEX_HOME = legacyCodexHome;
// Prevent pgrep from finding the test runner itself
spyOn(childProcess, 'spawnSync').mockReturnValue({
status: 1,
stdout: '',
stderr: '',
pid: 0,
output: [],
signal: null,
error: undefined,
});
});
afterEach(() => {
if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME;
else process.env.CCS_HOME = ORIG_CCS_HOME;
if (ORIG_LEGACY === undefined) delete process.env.LEGACY_CODEX_HOME;
else process.env.LEGACY_CODEX_HOME = ORIG_LEGACY;
fs.rmSync(tempDir, { recursive: true, force: true });
mock.restore();
});
async function makeCtx() {
const { CodexProfileRegistry } = await import('../../../src/codex-auth/codex-profile-registry');
return { registry: new CodexProfileRegistry(), version: '0.0.0-test' };
}
function silence(): () => void {
const origLog = console.log;
const origErr = process.stderr.write.bind(process.stderr);
console.log = () => {};
process.stderr.write = () => true;
return () => {
console.log = origLog;
process.stderr.write = origErr;
};
}
// ─────────────────────────────────────────────────────────────────────────────
describe('import-default integration — end-to-end happy path', () => {
it('creates registry entry with decoded email and symlink after import', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), TEST_AUTH_JSON);
const { handleImportDefaultCodex } = await import(
'../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
const restore = silence();
try {
await handleImportDefaultCodex(ctx, ['integ-profile']);
} finally {
restore();
}
// Registry entry created with email
expect(ctx.registry.hasProfile('integ-profile')).toBe(true);
const meta = ctx.registry.getProfile('integ-profile');
expect(meta.email).toBe(TEST_EMAIL);
expect(meta.plan_type).toBe('plus');
// auth.json written to profile dir
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'integ-profile');
const destAuth = path.join(profileDir, 'auth.json');
expect(fs.existsSync(destAuth)).toBe(true);
// config.toml is a symlink (or was attempted — skip check on no-symlink platforms)
const configLink = path.join(profileDir, 'config.toml');
if (fs.existsSync(configLink)) {
const stat = fs.lstatSync(configLink);
expect(stat.isSymbolicLink()).toBe(true);
}
// No tmp leftovers in profile dir (atomic write cleanup)
const files = fs.readdirSync(profileDir);
const tmpFiles = files.filter((f) => f.includes('.tmp.'));
expect(tmpFiles.length).toBe(0);
});
});
describe('import-default integration — with-history flag', () => {
it('copies history.jsonl and sessions/ when --with-history is passed', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), TEST_AUTH_JSON);
fs.writeFileSync(
path.join(legacyCodexHome, 'history.jsonl'),
'{"prompt":"hello","response":"world"}\n'
);
const sessDir = path.join(legacyCodexHome, 'sessions');
fs.mkdirSync(sessDir, { recursive: true });
fs.writeFileSync(path.join(sessDir, 'sess-001.json'), JSON.stringify({ id: 'sess-001' }));
fs.writeFileSync(path.join(sessDir, 'sess-002.json'), JSON.stringify({ id: 'sess-002' }));
const { handleImportDefaultCodex } = await import(
'../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
const restore = silence();
try {
await handleImportDefaultCodex(ctx, ['with-hist', '--with-history']);
} finally {
restore();
}
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'with-hist');
expect(fs.existsSync(path.join(profileDir, 'history.jsonl'))).toBe(true);
expect(fs.existsSync(path.join(profileDir, 'sessions', 'sess-001.json'))).toBe(true);
expect(fs.existsSync(path.join(profileDir, 'sessions', 'sess-002.json'))).toBe(true);
});
});
describe('import-default integration — force re-import', () => {
it('refuses re-import without --force; succeeds with --force + creates backup', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), TEST_AUTH_JSON);
const { handleImportDefaultCodex } = await import(
'../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
// First import — should succeed
const r1 = silence();
try {
await handleImportDefaultCodex(ctx, ['force-test']);
} finally {
r1();
}
expect(ctx.registry.hasProfile('force-test')).toBe(true);
// Re-import without --force — should refuse
let exitCalled = false;
const origExit = process.exit;
process.exit = () => {
exitCalled = true;
throw new Error('exit');
};
const r2 = silence();
try {
await handleImportDefaultCodex(ctx, ['force-test']);
} catch {
/* expected */
} finally {
r2();
process.exit = origExit;
}
expect(exitCalled).toBe(true);
// Re-import with --force — should overwrite + backup
const newJwt = makeJwt({ email: 'updated@example.com' });
fs.writeFileSync(
path.join(legacyCodexHome, 'auth.json'),
JSON.stringify({ tokens: { id_token: newJwt } })
);
const r3 = silence();
try {
await handleImportDefaultCodex(ctx, ['force-test', '--force']);
} finally {
r3();
}
// Registry updated with new email
const meta = ctx.registry.getProfile('force-test');
expect(meta.email).toBe('updated@example.com');
// Backup file created
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'force-test');
const files = fs.readdirSync(profileDir);
const bakFile = files.find((f) => f.startsWith('auth.json.bak-'));
expect(bakFile).toBeDefined();
});
});
@@ -0,0 +1,100 @@
/**
* Integration tests: legacy fallback when no profiles are registered.
*
* Verifies that with no codex-auth profiles and no CCS_CODEX_PROFILE env set,
* resolveActiveProfile returns null — allowing codex to fall back to ~/.codex
* (legacy mode). This guarantees zero behaviour change for users who never
* run `ccsx auth create`.
*
* Cases:
* - Empty registry → resolveActiveProfile returns null (legacy mode)
* - Missing registry file → returns null (no registry = legacy mode)
* - CCS_CODEX_PROFILE set but registry missing or unmatched → throws to avoid unsafe fallback
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
let tempDir: string;
let ccsHome: string;
const ORIG_CCS_HOME = process.env.CCS_HOME;
const ORIG_CCS_CODEX_PROFILE = process.env.CCS_CODEX_PROFILE;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-legacy-fallback-'));
ccsHome = path.join(tempDir, 'ccs');
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true });
process.env.CCS_HOME = ccsHome;
delete process.env.CCS_CODEX_PROFILE;
});
afterEach(() => {
if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME;
else process.env.CCS_HOME = ORIG_CCS_HOME;
if (ORIG_CCS_CODEX_PROFILE === undefined) delete process.env.CCS_CODEX_PROFILE;
else process.env.CCS_CODEX_PROFILE = ORIG_CCS_CODEX_PROFILE;
fs.rmSync(tempDir, { recursive: true, force: true });
});
// ─────────────────────────────────────────────────────────────────────────────
describe('legacy fallback — no registry file', () => {
it('returns null when registry file does not exist (CODEX_HOME stays unset)', async () => {
// CCS_HOME points to empty temp dir — no codex-profiles.yaml created
const registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml');
expect(fs.existsSync(registryPath)).toBe(false);
const { resolveActiveProfile } = await import('../../../src/codex-auth/resolve-active-profile');
const result = resolveActiveProfile({});
expect(result).toBeNull();
// When null: caller (codex-runtime.ts) leaves CODEX_HOME unset → Codex uses ~/.codex
});
});
describe('legacy fallback — empty registry', () => {
it('returns null when registry exists but has no profiles and no default', async () => {
const { CodexProfileRegistry } = await import('../../../src/codex-auth/codex-profile-registry');
// Touch registry by constructing (which cleans orphan tmps but doesn't write)
// Write an empty registry manually
const registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml');
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(registryPath, 'version: "1.0"\ndefault: null\nprofiles: {}\n', {
mode: 0o600,
});
const { resolveActiveProfile } = await import('../../../src/codex-auth/resolve-active-profile');
const result = resolveActiveProfile({});
expect(result).toBeNull();
// Registry exists but no profiles → legacy mode
void new CodexProfileRegistry(); // verify registry reads cleanly
});
});
describe('legacy fallback — CCS_CODEX_PROFILE set but no matching profile', () => {
it('throws when env points to non-existent profile', async () => {
// Create registry with no profiles
const registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml');
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(registryPath, 'version: "1.0"\ndefault: null\nprofiles: {}\n', {
mode: 0o600,
});
const { resolveActiveProfile } = await import('../../../src/codex-auth/resolve-active-profile');
expect(() => resolveActiveProfile({ CCS_CODEX_PROFILE: 'ghost-profile' })).toThrow(
/ghost-profile/
);
});
it('throws when env is set but registry file is missing', async () => {
const registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml');
expect(fs.existsSync(registryPath)).toBe(false);
const { resolveActiveProfile } = await import('../../../src/codex-auth/resolve-active-profile');
expect(() => resolveActiveProfile({ CCS_CODEX_PROFILE: 'ghost-profile' })).toThrow(
/does not exist/
);
});
});
@@ -0,0 +1,108 @@
/**
* Integration tests: two-terminal profile isolation.
*
* Verifies that two profiles with separate CODEX_HOME dirs write to their own
* auth.json/history.jsonl with zero crosstalk. Uses real filesystem.
*
* Cases:
* - Profiles A and B have independent auth.json (writing A does not touch B)
* - Profiles A and B have independent history.jsonl (writing A does not touch B)
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
let tempDir: string;
let ccsHome: string;
const ORIG_CCS_HOME = process.env.CCS_HOME;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-two-terminal-'));
ccsHome = path.join(tempDir, 'ccs');
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true });
process.env.CCS_HOME = ccsHome;
});
afterEach(() => {
if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME;
else process.env.CCS_HOME = ORIG_CCS_HOME;
fs.rmSync(tempDir, { recursive: true, force: true });
});
function makeJwt(email: string): string {
const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString('base64url');
const body = Buffer.from(JSON.stringify({ email })).toString('base64url');
return `${header}.${body}.sig`;
}
async function createProfile(name: string) {
const { CodexProfileRegistry } = await import('../../../src/codex-auth/codex-profile-registry');
const { resolveCodexProfileDir } = await import('../../../src/codex-auth/codex-profile-paths');
const registry = new CodexProfileRegistry();
const dir = resolveCodexProfileDir(name);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
registry.createProfile(name, { created: new Date().toISOString(), last_used: null });
return { dir, registry };
}
// ─────────────────────────────────────────────────────────────────────────────
describe('two-terminal isolation — auth.json independence', () => {
it('writing auth.json to profile A does not affect profile B', async () => {
const { dir: dirA } = await createProfile('terminal-a');
const { dir: dirB } = await createProfile('terminal-b');
const authA = path.join(dirA, 'auth.json');
const authB = path.join(dirB, 'auth.json');
// Simulate Codex writing auth.json for profile A (token refresh)
const tokenA = JSON.stringify({ tokens: { id_token: makeJwt('a@example.com') } });
fs.writeFileSync(authA, tokenA, { mode: 0o600 });
// Profile B auth.json must not exist (untouched)
expect(fs.existsSync(authB)).toBe(false);
// Now simulate a login for profile B
const tokenB = JSON.stringify({ tokens: { id_token: makeJwt('b@example.com') } });
fs.writeFileSync(authB, tokenB, { mode: 0o600 });
// Verify A's auth.json content is unchanged — same token that was written for A
const readA = fs.readFileSync(authA, 'utf8');
expect(readA).toBe(tokenA);
// Verify each profile dir is fully independent
expect(dirA).not.toBe(dirB);
expect(dirA.endsWith('terminal-a')).toBe(true);
expect(dirB.endsWith('terminal-b')).toBe(true);
});
});
describe('two-terminal isolation — history.jsonl independence', () => {
it('writing history.jsonl to profile A does not affect profile B', async () => {
const { dir: dirA } = await createProfile('hist-a');
const { dir: dirB } = await createProfile('hist-b');
const histA = path.join(dirA, 'history.jsonl');
const histB = path.join(dirB, 'history.jsonl');
// Profile A writes history
fs.writeFileSync(histA, '{"prompt":"hello from A"}\n');
// Profile B history must not exist
expect(fs.existsSync(histB)).toBe(false);
// Profile B writes its own history
fs.writeFileSync(histB, '{"prompt":"hello from B"}\n');
// A's history unchanged
const contentA = fs.readFileSync(histA, 'utf8');
expect(contentA).toContain('hello from A');
expect(contentA).not.toContain('hello from B');
// B's history has its own entry only
const contentB = fs.readFileSync(histB, 'utf8');
expect(contentB).toContain('hello from B');
expect(contentB).not.toContain('hello from A');
});
});
@@ -0,0 +1,281 @@
/**
* Integration tests for GET /api/codex/profiles endpoint.
*
* Covers:
* - localhost GET -> 200 + correct shape
* - non-localhost (mocked remote IP) -> 403 (H6 localhost guard)
* - empty registry -> {active: null, default: null, profiles: []} (no 404)
* - response contains no token substrings
*/
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as http from 'http';
import * as os from 'os';
import * as path from 'path';
import express from 'express';
let tmpDir: string;
let ccsDir: string;
let server: http.Server | null = null;
let port: number;
// Helpers ------------------------------------------------------------------
function buildToken(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `${header}.${body}.fakesig`;
}
function writeAuthJson(profileDir: string, payload: Record<string, unknown>): void {
const authJson = {
tokens: {
id_token: buildToken(payload),
access_token: 'MUST_NOT_APPEAR_IN_RESPONSE',
refresh_token: 'MUST_NOT_APPEAR_IN_RESPONSE',
},
};
fs.writeFileSync(path.join(profileDir, 'auth.json'), JSON.stringify(authJson), {
mode: 0o600,
});
}
async function startApp(): Promise<void> {
// Invalidate cache before each test
const svc = await import('../../../src/codex-auth/codex-auth-dashboard-service');
svc.invalidateCodexAuthProfilesCache();
const codexRouter = (await import('../../../src/web-server/routes/codex-routes')).default;
const app = express();
app.use(express.json());
app.use('/api/codex', codexRouter);
await new Promise<void>((resolve) => {
server = app.listen(0, '127.0.0.1', () => resolve());
});
port = (server!.address() as { port: number }).port;
}
async function stopApp(): Promise<void> {
if (server) {
await new Promise<void>((resolve, reject) => {
server!.close((err) => (err ? reject(err) : resolve()));
});
server = null;
}
}
async function get(urlPath: string): Promise<{ status: number; body: unknown }> {
const res = await fetch(`http://127.0.0.1:${port}${urlPath}`);
const body = await res.json();
return { status: res.status, body };
}
// Setup / teardown ---------------------------------------------------------
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-int-test-'));
process.env.CCS_HOME = tmpDir;
// getCcsDir() returns path.join(CCS_HOME, '.ccs')
ccsDir = path.join(tmpDir, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
delete process.env.CODEX_HOME;
delete process.env.CCS_CODEX_PROFILE;
delete process.env.CCS_DASHBOARD_AUTH_ENABLED;
await startApp();
});
afterEach(async () => {
await stopApp();
delete process.env.CCS_HOME;
delete process.env.CODEX_HOME;
delete process.env.CCS_CODEX_PROFILE;
mock.restore();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
// Tests --------------------------------------------------------------------
describe('GET /api/codex/profiles', () => {
it('returns 200 with empty shape when registry does not exist', async () => {
const { status, body } = await get('/api/codex/profiles');
expect(status).toBe(200);
const b = body as Record<string, unknown>;
expect(b.active).toBeNull();
expect(b.default).toBeNull();
expect(Array.isArray(b.profiles)).toBe(true);
expect((b.profiles as unknown[]).length).toBe(0);
});
it('returns 500 when registry YAML is malformed', async () => {
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
fs.writeFileSync(registryPath, '{ invalid: yaml: [', { mode: 0o600 });
const svc = await import('../../../src/codex-auth/codex-auth-dashboard-service');
svc.invalidateCodexAuthProfilesCache();
const { status, body } = await get('/api/codex/profiles');
expect(status).toBe(500);
expect((body as { error?: string }).error).toContain('could not be read safely');
});
it('returns 500 when a malformed registry appears after an empty response was cached', async () => {
const first = await get('/api/codex/profiles');
expect(first.status).toBe(200);
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
fs.writeFileSync(registryPath, '{ invalid: yaml: [', { mode: 0o600 });
const future = new Date(Date.now() + 10_000);
fs.utimesSync(registryPath, future, future);
const { status, body } = await get('/api/codex/profiles');
expect(status).toBe(500);
expect((body as { error?: string }).error).toContain('could not be read safely');
});
it('returns a sanitized 500 when registry stat fails', async () => {
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
const rawMessage = `EACCES: permission denied, stat '${registryPath}'`;
const realStatSync = fs.statSync;
spyOn(fs, 'statSync').mockImplementation((target) => {
if (target === registryPath) {
const err = new Error(rawMessage) as NodeJS.ErrnoException;
err.code = 'EACCES';
throw err;
}
return realStatSync(target);
});
const { status, body } = await get('/api/codex/profiles');
const error = (body as { error?: string }).error ?? '';
expect(status).toBe(500);
expect(error).toContain('could not be checked safely');
expect(error).not.toContain(registryPath);
expect(error).not.toContain('EACCES');
});
it('returns 200 with decoded email and plan for a valid profile', async () => {
const instancesDir = path.join(ccsDir, 'codex-instances');
const workDir = path.join(instancesDir, 'work');
fs.mkdirSync(workDir, { recursive: true });
writeAuthJson(workDir, {
email: 'work@example.com',
'https://api.openai.com/auth': {
chatgpt_plan_type: 'pro',
chatgpt_account_id: 'acct-work',
},
});
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
fs.writeFileSync(
registryPath,
`version: "1.0"\ndefault: work\nprofiles:\n work:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: "2026-05-17T05:45:00Z"\n`,
{ mode: 0o600 }
);
// Invalidate cache so the new files are read
const svc = await import('../../../src/codex-auth/codex-auth-dashboard-service');
svc.invalidateCodexAuthProfilesCache();
const { status, body } = await get('/api/codex/profiles');
const b = body as Record<string, unknown>;
expect(status).toBe(200);
const profiles = b.profiles as Array<Record<string, unknown>>;
expect(profiles.length).toBe(1);
const profile = profiles[0];
expect(profile?.name).toBe('work');
expect(profile?.email).toBe('work@example.com');
expect(profile?.plan).toBe('pro');
expect(profile?.authValid).toBe(true);
const active = b.active as Record<string, unknown>;
expect(active?.source).toBe('default');
expect(active?.name).toBe('work');
});
it('returns 403 when requireLocalAccessWhenAuthDisabled guard rejects non-localhost', async () => {
// The guard checks req.socket.remoteAddress. Since the server binds to
// 127.0.0.1 and the test client connects to 127.0.0.1, the built-in fetch
// will always be loopback. We test the guard directly via a separate
// Express app that injects a non-loopback remote address.
const { requireLocalAccessWhenAuthDisabled } = await import(
'../../../src/web-server/middleware/auth-middleware'
);
const { isDashboardAuthEnabled } = await import('../../../src/config/config-loader-facade');
if (!isDashboardAuthEnabled()) {
let guardResult: boolean | undefined;
let responseStatus: number | undefined;
const testApp = express();
testApp.get('/test', (req, res) => {
// Spoof a non-localhost remote address
Object.defineProperty(req, 'socket', {
value: { remoteAddress: '203.0.113.42' },
writable: true,
configurable: true,
});
guardResult = requireLocalAccessWhenAuthDisabled(req, res, 'localhost only');
if (guardResult) {
responseStatus = 200;
res.json({ ok: true });
} else {
responseStatus = 403;
}
});
const testServer = await new Promise<http.Server>((resolve) => {
const s = testApp.listen(0, '127.0.0.1', () => resolve(s));
});
const testPort = (testServer.address() as { port: number }).port;
const res = await fetch(`http://127.0.0.1:${testPort}/test`);
await new Promise<void>((resolve) => testServer.close(() => resolve()));
expect(res.status).toBe(403);
expect(guardResult).toBe(false);
}
});
it('response body contains no token substrings for a valid profile', async () => {
const instancesDir = path.join(ccsDir, 'codex-instances');
const workDir = path.join(instancesDir, 'work');
fs.mkdirSync(workDir, { recursive: true });
writeAuthJson(workDir, {
email: 'secure@example.com',
'https://api.openai.com/auth': {
chatgpt_plan_type: 'pro',
chatgpt_account_id: 'acct-secure',
},
});
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
fs.writeFileSync(
registryPath,
`version: "1.0"\ndefault: work\nprofiles:\n work:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`,
{ mode: 0o600 }
);
// Invalidate cache so the new files are read
const svc = await import('../../../src/codex-auth/codex-auth-dashboard-service');
svc.invalidateCodexAuthProfilesCache();
const { status, body } = await get('/api/codex/profiles');
expect(status).toBe(200);
const bodyStr = JSON.stringify(body);
expect(bodyStr).not.toContain('id_token');
expect(bodyStr).not.toContain('access_token');
expect(bodyStr).not.toContain('refresh_token');
expect(bodyStr).not.toContain('MUST_NOT_APPEAR_IN_RESPONSE');
});
});
+30 -3
View File
@@ -7,9 +7,10 @@ const ccsPath = require.resolve('../../../src/ccs.ts');
describe('ccsxp runtime wrapper', () => {
const originalArgv = process.argv;
const originalEntryTarget = process.env.CCS_INTERNAL_ENTRY_TARGET;
const originalCodexHome = process.env.CODEX_HOME;
const originalCcsxpCodexHome = process.env.CCSXP_CODEX_HOME;
const originalEntryTarget = process.env.CCS_INTERNAL_ENTRY_TARGET;
const originalCodexHome = process.env.CODEX_HOME;
const originalCcsCodexProfile = process.env.CCS_CODEX_PROFILE;
const originalCcsxpCodexHome = process.env.CCSXP_CODEX_HOME;
beforeEach(() => {
delete require.cache[wrapperPath];
@@ -29,6 +30,11 @@ describe('ccsxp runtime wrapper', () => {
} else {
process.env.CODEX_HOME = originalCodexHome;
}
if (originalCcsCodexProfile === undefined) {
delete process.env.CCS_CODEX_PROFILE;
} else {
process.env.CCS_CODEX_PROFILE = originalCcsCodexProfile;
}
if (originalCcsxpCodexHome === undefined) {
delete process.env.CCSXP_CODEX_HOME;
} else {
@@ -75,6 +81,27 @@ describe('ccsxp runtime wrapper', () => {
expect(process.env.CODEX_HOME).toBe('/tmp/explicit-ccsxp-codex-home');
});
it('emits a notice when CCS_CODEX_PROFILE is ignored by ccsxp', () => {
process.env.CCS_CODEX_PROFILE = 'work';
process.argv = ['node', wrapperPath, '--version'];
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
const stderrChunks: string[] = [];
const origWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk: string | Uint8Array): boolean => {
stderrChunks.push(typeof chunk === 'string' ? chunk : chunk.toString());
return true;
};
try {
require(wrapperPath);
} finally {
process.stderr.write = origWrite;
}
expect(process.env.CODEX_HOME).toBe(path.join(os.homedir(), '.codex'));
expect(stderrChunks.join('')).toContain('CCS_CODEX_PROFILE is ignored by ccsxp');
});
it('keeps flag-only invocations routed through the native cliproxy shortcut', () => {
process.argv = ['node', wrapperPath, '--version'];
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
+366
View File
@@ -0,0 +1,366 @@
/**
* Tests for the codex-runtime router module (src/bin/codex-runtime-router.ts).
*
* Strategy: mirrors ccsxp-runtime.test.ts — invalidate require.cache before
* each test so the module re-evaluates with updated stubs and env state.
* Stub runCodexAuth and require('../ccs') via require.cache injection.
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as yaml from 'js-yaml';
const routerPath = require.resolve('../../../src/bin/codex-runtime-router.ts');
const ccsPath = require.resolve('../../../src/ccs.ts');
const codexAuthRouterPath = require.resolve('../../../src/codex-auth/codex-auth-router.ts');
const resolveProfilePath = require.resolve('../../../src/codex-auth/resolve-active-profile.ts');
const symlinkPath = require.resolve('../../../src/codex-auth/codex-config-symlink.ts');
const ORIGINAL_CCS_HOME = process.env.CCS_HOME;
const ORIGINAL_CODEX_HOME = process.env.CODEX_HOME;
const ORIGINAL_CCS_CODEX_PROFILE = process.env.CCS_CODEX_PROFILE;
let tempDir: string;
let ccsHome: string;
let registryPath: string;
let instancesDir: string;
function flushRouterCache() {
delete require.cache[routerPath];
delete require.cache[codexAuthRouterPath];
delete require.cache[resolveProfilePath];
delete require.cache[symlinkPath];
// Keep ccsPath stub intact — tests inject it explicitly each time
}
function writeRegistry(data: object): void {
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(registryPath, yaml.dump(data, { indent: 2 }), { mode: 0o600 });
}
function makeProfileDir(name: string): string {
const dir = path.join(instancesDir, name);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
return dir;
}
async function withCapturedStderr<T>(fn: () => Promise<T>): Promise<{ result: T; stderr: string }> {
const stderrMessages: string[] = [];
const origWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk: string | Uint8Array): boolean => {
stderrMessages.push(typeof chunk === 'string' ? chunk : String(chunk));
return true;
};
try {
return { result: await fn(), stderr: stderrMessages.join('') };
} finally {
process.stderr.write = origWrite;
}
}
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-router-test-'));
ccsHome = path.join(tempDir, 'ccs');
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true, mode: 0o700 });
process.env.CCS_HOME = ccsHome;
registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml');
instancesDir = path.join(ccsHome, '.ccs', 'codex-instances');
delete process.env.CODEX_HOME;
delete process.env.CCS_CODEX_PROFILE;
flushRouterCache();
});
afterEach(() => {
if (ORIGINAL_CCS_HOME === undefined) delete process.env.CCS_HOME;
else process.env.CCS_HOME = ORIGINAL_CCS_HOME;
if (ORIGINAL_CODEX_HOME === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = ORIGINAL_CODEX_HOME;
if (ORIGINAL_CCS_CODEX_PROFILE === undefined) delete process.env.CCS_CODEX_PROFILE;
else process.env.CCS_CODEX_PROFILE = ORIGINAL_CCS_CODEX_PROFILE;
flushRouterCache();
delete require.cache[ccsPath];
fs.rmSync(tempDir, { recursive: true, force: true });
});
// ── Auth routing ──────────────────────────────────────────────────────────────
describe('codex-runtime router — auth subcommand routing', () => {
it('routes argv[2]===auth to runCodexAuth with remaining args and returns its exit code', async () => {
let capturedArgs: string[] | undefined;
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
require.cache[codexAuthRouterPath] = {
exports: {
runCodexAuth: async (args: string[]) => {
capturedArgs = args;
return 0;
},
},
} as NodeJS.Module;
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
const code = await main(['node', 'codex-runtime', 'auth', 'create', 'work']);
expect(capturedArgs).toEqual(['create', 'work']);
expect(code).toBe(0);
});
it('returns non-zero exit code propagated from runCodexAuth', async () => {
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
require.cache[codexAuthRouterPath] = {
exports: { runCodexAuth: async (_args: string[]) => 1 },
} as NodeJS.Module;
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
const code = await main(['node', 'codex-runtime', 'auth', 'login']);
expect(code).toBe(1);
});
});
// ── Non-auth profile resolution ───────────────────────────────────────────────
describe('codex-runtime router — non-auth profile resolution', () => {
it('sets CODEX_HOME from active profile when CCS_CODEX_PROFILE env matches registry', async () => {
const profileDir = makeProfileDir('work');
writeRegistry({
version: '1.0',
default: null,
profiles: { work: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null } },
});
process.env.CCS_CODEX_PROFILE = 'work';
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
// Let real resolve-active-profile + codex-config-symlink run; stub ccs only
flushRouterCache();
delete require.cache[ccsPath]; // ensure fresh load guard doesn't skip
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
await main(['node', 'codex-runtime', 'chat']);
expect(process.env.CODEX_HOME).toBe(profileDir);
});
it('leaves CODEX_HOME unset when no registry exists and no env profile set', async () => {
// No registry file, no CCS_CODEX_PROFILE
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
const code = await main(['node', 'codex-runtime', 'chat']);
expect(process.env.CODEX_HOME).toBeUndefined();
expect(code).toBe(-1); // CCS branch: entry must not call process.exit()
});
it('fails fast when CCS_CODEX_PROFILE points to a missing registry profile', async () => {
writeRegistry({
version: '1.0',
default: null,
profiles: {},
});
process.env.CCS_CODEX_PROFILE = 'ghost';
const { result: code, stderr } = await withCapturedStderr(async () => {
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
flushRouterCache();
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
return main(['node', 'codex-runtime', 'chat']);
});
expect(code).toBe(1);
expect(process.env.CODEX_HOME).toBeUndefined();
expect(stderr).toContain("CCS_CODEX_PROFILE='ghost'");
});
it('fails fast when CCS_CODEX_PROFILE is set but registry is missing', async () => {
process.env.CCS_CODEX_PROFILE = 'ghost';
const { result: code, stderr } = await withCapturedStderr(async () => {
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
flushRouterCache();
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
return main(['node', 'codex-runtime', 'chat']);
});
expect(code).toBe(1);
expect(process.env.CODEX_HOME).toBeUndefined();
expect(stderr).toContain('does not exist');
});
it('fails fast when CCS_CODEX_PROFILE is set and registry YAML is corrupt', async () => {
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(registryPath, 'profiles: [unterminated\n');
process.env.CCS_CODEX_PROFILE = 'ghost';
const { result: code, stderr } = await withCapturedStderr(async () => {
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
flushRouterCache();
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
return main(['node', 'codex-runtime', 'chat']);
});
expect(code).toBe(1);
expect(process.env.CODEX_HOME).toBeUndefined();
expect(stderr).toContain('registry YAML could not be parsed');
});
it('fails fast when registry YAML is corrupt even without CCS_CODEX_PROFILE', async () => {
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(registryPath, 'profiles: [unterminated\n');
const { result: code, stderr } = await withCapturedStderr(async () => {
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
flushRouterCache();
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
return main(['node', 'codex-runtime', 'chat']);
});
expect(code).toBe(1);
expect(process.env.CODEX_HOME).toBeUndefined();
expect(stderr).toContain('registry YAML could not be parsed');
});
it('fails fast when CCS_CODEX_PROFILE is set and registry is not an object', async () => {
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(registryPath, '- not\n- an\n- object\n');
process.env.CCS_CODEX_PROFILE = 'ghost';
const { result: code, stderr } = await withCapturedStderr(async () => {
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
flushRouterCache();
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
return main(['node', 'codex-runtime', 'chat']);
});
expect(code).toBe(1);
expect(process.env.CODEX_HOME).toBeUndefined();
expect(stderr).toContain('registry YAML root is not an object');
});
it('fails fast for structural resolver errors with the expected name', async () => {
const { result: code, stderr } = await withCapturedStderr(async () => {
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
flushRouterCache();
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
require.cache[resolveProfilePath] = {
exports: {
resolveActiveProfile: () => {
throw { name: 'CodexAuthProfileResolutionError', message: 'boundary failure' };
},
},
} as NodeJS.Module;
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
return main(['node', 'codex-runtime', 'chat']);
});
expect(code).toBe(1);
expect(process.env.CODEX_HOME).toBeUndefined();
expect(stderr).toContain('boundary failure');
});
it('fails closed for unexpected resolver errors instead of falling back to legacy mode', async () => {
const { result: code, stderr } = await withCapturedStderr(async () => {
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
flushRouterCache();
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
require.cache[resolveProfilePath] = {
exports: {
resolveActiveProfile: () => {
throw new Error('permission denied');
},
},
} as NodeJS.Module;
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
return main(['node', 'codex-runtime', 'chat']);
});
expect(code).toBe(1);
expect(process.env.CODEX_HOME).toBeUndefined();
expect(stderr).toContain('profile resolution failed');
expect(stderr).toContain('permission denied');
});
it('preserves an explicit CODEX_HOME already in env — does not overwrite', async () => {
const explicitHome = path.join(tempDir, 'explicit-codex-home');
fs.mkdirSync(explicitHome, { recursive: true });
process.env.CODEX_HOME = explicitHome;
// Even with a populated registry default, explicit wins
makeProfileDir('other');
writeRegistry({
version: '1.0',
default: 'other',
profiles: { other: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null } },
});
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
await main(['node', 'codex-runtime', 'chat']);
expect(process.env.CODEX_HOME).toBe(explicitHome);
});
it('lets CCS_CODEX_PROFILE override a stale explicit CODEX_HOME', async () => {
const explicitHome = path.join(tempDir, 'stale-codex-home');
const profileDir = makeProfileDir('work');
fs.mkdirSync(explicitHome, { recursive: true });
process.env.CODEX_HOME = explicitHome;
process.env.CCS_CODEX_PROFILE = 'work';
writeRegistry({
version: '1.0',
default: null,
profiles: { work: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null } },
});
const { stderr } = await withCapturedStderr(async () => {
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
flushRouterCache();
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
return main(['node', 'codex-runtime', 'chat']);
});
expect(process.env.CODEX_HOME).toBe(profileDir);
expect(stderr).toContain('overrides existing CODEX_HOME');
});
it('sets CODEX_HOME from registry default when no CCS_CODEX_PROFILE is set', async () => {
const profileDir = makeProfileDir('personal');
writeRegistry({
version: '1.0',
default: 'personal',
profiles: {
personal: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null },
},
});
// No CCS_CODEX_PROFILE set
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
flushRouterCache();
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
await main(['node', 'codex-runtime', '--version']);
expect(process.env.CODEX_HOME).toBe(profileDir);
});
});
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
let decodeAccountIdentity: (authJsonPath: string) => {
email?: string;
plan_type?: string;
account_id?: string;
};
function buildToken(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `${header}.${body}.fakesig`;
}
const VALID_TOKEN = buildToken({
email: 'test@example.com',
'https://api.openai.com/auth': {
chatgpt_plan_type: 'pro',
chatgpt_account_id: 'acct-abc123',
},
});
let tempDir: string;
beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-identity-test-'));
const mod = await import('../../../src/codex-auth/codex-account-identity');
decodeAccountIdentity = mod.decodeAccountIdentity;
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('decodeAccountIdentity', () => {
it('returns {} when auth.json does not exist', () => {
const result = decodeAccountIdentity(path.join(tempDir, 'auth.json'));
expect(result).toEqual({});
});
it('returns identity fields from valid auth.json with id_token', () => {
const authJson = {
auth_mode: 'chatgpt_oauth',
tokens: { id_token: VALID_TOKEN, access_token: 'acc', refresh_token: 'ref' },
};
fs.writeFileSync(path.join(tempDir, 'auth.json'), JSON.stringify(authJson), { mode: 0o600 });
const result = decodeAccountIdentity(path.join(tempDir, 'auth.json'));
expect(result.email).toBe('test@example.com');
expect(result.plan_type).toBe('pro');
expect(result.account_id).toBe('acct-abc123');
});
it('returns {} when auth.json contains corrupt JSON', () => {
fs.writeFileSync(path.join(tempDir, 'auth.json'), '{ not valid json !!!', { mode: 0o600 });
const result = decodeAccountIdentity(path.join(tempDir, 'auth.json'));
expect(result).toEqual({});
});
it('returns {} when id_token field is missing from tokens', () => {
const authJson = { auth_mode: 'openai', tokens: { access_token: 'acc' } };
fs.writeFileSync(path.join(tempDir, 'auth.json'), JSON.stringify(authJson), { mode: 0o600 });
const result = decodeAccountIdentity(path.join(tempDir, 'auth.json'));
expect(result).toEqual({});
});
it('returns {} when tokens field is absent entirely', () => {
const authJson = { auth_mode: 'openai', OPENAI_API_KEY: 'sk-...' };
fs.writeFileSync(path.join(tempDir, 'auth.json'), JSON.stringify(authJson), { mode: 0o600 });
const result = decodeAccountIdentity(path.join(tempDir, 'auth.json'));
expect(result).toEqual({});
});
});
@@ -0,0 +1,527 @@
/**
* Unit tests for codex-auth-dashboard-service.
*
* Tests cover: empty registry, decoded fields, corrupt auth.json,
* active resolution precedence (4 paths), cache TTL, cache invalidation,
* and token redaction from response.
*/
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
let tmpDir: string;
// Helpers ------------------------------------------------------------------
function buildToken(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `${header}.${body}.fakesig`;
}
function writeAuthJson(profileDir: string, idTokenPayload: Record<string, unknown>): void {
const authJson = {
tokens: {
id_token: buildToken(idTokenPayload),
access_token: 'access-token-should-not-appear',
refresh_token: 'refresh-token-should-not-appear',
},
};
fs.writeFileSync(path.join(profileDir, 'auth.json'), JSON.stringify(authJson), {
mode: 0o600,
});
}
function writeRawAuthJson(profileDir: string, idToken: string): void {
fs.writeFileSync(
path.join(profileDir, 'auth.json'),
JSON.stringify({
tokens: {
id_token: idToken,
access_token: 'access-token-should-not-appear',
refresh_token: 'refresh-token-should-not-appear',
},
}),
{ mode: 0o600 }
);
}
function writeRegistry(registryPath: string, data: unknown): void {
const dir = path.dirname(registryPath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const yaml = (d: unknown): string => {
// Minimal YAML serialiser for the test fixture
if (typeof d === 'object' && d !== null) {
return Object.entries(d as Record<string, unknown>)
.map(([k, v]) => {
if (typeof v === 'object' && v !== null) {
const nested = Object.entries(v as Record<string, unknown>)
.map(([nk, nv]) => ` ${nk}: ${nv === null ? 'null' : String(nv)}`)
.join('\n');
return `${k}:\n${nested}`;
}
return `${k}: ${v === null ? 'null' : String(v)}`;
})
.join('\n');
}
return '';
};
fs.writeFileSync(registryPath, yaml(data), { mode: 0o600 });
}
function bumpRegistryMtime(registryPath: string): void {
const future = new Date(Date.now() + 10_000);
fs.utimesSync(registryPath, future, future);
}
// Module cache helpers -----------------------------------------------------
async function importService() {
// Bust module cache on each test by importing fresh via dynamic import
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await import(
'../../../src/codex-auth/codex-auth-dashboard-service'
);
return { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache };
}
// Setup / teardown ---------------------------------------------------------
// getCcsDir() returns path.join(CCS_HOME, '.ccs') when CCS_HOME is set.
// Tests must write to tmpDir/.ccs/ to be visible to the service.
let ccsDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-'));
process.env.CCS_HOME = tmpDir;
ccsDir = path.join(tmpDir, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
// Clear module cache so cache state doesn't bleed between tests
// Bun doesn't have require.cache; we rely on invalidateCodexAuthProfilesCache
});
afterEach(() => {
delete process.env.CCS_HOME;
delete process.env.CODEX_HOME;
delete process.env.CCS_CODEX_PROFILE;
mock.restore();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
// Tests --------------------------------------------------------------------
describe('getCodexAuthProfilesSummary', () => {
it('returns empty profiles and active=null when registry does not exist', async () => {
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
invalidateCodexAuthProfilesCache();
const result = await getCodexAuthProfilesSummary();
expect(result.profiles).toEqual([]);
expect(result.active).toBeNull();
expect(result.default).toBeNull();
});
it('throws instead of returning an empty list when registry YAML is malformed', async () => {
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
invalidateCodexAuthProfilesCache();
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
fs.writeFileSync(registryPath, '{ invalid: yaml: [', { mode: 0o600 });
await expect(getCodexAuthProfilesSummary()).rejects.toThrow(/could not be read safely/i);
});
it('does not expose raw stat errors when the registry cannot be checked', async () => {
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
invalidateCodexAuthProfilesCache();
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
const rawMessage = `EACCES: permission denied, stat '${registryPath}'`;
const realStatSync = fs.statSync;
spyOn(fs, 'statSync').mockImplementation((target) => {
if (target === registryPath) {
const err = new Error(rawMessage) as NodeJS.ErrnoException;
err.code = 'EACCES';
throw err;
}
return realStatSync(target);
});
let message = '';
try {
await getCodexAuthProfilesSummary();
} catch (err) {
message = String(err);
}
expect(message).toContain('could not be checked safely');
expect(message).not.toContain(registryPath);
expect(message).not.toContain('EACCES');
});
it('returns decoded email, plan, accountId for valid registry with 2 profiles', async () => {
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
invalidateCodexAuthProfilesCache();
const instancesDir = path.join(ccsDir, 'codex-instances');
const workDir = path.join(instancesDir, 'work');
const personalDir = path.join(instancesDir, 'personal');
fs.mkdirSync(workDir, { recursive: true });
fs.mkdirSync(personalDir, { recursive: true });
writeAuthJson(workDir, {
email: 'work@example.com',
'https://api.openai.com/auth': {
chatgpt_plan_type: 'pro',
chatgpt_account_id: 'acct-work-123',
},
});
writeAuthJson(personalDir, {
email: 'personal@example.com',
'https://api.openai.com/auth': {
chatgpt_plan_type: 'free',
chatgpt_account_id: 'acct-personal-456',
},
});
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
const yamlContent = `version: "1.0"\ndefault: work\nprofiles:\n work:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: "2026-05-17T05:45:00Z"\n personal:\n type: codex\n created: "2026-01-02T00:00:00Z"\n last_used: null\n`;
fs.writeFileSync(registryPath, yamlContent, { mode: 0o600 });
const result = await getCodexAuthProfilesSummary();
expect(result.profiles).toHaveLength(2);
const work = result.profiles.find((p) => p.name === 'work');
expect(work).toBeDefined();
expect(work?.email).toBe('work@example.com');
expect(work?.plan).toBe('pro');
expect(work?.accountId).toBe('acct-work-123');
expect(work?.authValid).toBe(true);
expect(work?.lastUsed).toBe('2026-05-17T05:45:00Z');
const personal = result.profiles.find((p) => p.name === 'personal');
expect(personal).toBeDefined();
expect(personal?.email).toBe('personal@example.com');
expect(personal?.plan).toBe('free');
expect(personal?.accountId).toBe('acct-personal-456');
expect(personal?.authValid).toBe(true);
expect(personal?.lastUsed).toBeNull();
});
it('sets authValid=false and nulls identity fields when auth.json is corrupt JSON', async () => {
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
invalidateCodexAuthProfilesCache();
const instancesDir = path.join(ccsDir, 'codex-instances');
const brokenDir = path.join(instancesDir, 'broken');
fs.mkdirSync(brokenDir, { recursive: true });
fs.writeFileSync(path.join(brokenDir, 'auth.json'), '{ not valid json', {
mode: 0o600,
});
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
fs.writeFileSync(
registryPath,
`version: "1.0"\ndefault: broken\nprofiles:\n broken:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`,
{ mode: 0o600 }
);
const result = await getCodexAuthProfilesSummary();
expect(result.profiles).toHaveLength(1);
const broken = result.profiles[0];
expect(broken?.authValid).toBe(false);
expect(broken?.email).toBeNull();
expect(broken?.plan).toBeNull();
expect(broken?.accountId).toBeNull();
});
it('sets authValid=false when id_token is non-empty but malformed', async () => {
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
invalidateCodexAuthProfilesCache();
const instancesDir = path.join(ccsDir, 'codex-instances');
const brokenDir = path.join(instancesDir, 'broken-jwt');
fs.mkdirSync(brokenDir, { recursive: true });
writeRawAuthJson(brokenDir, 'not-a-jwt');
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
fs.writeFileSync(
registryPath,
`version: "1.0"\ndefault: broken-jwt\nprofiles:\n broken-jwt:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`,
{ mode: 0o600 }
);
const result = await getCodexAuthProfilesSummary();
const broken = result.profiles[0];
expect(broken?.authValid).toBe(false);
expect(broken?.email).toBeNull();
expect(broken?.plan).toBeNull();
expect(broken?.accountId).toBeNull();
});
it('sets authValid=false when id_token contains invalid base64url characters', async () => {
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
invalidateCodexAuthProfilesCache();
const instancesDir = path.join(ccsDir, 'codex-instances');
const brokenDir = path.join(instancesDir, 'broken-base64url');
fs.mkdirSync(brokenDir, { recursive: true });
writeRawAuthJson(brokenDir, 'h.e30$.s');
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
fs.writeFileSync(
registryPath,
`version: "1.0"\ndefault: broken-base64url\nprofiles:\n broken-base64url:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`,
{ mode: 0o600 }
);
const result = await getCodexAuthProfilesSummary();
const broken = result.profiles[0];
expect(broken?.authValid).toBe(false);
});
it('sets authValid=true for a valid but sparse JWT payload', async () => {
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
invalidateCodexAuthProfilesCache();
const instancesDir = path.join(ccsDir, 'codex-instances');
const sparseDir = path.join(instancesDir, 'sparse');
fs.mkdirSync(sparseDir, { recursive: true });
writeRawAuthJson(sparseDir, buildToken({}));
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
fs.writeFileSync(
registryPath,
`version: "1.0"\ndefault: sparse\nprofiles:\n sparse:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`,
{ mode: 0o600 }
);
const result = await getCodexAuthProfilesSummary();
const sparse = result.profiles[0];
expect(sparse?.authValid).toBe(true);
expect(sparse?.email).toBeNull();
expect(sparse?.plan).toBeNull();
expect(sparse?.accountId).toBeNull();
});
it('sets authValid=false and nulls identity fields when auth.json is missing', async () => {
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
invalidateCodexAuthProfilesCache();
const instancesDir = path.join(ccsDir, 'codex-instances');
const noAuthDir = path.join(instancesDir, 'noauth');
fs.mkdirSync(noAuthDir, { recursive: true });
// No auth.json written
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
fs.writeFileSync(
registryPath,
`version: "1.0"\ndefault: noauth\nprofiles:\n noauth:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`,
{ mode: 0o600 }
);
const result = await getCodexAuthProfilesSummary();
const profile = result.profiles[0];
expect(profile?.authValid).toBe(false);
expect(profile?.email).toBeNull();
expect(profile?.plan).toBeNull();
expect(profile?.accountId).toBeNull();
});
it('active resolution: CODEX_HOME set externally -> source=explicit-codex-home', async () => {
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
invalidateCodexAuthProfilesCache();
const externalHome = path.join(tmpDir, 'external-codex');
process.env.CODEX_HOME = externalHome;
const result = await getCodexAuthProfilesSummary();
expect(result.active).not.toBeNull();
expect(result.active?.source).toBe('explicit-codex-home');
expect(result.active?.codexHome).toBe(externalHome);
});
it('active resolution: CCS_CODEX_PROFILE set -> source=env, name=that profile', async () => {
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
invalidateCodexAuthProfilesCache();
process.env.CCS_CODEX_PROFILE = 'work';
const instancesDir = path.join(ccsDir, 'codex-instances');
const workDir = path.join(instancesDir, 'work');
fs.mkdirSync(workDir, { recursive: true });
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
fs.writeFileSync(
registryPath,
`version: "1.0"\ndefault: null\nprofiles:\n work:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`,
{ mode: 0o600 }
);
const result = await getCodexAuthProfilesSummary();
expect(result.active?.source).toBe('env');
expect(result.active?.name).toBe('work');
});
it('active resolution: ignores stale CCS_CODEX_PROFILE values missing from registry', async () => {
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
invalidateCodexAuthProfilesCache();
process.env.CCS_CODEX_PROFILE = 'ghost';
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
fs.writeFileSync(
registryPath,
`version: "1.0"\ndefault: null\nprofiles:\n work:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`,
{ mode: 0o600 }
);
const result = await getCodexAuthProfilesSummary();
expect(result.active).toBeNull();
});
it('active resolution: registry default set -> source=default', async () => {
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
invalidateCodexAuthProfilesCache();
const instancesDir = path.join(ccsDir, 'codex-instances');
const workDir = path.join(instancesDir, 'work');
fs.mkdirSync(workDir, { recursive: true });
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
fs.writeFileSync(
registryPath,
`version: "1.0"\ndefault: work\nprofiles:\n work:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`,
{ mode: 0o600 }
);
const result = await getCodexAuthProfilesSummary();
expect(result.active?.source).toBe('default');
expect(result.active?.name).toBe('work');
});
it('active resolution: no env, no default -> active=null', async () => {
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
invalidateCodexAuthProfilesCache();
// No registry file, no env vars
const result = await getCodexAuthProfilesSummary();
expect(result.active).toBeNull();
});
it('returns cached value on second call within 5s when the registry file is unchanged', async () => {
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
invalidateCodexAuthProfilesCache();
const instancesDir = path.join(ccsDir, 'codex-instances');
const workDir = path.join(instancesDir, 'work');
fs.mkdirSync(workDir, { recursive: true });
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
fs.writeFileSync(
registryPath,
`version: "1.0"\ndefault: work\nprofiles:\n work:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`,
{ mode: 0o600 }
);
const first = await getCodexAuthProfilesSummary();
const second = await getCodexAuthProfilesSummary();
// Both calls should return same reference (cache hit)
expect(second).toBe(first);
});
it('does not serve cached missing-registry success after a malformed registry appears', async () => {
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
invalidateCodexAuthProfilesCache();
const first = await getCodexAuthProfilesSummary();
expect(first.profiles).toHaveLength(0);
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
fs.writeFileSync(registryPath, '{ invalid: yaml: [', { mode: 0o600 });
bumpRegistryMtime(registryPath);
await expect(getCodexAuthProfilesSummary()).rejects.toThrow(/could not be read safely/i);
});
it('does not serve cached valid-registry success after the registry becomes malformed', async () => {
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
invalidateCodexAuthProfilesCache();
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
fs.writeFileSync(registryPath, `version: "1.0"\ndefault: null\nprofiles: {}\n`, {
mode: 0o600,
});
const first = await getCodexAuthProfilesSummary();
expect(first.profiles).toHaveLength(0);
fs.writeFileSync(registryPath, '{ invalid: yaml: [', { mode: 0o600 });
bumpRegistryMtime(registryPath);
await expect(getCodexAuthProfilesSummary()).rejects.toThrow(/could not be read safely/i);
});
it('invalidateCodexAuthProfilesCache forces re-read on next call', async () => {
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
invalidateCodexAuthProfilesCache();
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
fs.writeFileSync(registryPath, `version: "1.0"\ndefault: null\nprofiles: {}\n`, {
mode: 0o600,
});
const first = await getCodexAuthProfilesSummary();
expect(first.profiles).toHaveLength(0);
// Now add a profile
const instancesDir = path.join(ccsDir, 'codex-instances');
const newDir = path.join(instancesDir, 'newprofile');
fs.mkdirSync(newDir, { recursive: true });
fs.writeFileSync(
registryPath,
`version: "1.0"\ndefault: newprofile\nprofiles:\n newprofile:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`,
{ mode: 0o600 }
);
invalidateCodexAuthProfilesCache();
const second = await getCodexAuthProfilesSummary();
expect(second.profiles).toHaveLength(1);
expect(second.profiles[0]?.name).toBe('newprofile');
});
it('response JSON contains no token substrings', async () => {
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
invalidateCodexAuthProfilesCache();
const instancesDir = path.join(ccsDir, 'codex-instances');
const workDir = path.join(instancesDir, 'work');
fs.mkdirSync(workDir, { recursive: true });
writeAuthJson(workDir, {
email: 'work@example.com',
'https://api.openai.com/auth': {
chatgpt_plan_type: 'pro',
chatgpt_account_id: 'acct-work',
},
});
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
fs.writeFileSync(
registryPath,
`version: "1.0"\ndefault: work\nprofiles:\n work:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`,
{ mode: 0o600 }
);
const result = await getCodexAuthProfilesSummary();
const serialized = JSON.stringify(result);
// Security: no raw token material in response
expect(serialized).not.toContain('access_token');
expect(serialized).not.toContain('refresh_token');
expect(serialized).not.toContain('id_token');
// The known sentinel values from writeAuthJson
expect(serialized).not.toContain('access-token-should-not-appear');
expect(serialized).not.toContain('refresh-token-should-not-appear');
});
});
@@ -0,0 +1,140 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
let tempDir: string;
let ccsHome: string;
const ORIG_CCS_HOME = process.env.CCS_HOME;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-router-test-'));
ccsHome = path.join(tempDir, 'ccs');
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true });
process.env.CCS_HOME = ccsHome;
});
afterEach(() => {
if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME;
else process.env.CCS_HOME = ORIG_CCS_HOME;
fs.rmSync(tempDir, { recursive: true, force: true });
});
async function loadRouter() {
// Re-import fresh each test via dynamic import cache busting with timestamp
const { runCodexAuth } = await import('../../../src/codex-auth/codex-auth-router');
return runCodexAuth;
}
describe('runCodexAuth — help and no-arg', () => {
it('no args → prints help and returns 0', async () => {
const runCodexAuth = await loadRouter();
const out: string[] = [];
const origWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = (chunk: string | Uint8Array) => {
out.push(String(chunk));
return true;
};
try {
const code = await runCodexAuth([]);
expect(code).toBe(0);
expect(out.join('')).toContain('ccsx auth');
} finally {
process.stdout.write = origWrite;
}
});
it('--help → returns 0 and prints help', async () => {
const runCodexAuth = await loadRouter();
const out: string[] = [];
const origWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = (chunk: string | Uint8Array) => {
out.push(String(chunk));
return true;
};
try {
const code = await runCodexAuth(['--help']);
expect(code).toBe(0);
expect(out.join('')).toContain('Commands');
} finally {
process.stdout.write = origWrite;
}
});
it('-h → returns 0', async () => {
const runCodexAuth = await loadRouter();
const out: string[] = [];
const origWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = (chunk: string | Uint8Array) => {
out.push(String(chunk));
return true;
};
try {
const code = await runCodexAuth(['-h']);
expect(code).toBe(0);
} finally {
process.stdout.write = origWrite;
}
});
});
describe('runCodexAuth — unknown subcommand', () => {
it('unknown subcommand → returns 1 and writes to stderr', async () => {
const runCodexAuth = await loadRouter();
const errOut: string[] = [];
const origWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk: string | Uint8Array) => {
errOut.push(String(chunk));
return true;
};
try {
const code = await runCodexAuth(['bogus']);
expect(code).toBe(1);
expect(errOut.join('')).toContain('Unknown command');
expect(errOut.join('')).toContain('bogus');
} finally {
process.stderr.write = origWrite;
}
});
});
describe('runCodexAuth — version', () => {
it('--version → returns 0 and prints version', async () => {
const runCodexAuth = await loadRouter();
const out: string[] = [];
const origWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = (chunk: string | Uint8Array) => {
out.push(String(chunk));
return true;
};
try {
const code = await runCodexAuth(['--version']);
expect(code).toBe(0);
expect(out.join('')).toMatch(/\d+\.\d+/);
} finally {
process.stdout.write = origWrite;
}
});
});
describe('runCodexAuth — dispatches show without crashing', () => {
it('show with no profiles → exit 0', async () => {
const runCodexAuth = await loadRouter();
const out: string[] = [];
const origLog = console.log;
const origWrite = process.stdout.write.bind(process.stdout);
console.log = (...a: unknown[]) => out.push(a.map(String).join(' '));
process.stdout.write = (chunk: string | Uint8Array) => {
out.push(String(chunk));
return true;
};
try {
const code = await runCodexAuth(['show']);
expect(code).toBe(0);
expect(out.join('')).toContain('No Codex profiles');
} finally {
console.log = origLog;
process.stdout.write = origWrite;
}
});
});
@@ -0,0 +1,167 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
let ensureSharedConfigSymlink: (profileDir: string, sharedConfigPath?: string) => void;
let tempDir: string;
let profileDir: string;
let sharedConfigPath: string;
beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-symlink-test-'));
profileDir = path.join(tempDir, 'profile');
// Use a temp path for the shared config so tests never touch real ~/.codex/config.toml
sharedConfigPath = path.join(tempDir, 'shared-config.toml');
const mod = await import('../../../src/codex-auth/codex-config-symlink');
ensureSharedConfigSymlink = mod.ensureSharedConfigSymlink;
});
afterEach(() => {
mock.restore();
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('ensureSharedConfigSymlink', () => {
it('creates empty shared config and symlink when neither exists', () => {
// Neither profileDir nor sharedConfigPath exist yet
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
expect(fs.existsSync(sharedConfigPath)).toBe(true);
expect(fs.readFileSync(sharedConfigPath, 'utf8')).toBe('');
const linkPath = path.join(profileDir, 'config.toml');
const stat = fs.lstatSync(linkPath);
expect(stat.isSymbolicLink()).toBe(true);
expect(fs.readlinkSync(linkPath)).toBe(sharedConfigPath);
});
it('is idempotent when symlink already points to correct target', () => {
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
// Call again — should not throw
expect(() => ensureSharedConfigSymlink(profileDir, sharedConfigPath)).not.toThrow();
const linkPath = path.join(profileDir, 'config.toml');
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
expect(fs.readlinkSync(linkPath)).toBe(sharedConfigPath);
});
it('preserves existing shared config content (does not overwrite)', () => {
fs.writeFileSync(sharedConfigPath, '[model]\nname = "o4"', { mode: 0o600 });
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
expect(fs.readFileSync(sharedConfigPath, 'utf8')).toBe('[model]\nname = "o4"');
});
it('replaces a stale symlink pointing to a wrong target with correct one', () => {
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
const wrongTarget = path.join(tempDir, 'wrong.toml');
fs.writeFileSync(wrongTarget, '', { mode: 0o600 });
const linkPath = path.join(profileDir, 'config.toml');
fs.symlinkSync(wrongTarget, linkPath);
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
expect(fs.readlinkSync(linkPath)).toBe(sharedConfigPath);
});
it('preserves an edited regular file at link path by default', () => {
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
const linkPath = path.join(profileDir, 'config.toml');
fs.writeFileSync(sharedConfigPath, '[shared]\ndata = true', { mode: 0o600 });
fs.writeFileSync(linkPath, '[existing]\ndata = true', { mode: 0o600 });
// Capture stderr to verify warning was written
const stderrChunks: string[] = [];
const origWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk: string | Uint8Array): boolean => {
stderrChunks.push(typeof chunk === 'string' ? chunk : chunk.toString());
return origWrite(chunk);
};
try {
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
} finally {
process.stderr.write = origWrite;
}
expect(fs.lstatSync(linkPath).isFile()).toBe(true);
expect(fs.readFileSync(linkPath, 'utf8')).toBe('[existing]\ndata = true');
// A warning should have been emitted
expect(stderrChunks.join('')).toMatch(/preserving existing regular config/i);
});
it('replaces a regular file when explicit overwrite repair is requested', () => {
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
const linkPath = path.join(profileDir, 'config.toml');
fs.writeFileSync(linkPath, '[existing]\ndata = true', { mode: 0o600 });
ensureSharedConfigSymlink(profileDir, sharedConfigPath, { overwriteRegularFile: true });
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
expect(fs.readlinkSync(linkPath)).toBe(sharedConfigPath);
});
it('replaces a broken symlink (dangling) with correct symlink', () => {
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
const linkPath = path.join(profileDir, 'config.toml');
// Create symlink to non-existent target
fs.symlinkSync(path.join(tempDir, 'does-not-exist.toml'), linkPath);
// Verify it's broken
expect(fs.existsSync(linkPath)).toBe(false);
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
expect(fs.readlinkSync(linkPath)).toBe(sharedConfigPath);
});
it('copies shared config when symlink creation fails', () => {
fs.writeFileSync(sharedConfigPath, 'model = "gpt-5.5"\n', { mode: 0o600 });
const linkPath = path.join(profileDir, 'config.toml');
const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation(() => {
throw Object.assign(new Error('simulated symlink failure'), { code: 'EPERM' });
});
const stderrChunks: string[] = [];
const origWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk: string | Uint8Array): boolean => {
stderrChunks.push(typeof chunk === 'string' ? chunk : chunk.toString());
return true;
};
try {
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
} finally {
process.stderr.write = origWrite;
symlinkSpy.mockRestore();
}
expect(fs.lstatSync(linkPath).isFile()).toBe(true);
expect(fs.readFileSync(linkPath, 'utf8')).toBe('model = "gpt-5.5"\n');
expect(stderrChunks.join('')).toContain('symlink unavailable');
});
it('preserves edited fallback copies on later repair attempts', () => {
fs.writeFileSync(sharedConfigPath, 'model = "gpt-5.5"\n', { mode: 0o600 });
const linkPath = path.join(profileDir, 'config.toml');
const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation(() => {
throw Object.assign(new Error('simulated symlink failure'), { code: 'EPERM' });
});
const origWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = () => true;
try {
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
fs.writeFileSync(linkPath, 'model = "local-edit"\n', { mode: 0o600 });
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
} finally {
process.stderr.write = origWrite;
symlinkSpy.mockRestore();
}
expect(fs.lstatSync(linkPath).isFile()).toBe(true);
expect(fs.readFileSync(linkPath, 'utf8')).toBe('model = "local-edit"\n');
});
});
@@ -0,0 +1,64 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as os from 'os';
import * as path from 'path';
let getCodexAuthRegistryPath: () => string;
let getCodexInstancesDir: () => string;
let resolveCodexProfileDir: (name: string) => string;
let getSharedCodexConfigPath: () => string;
const ORIGINAL_CCS_HOME = process.env.CCS_HOME;
beforeEach(async () => {
process.env.CCS_HOME = '/tmp/test-ccs-home';
// Re-import to pick up env change — use dynamic import with cache busting
const mod = await import('../../../src/codex-auth/codex-profile-paths');
getCodexAuthRegistryPath = mod.getCodexAuthRegistryPath;
getCodexInstancesDir = mod.getCodexInstancesDir;
resolveCodexProfileDir = mod.resolveCodexProfileDir;
getSharedCodexConfigPath = mod.getSharedCodexConfigPath;
});
afterEach(() => {
if (ORIGINAL_CCS_HOME === undefined) {
delete process.env.CCS_HOME;
} else {
process.env.CCS_HOME = ORIGINAL_CCS_HOME;
}
});
describe('codex-profile-paths', () => {
it('getCodexAuthRegistryPath returns codex-profiles.yaml inside getCcsDir()', () => {
const result = getCodexAuthRegistryPath();
expect(result).toContain('codex-profiles.yaml');
expect(result).toContain('.ccs');
});
it('getCodexInstancesDir returns codex-instances inside getCcsDir()', () => {
const result = getCodexInstancesDir();
expect(result).toContain('codex-instances');
expect(result).toContain('.ccs');
});
it('resolveCodexProfileDir returns instancesDir/<name>', () => {
const instancesDir = getCodexInstancesDir();
const profileDir = resolveCodexProfileDir('work');
expect(profileDir).toBe(path.join(instancesDir, 'work'));
});
it('resolveCodexProfileDir correctly nests a different profile name', () => {
const instancesDir = getCodexInstancesDir();
const profileDir = resolveCodexProfileDir('personal');
expect(profileDir).toBe(path.join(instancesDir, 'personal'));
});
it('getSharedCodexConfigPath resolves under os.homedir() not getCcsDir()', () => {
const result = getSharedCodexConfigPath();
// Must equal os.homedir()/.codex/config.toml — uses real homedir, not getCcsDir()
expect(result).toBe(path.join(os.homedir(), '.codex', 'config.toml'));
// Must end with the Codex-canonical path fragment
expect(result).toMatch(/\.codex[/\\]config\.toml$/);
// Must NOT end inside the .ccs directory (i.e. not a CCS-owned path)
expect(result).not.toContain(path.join('.ccs', 'codex'));
});
});
@@ -0,0 +1,378 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as yaml from 'js-yaml';
import * as lockfile from 'proper-lockfile';
import { spawn } from 'child_process';
let CodexProfileRegistry: new (registryPath?: string) => {
createProfile(name: string, meta?: Record<string, unknown>): void;
getProfile(name: string): Record<string, unknown>;
updateProfile(name: string, partial: Record<string, unknown>): void;
removeProfile(name: string, options?: { forceDefault?: boolean }): void;
listProfiles(): string[];
hasProfile(name: string): boolean;
getDefault(): string | null;
setDefault(name: string): void;
clearDefault(): void;
touchProfile(name: string): void;
};
let tempDir: string;
let ccsHome: string;
let registryPath: string;
const ORIGINAL_CCS_HOME = process.env.CCS_HOME;
beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-registry-test-'));
ccsHome = path.join(tempDir, 'ccs-home');
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true, mode: 0o700 });
process.env.CCS_HOME = ccsHome;
registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml');
const mod = await import('../../../src/codex-auth/codex-profile-registry');
CodexProfileRegistry = mod.CodexProfileRegistry;
});
afterEach(() => {
if (ORIGINAL_CCS_HOME === undefined) {
delete process.env.CCS_HOME;
} else {
process.env.CCS_HOME = ORIGINAL_CCS_HOME;
}
fs.rmSync(tempDir, { recursive: true, force: true });
mock.restore();
});
describe('CodexProfileRegistry — empty state', () => {
it('returns empty list when registry file does not exist', () => {
const reg = new CodexProfileRegistry(registryPath);
expect(reg.listProfiles()).toEqual([]);
});
it('returns null default when registry file does not exist', () => {
const reg = new CodexProfileRegistry(registryPath);
expect(reg.getDefault()).toBeNull();
});
});
describe('CodexProfileRegistry — create and get', () => {
it('creates a profile and retrieves it by name', () => {
const reg = new CodexProfileRegistry(registryPath);
reg.createProfile('work');
const profile = reg.getProfile('work');
expect(profile.type).toBe('codex');
expect(typeof profile.created).toBe('string');
expect(profile.last_used).toBeNull();
});
it('persists profile to disk as YAML with schema version', () => {
const reg = new CodexProfileRegistry(registryPath);
reg.createProfile('work');
const raw = fs.readFileSync(registryPath, 'utf8');
const parsed = yaml.load(raw) as Record<string, unknown>;
expect(parsed.version).toBe('1.0');
expect(typeof parsed.profiles).toBe('object');
});
it('throws when creating a duplicate profile name', () => {
const reg = new CodexProfileRegistry(registryPath);
reg.createProfile('work');
expect(() => reg.createProfile('work')).toThrow(/already exists/i);
});
it('accepts optional metadata on create', () => {
const reg = new CodexProfileRegistry(registryPath);
reg.createProfile('personal', { email: 'me@example.com', plan_type: 'pro' });
const profile = reg.getProfile('personal');
expect(profile.email).toBe('me@example.com');
expect(profile.plan_type).toBe('pro');
});
it('hasProfile returns false before creation and true after', () => {
const reg = new CodexProfileRegistry(registryPath);
expect(reg.hasProfile('work')).toBe(false);
reg.createProfile('work');
expect(reg.hasProfile('work')).toBe(true);
});
it('rejects unsafe profile names before writing the registry', () => {
const reg = new CodexProfileRegistry(registryPath);
expect(() => reg.createProfile('../escape')).toThrow(/path separators/i);
expect(reg.hasProfile('../escape')).toBe(false);
expect(fs.existsSync(registryPath)).toBe(false);
});
});
describe('CodexProfileRegistry — remove', () => {
it('removes an existing profile', () => {
const reg = new CodexProfileRegistry(registryPath);
reg.createProfile('work');
reg.removeProfile('work');
expect(reg.listProfiles()).toEqual([]);
});
it('throws when removing a non-existent profile', () => {
const reg = new CodexProfileRegistry(registryPath);
expect(() => reg.removeProfile('ghost')).toThrow(/not found/i);
});
it('clears default when the default profile is removed', () => {
const reg = new CodexProfileRegistry(registryPath);
reg.createProfile('work');
reg.setDefault('work');
expect(reg.getDefault()).toBe('work');
reg.removeProfile('work');
expect(reg.getDefault()).toBeNull();
});
it('refuses to remove the default profile when other profiles remain without force', () => {
const reg = new CodexProfileRegistry(registryPath);
reg.createProfile('work');
reg.createProfile('personal');
reg.setDefault('work');
expect(() => reg.removeProfile('work')).toThrow(/default profile.*without --force/i);
expect(reg.listProfiles()).toEqual(['work', 'personal']);
expect(reg.getDefault()).toBe('work');
});
it('does not promote another profile when the default profile is force removed', () => {
const reg = new CodexProfileRegistry(registryPath);
reg.createProfile('work');
reg.createProfile('personal');
reg.setDefault('work');
reg.removeProfile('work', { forceDefault: true });
expect(reg.listProfiles()).toEqual(['personal']);
expect(reg.getDefault()).toBeNull();
});
});
describe('CodexProfileRegistry — default pointer', () => {
it('setDefault throws when profile does not exist', () => {
const reg = new CodexProfileRegistry(registryPath);
expect(() => reg.setDefault('ghost')).toThrow(/not found/i);
});
it('setDefault and getDefault round-trip', () => {
const reg = new CodexProfileRegistry(registryPath);
reg.createProfile('work');
reg.setDefault('work');
expect(reg.getDefault()).toBe('work');
});
it('clearDefault resets default to null', () => {
const reg = new CodexProfileRegistry(registryPath);
reg.createProfile('work');
reg.setDefault('work');
reg.clearDefault();
expect(reg.getDefault()).toBeNull();
});
});
describe('CodexProfileRegistry — listProfiles', () => {
it('returns all profile names', () => {
const reg = new CodexProfileRegistry(registryPath);
reg.createProfile('work');
reg.createProfile('personal');
const list = reg.listProfiles();
expect(list).toContain('work');
expect(list).toContain('personal');
expect(list.length).toBe(2);
});
});
describe('CodexProfileRegistry — corrupt YAML safety', () => {
it('throws on corrupt YAML without rewriting the registry', () => {
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
const corrupt = '{ invalid: yaml: content: [';
fs.writeFileSync(registryPath, corrupt, { mode: 0o600 });
const reg = new CodexProfileRegistry(registryPath);
expect(() => reg.listProfiles()).toThrow(/could not be read safely/i);
expect(fs.readFileSync(registryPath, 'utf8')).toBe(corrupt);
});
it('refuses mutating writes when the registry shape is invalid', () => {
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
const invalidShape = 'version: "1.0"\ndefault: null\nprofiles: []\n';
fs.writeFileSync(registryPath, invalidShape, { mode: 0o600 });
const reg = new CodexProfileRegistry(registryPath);
expect(() => reg.createProfile('work')).toThrow(/profiles map/i);
expect(fs.readFileSync(registryPath, 'utf8')).toBe(invalidShape);
});
it('refuses registry entries with unsafe profile names', () => {
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
const unsafeRegistry =
'version: "1.0"\ndefault: null\nprofiles:\n ../escape:\n type: codex\n created: "2026-01-01T00:00:00.000Z"\n last_used: null\n';
fs.writeFileSync(registryPath, unsafeRegistry, { mode: 0o600 });
const reg = new CodexProfileRegistry(registryPath);
expect(() => reg.listProfiles()).toThrow(/invalid profile name/i);
expect(fs.readFileSync(registryPath, 'utf8')).toBe(unsafeRegistry);
});
it('refuses malformed profile entries instead of activating corrupt state', () => {
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
const malformedRegistry = 'version: "1.0"\ndefault: work\nprofiles:\n work: 1\n';
fs.writeFileSync(registryPath, malformedRegistry, { mode: 0o600 });
const reg = new CodexProfileRegistry(registryPath);
expect(() => reg.getDefault()).toThrow(/must be an object/i);
expect(fs.readFileSync(registryPath, 'utf8')).toBe(malformedRegistry);
});
it('redacts absolute registry paths and raw YAML parser details in read errors', () => {
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
const corrupt = '{ invalid yaml: [[[ sensitive-local-fragment';
fs.writeFileSync(registryPath, corrupt, { mode: 0o600 });
const reg = new CodexProfileRegistry(registryPath);
let message = '';
try {
reg.listProfiles();
} catch (err) {
message = String(err);
}
expect(message).toContain('$CCS_HOME/.ccs/codex-profiles.yaml');
expect(message).not.toContain(registryPath);
expect(message).not.toContain('sensitive-local-fragment');
});
});
describe('CodexProfileRegistry — atomic write', () => {
it('leaves no .tmp file after successful write', () => {
const reg = new CodexProfileRegistry(registryPath);
reg.createProfile('work');
const dir = path.dirname(registryPath);
const tmpFiles = fs.readdirSync(dir).filter((f) => f.includes('.tmp.'));
expect(tmpFiles.length).toBe(0);
});
});
describe('CodexProfileRegistry — touchProfile', () => {
it('updates last_used timestamp', async () => {
const reg = new CodexProfileRegistry(registryPath);
reg.createProfile('work');
const before = new Date().toISOString();
await new Promise((r) => setTimeout(r, 5));
reg.touchProfile('work');
const profile = reg.getProfile('work');
expect(typeof profile.last_used).toBe('string');
expect((profile.last_used as string) >= before).toBe(true);
});
});
describe('CodexProfileRegistry — updateProfile', () => {
it('merges partial updates into existing profile', () => {
const reg = new CodexProfileRegistry(registryPath);
reg.createProfile('work');
reg.updateProfile('work', { email: 'updated@example.com', plan_type: 'plus' });
const profile = reg.getProfile('work');
expect(profile.email).toBe('updated@example.com');
expect(profile.plan_type).toBe('plus');
expect(profile.type).toBe('codex');
});
it('throws when updating a non-existent profile', () => {
const reg = new CodexProfileRegistry(registryPath);
expect(() => reg.updateProfile('ghost', { email: 'x@x.com' })).toThrow(/not found/i);
});
});
describe('CodexProfileRegistry — registry file permissions', () => {
it('writes registry file with mode 0o600', () => {
const reg = new CodexProfileRegistry(registryPath);
reg.createProfile('work');
const stat = fs.statSync(registryPath);
// On POSIX, check owner read/write only (0o600 = 0b110_000_000 = 384)
expect(stat.mode & 0o777).toBe(0o600);
});
});
describe('CodexProfileRegistry — write lock', () => {
it('serializes read-modify-write mutations through a registry lock', () => {
const release = () => {};
const lockSpy = spyOn(lockfile, 'lockSync').mockReturnValue(release);
const reg = new CodexProfileRegistry(registryPath);
reg.createProfile('work');
expect(lockSpy).toHaveBeenCalled();
const [lockTarget, options] = lockSpy.mock.calls[0] ?? [];
expect(lockTarget).toBe(path.dirname(registryPath));
expect(options).toMatchObject({ stale: 10000 });
});
it('waits for a contended registry lock before writing', async () => {
const registryDir = path.dirname(registryPath);
const readyPath = path.join(tempDir, 'holder-ready');
const holderScript = path.join(tempDir, 'hold-registry-lock.cjs');
fs.writeFileSync(
holderScript,
`
const fs = require('fs');
const lockfile = require(process.argv[4]);
const release = lockfile.lockSync(process.argv[2], { stale: 10000 });
fs.writeFileSync(process.argv[3], String(process.pid));
setTimeout(() => {
release();
process.exit(0);
}, 150);
setTimeout(() => process.exit(2), 5000);
process.on('SIGTERM', () => {
try { release(); } finally { process.exit(0); }
});
`,
'utf8'
);
const child = spawn(
process.execPath,
[
holderScript,
registryDir,
readyPath,
path.join(process.cwd(), 'node_modules', 'proper-lockfile'),
],
{
cwd: process.cwd(),
stdio: ['ignore', 'ignore', 'pipe'],
}
);
try {
await waitForFile(readyPath);
const reg = new CodexProfileRegistry(registryPath);
reg.createProfile('work');
expect(reg.hasProfile('work')).toBe(true);
} finally {
if (!child.killed) child.kill();
await waitForChildExit(child);
}
});
});
async function waitForFile(filePath: string, timeoutMs = 1000): Promise<void> {
const started = Date.now();
while (!fs.existsSync(filePath)) {
if (Date.now() - started > timeoutMs) {
throw new Error(`Timed out waiting for ${filePath}`);
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
async function waitForChildExit(child: ReturnType<typeof spawn>): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
await new Promise<void>((resolve) => child.once('exit', () => resolve()));
}
@@ -0,0 +1,420 @@
/**
* Tests for codex-auth create command.
* Mocks detectCodexCli and child_process.spawn to avoid real codex binary.
*/
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as childProcess from 'child_process';
let tempDir: string;
let ccsHome: string;
const ORIG_CCS_HOME = process.env.CCS_HOME;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-create-test-'));
ccsHome = path.join(tempDir, 'ccs');
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true });
process.env.CCS_HOME = ccsHome;
});
afterEach(() => {
if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME;
else process.env.CCS_HOME = ORIG_CCS_HOME;
fs.rmSync(tempDir, { recursive: true, force: true });
mock.restore();
});
async function makeCtx() {
const { CodexProfileRegistry } = await import(
'../../../../src/codex-auth/codex-profile-registry'
);
const reg = new CodexProfileRegistry();
return { registry: reg, version: '0.0.0-test' };
}
function buildToken(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `${header}.${body}.fakesig`;
}
/** Suppress console output during test. */
function silenceConsole(): () => void {
const origLog = console.log;
const origErr = console.error;
const origWarn = console.warn;
const origWrite = process.stderr.write.bind(process.stderr);
console.log = () => {};
console.error = () => {};
console.warn = () => {};
process.stderr.write = () => true;
return () => {
console.log = origLog;
console.error = origErr;
console.warn = origWarn;
process.stderr.write = origWrite;
};
}
function mockDetectCodexReturns(value: string | null) {
// We need to mock before importing the command module
// Use a global environment approach instead
if (value === null) {
process.env._TEST_CODEX_PATH = '';
} else {
process.env._TEST_CODEX_PATH = value;
}
}
describe('handleCreateCodex — happy path', () => {
it('creates profile dir and registry entry (no codex binary)', async () => {
const detectorMod = await import('../../../../src/targets/codex-detector');
spyOn(detectorMod, 'detectCodexCli').mockReturnValue(null);
const { handleCreateCodex } = await import(
'../../../../src/codex-auth/commands/create-command'
);
const ctx = await makeCtx();
const restore = silenceConsole();
try {
await handleCreateCodex(ctx, ['myprofile']);
} finally {
restore();
}
expect(ctx.registry.hasProfile('myprofile')).toBe(true);
const instancesDir = path.join(ccsHome, '.ccs', 'codex-instances', 'myprofile');
expect(fs.existsSync(instancesDir)).toBe(true);
});
});
describe('handleCreateCodex — idempotent re-run', () => {
it('repairs config.toml and preserves auth.json when profile already exists (no --force)', async () => {
const detectorMod = await import('../../../../src/targets/codex-detector');
spyOn(detectorMod, 'detectCodexCli').mockReturnValue(null);
const { handleCreateCodex } = await import(
'../../../../src/codex-auth/commands/create-command'
);
const ctx = await makeCtx();
const restore = silenceConsole();
try {
await handleCreateCodex(ctx, ['dupprofile']);
} finally {
restore();
}
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'dupprofile');
const configPath = path.join(profileDir, 'config.toml');
const authJsonPath = path.join(profileDir, 'auth.json');
const authJson = JSON.stringify({ tokens: { id_token: buildToken({ email: 'idempotent@test' }) } });
fs.writeFileSync(authJsonPath, authJson);
fs.rmSync(configPath, { force: true });
const restore2 = silenceConsole();
try {
await handleCreateCodex(ctx, ['dupprofile']); // second call is idempotent and self-healing
} finally {
restore2();
}
// Profile still has exactly one entry
expect(ctx.registry.listProfiles().filter((n) => n === 'dupprofile').length).toBe(1);
expect(fs.existsSync(configPath)).toBe(true);
expect(fs.readFileSync(authJsonPath, 'utf8')).toBe(authJson);
});
});
describe('handleCreateCodex — --force re-links symlink only', () => {
it('--force on existing profile does not wipe auth.json (D9)', async () => {
const detectorMod = await import('../../../../src/targets/codex-detector');
spyOn(detectorMod, 'detectCodexCli').mockReturnValue(null);
const { handleCreateCodex } = await import(
'../../../../src/codex-auth/commands/create-command'
);
const ctx = await makeCtx();
const restore = silenceConsole();
try {
await handleCreateCodex(ctx, ['forceprofile']);
} finally {
restore();
}
// Write a fake auth.json to simulate logged-in state
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'forceprofile');
const authJsonPath = path.join(profileDir, 'auth.json');
fs.writeFileSync(authJsonPath, JSON.stringify({ tokens: {} }));
const restore2 = silenceConsole();
try {
await handleCreateCodex(ctx, ['forceprofile', '--force']);
} finally {
restore2();
}
// auth.json must still exist (D9: preserve, re-link only)
expect(fs.existsSync(authJsonPath)).toBe(true);
});
});
describe('handleCreateCodex — validation', () => {
it('refuses reserved name "default"', async () => {
const detectorMod = await import('../../../../src/targets/codex-detector');
spyOn(detectorMod, 'detectCodexCli').mockReturnValue(null);
const { handleCreateCodex } = await import(
'../../../../src/codex-auth/commands/create-command'
);
const ctx = await makeCtx();
let exitCalled = false;
const origExit = process.exit;
process.exit = (code?: number) => {
exitCalled = true;
void code;
throw new Error(`process.exit(${code})`);
};
const restore = silenceConsole();
try {
await handleCreateCodex(ctx, ['default']);
} catch (e) {
// expected — process.exit throws
expect(String(e)).toContain('process.exit');
} finally {
restore();
process.exit = origExit;
}
expect(ctx.registry.hasProfile('default')).toBe(false);
});
it('refuses name with path separator', async () => {
const detectorMod = await import('../../../../src/targets/codex-detector');
spyOn(detectorMod, 'detectCodexCli').mockReturnValue(null);
const { handleCreateCodex } = await import(
'../../../../src/codex-auth/commands/create-command'
);
const ctx = await makeCtx();
let exitCalled = false;
const origExit = process.exit;
process.exit = () => {
exitCalled = true;
throw new Error('exit');
};
const restore = silenceConsole();
try {
await handleCreateCodex(ctx, ['foo/bar']);
} catch {
/* expected */
} finally {
restore();
process.exit = origExit;
}
expect(exitCalled).toBe(true);
expect(ctx.registry.hasProfile('foo/bar')).toBe(false);
});
it('refuses empty name', async () => {
const detectorMod = await import('../../../../src/targets/codex-detector');
spyOn(detectorMod, 'detectCodexCli').mockReturnValue(null);
const { handleCreateCodex } = await import(
'../../../../src/codex-auth/commands/create-command'
);
const ctx = await makeCtx();
let exitCalled = false;
const origExit = process.exit;
process.exit = () => {
exitCalled = true;
throw new Error('exit');
};
const restore = silenceConsole();
try {
await handleCreateCodex(ctx, []);
} catch {
/* expected */
} finally {
restore();
process.exit = origExit;
}
expect(exitCalled).toBe(true);
});
it('rejects command-specific flags that create does not support', async () => {
const detectorMod = await import('../../../../src/targets/codex-detector');
spyOn(detectorMod, 'detectCodexCli').mockReturnValue(null);
const { handleCreateCodex } = await import(
'../../../../src/codex-auth/commands/create-command'
);
const ctx = await makeCtx();
let exitCalled = false;
const origExit = process.exit;
process.exit = () => {
exitCalled = true;
throw new Error('exit');
};
const restore = silenceConsole();
try {
await handleCreateCodex(ctx, ['flagleak', '--shell', 'fish']);
} catch {
/* expected */
} finally {
restore();
process.exit = origExit;
}
expect(exitCalled).toBe(true);
expect(ctx.registry.hasProfile('flagleak')).toBe(false);
});
});
describe('handleCreateCodex — auto-spawn login (D11)', () => {
it('invokes spawn with CODEX_HOME set to profile dir', async () => {
const detectorMod = await import('../../../../src/targets/codex-detector');
spyOn(detectorMod, 'detectCodexCli').mockReturnValue('/usr/bin/codex');
// Mock spawn to emit exit(0) and write auth.json
spyOn(childProcess, 'spawn').mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(_cmd: string, _args: string[], opts: any) => {
const dir = (opts?.env?.CODEX_HOME as string) ?? '';
if (dir) {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, 'auth.json'),
JSON.stringify({ tokens: { id_token: 'h.e30K.s' } })
);
}
const ee = {
on: (evt: string, cb: (n: number) => void) => {
if (evt === 'exit') setImmediate(() => cb(0));
return ee;
},
};
return ee as ReturnType<typeof childProcess.spawn>;
}
);
const { handleCreateCodex } = await import(
'../../../../src/codex-auth/commands/create-command'
);
const ctx = await makeCtx();
const restore = silenceConsole();
try {
await handleCreateCodex(ctx, ['logintest']);
} finally {
restore();
}
expect(childProcess.spawn).toHaveBeenCalled();
const spawnArgs = (childProcess.spawn as ReturnType<typeof spyOn>).mock.calls[0];
expect(String(spawnArgs[2]?.env?.CODEX_HOME)).toContain('logintest');
});
it('login failure leaves profile dir created (retry-able)', async () => {
const detectorMod = await import('../../../../src/targets/codex-detector');
spyOn(detectorMod, 'detectCodexCli').mockReturnValue('/usr/bin/codex');
spyOn(childProcess, 'spawn').mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(_cmd: string, _args: string[], _opts: any) => {
const ee = {
on: (evt: string, cb: (n: number) => void) => {
if (evt === 'exit') setImmediate(() => cb(1));
return ee;
},
};
return ee as ReturnType<typeof childProcess.spawn>;
}
);
const { handleCreateCodex } = await import(
'../../../../src/codex-auth/commands/create-command'
);
const ctx = await makeCtx();
let exitCode = -1;
const origExit = process.exit;
process.exit = (code?: number) => {
exitCode = code ?? 0;
throw new Error('exit');
};
const restore = silenceConsole();
try {
await handleCreateCodex(ctx, ['faillogin']);
} catch {
/* expected */
} finally {
restore();
process.exit = origExit;
}
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'faillogin');
expect(fs.existsSync(profileDir)).toBe(true);
expect(ctx.registry.hasProfile('faillogin')).toBe(true);
expect(exitCode).toBe(4);
});
it('persists last_used and account_id when login token has account_id only', async () => {
const detectorMod = await import('../../../../src/targets/codex-detector');
spyOn(detectorMod, 'detectCodexCli').mockReturnValue('/usr/bin/codex');
spyOn(childProcess, 'spawn').mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(_cmd: string, _args: string[], opts: any) => {
const dir = (opts?.env?.CODEX_HOME as string) ?? '';
if (dir) {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, 'auth.json'),
JSON.stringify({
tokens: {
id_token: buildToken({
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct-account-only',
},
}),
},
})
);
}
const ee = {
on: (evt: string, cb: (n: number) => void) => {
if (evt === 'exit') setImmediate(() => cb(0));
return ee;
},
};
return ee as ReturnType<typeof childProcess.spawn>;
}
);
const { handleCreateCodex } = await import(
'../../../../src/codex-auth/commands/create-command'
);
const ctx = await makeCtx();
const restore = silenceConsole();
try {
await handleCreateCodex(ctx, ['accountonly']);
} finally {
restore();
}
const meta = ctx.registry.getProfile('accountonly');
expect(meta.last_used).toBeTruthy();
expect(meta.account_id).toBe('acct-account-only');
});
});
@@ -0,0 +1,709 @@
/**
* Unit tests for codex-auth import-default command.
*
* Covers:
* - missing legacy auth.json → clean error
* - profile exists no --force → refuses with hint
* - profile exists --force → backup created, overwrite
* - cliproxy-format source → rejects with clear message
* - torn-write retry: truncated JSON twice then full → succeeds on 3rd read
* - persistent torn state → clean error, not silent corruption
* - pgrep mock returning PID → warns + refuses without --force-while-running
* - --force-while-running bypasses pgrep check
* - --with-history copies history.jsonl + sessions/
* - --with-history default false → not copied
* - atomic write: tmp file gone after rename
*/
import { afterEach, beforeEach, describe, expect, it, spyOn, mock } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as childProcess from 'child_process';
// Note: spyOn(fs, 'readFileSync') crashes Bun's process due to native module binding.
// Torn-write retry tests use real file replacement via timer instead.
// Build a minimal valid JWT with given payload for test fixtures
function makeJwt(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `${header}.${body}.fakesig`;
}
const VALID_JWT = makeJwt({
email: 'test@example.com',
'https://api.openai.com/auth': {
chatgpt_plan_type: 'plus',
chatgpt_account_id: 'acct-123',
},
});
const VALID_AUTH_JSON = JSON.stringify({ tokens: { id_token: VALID_JWT } });
let tempDir: string;
let ccsHome: string;
let legacyCodexHome: string;
const ORIG_CCS_HOME = process.env.CCS_HOME;
const ORIG_LEGACY_CODEX_HOME = process.env.LEGACY_CODEX_HOME;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-import-default-test-'));
ccsHome = path.join(tempDir, 'ccs');
legacyCodexHome = path.join(tempDir, 'legacy-codex');
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true });
fs.mkdirSync(legacyCodexHome, { recursive: true });
process.env.CCS_HOME = ccsHome;
process.env.LEGACY_CODEX_HOME = legacyCodexHome;
// Default: pgrep finds nothing (no Codex running). Tests that need a positive
// result override this per-test. Without this default, `pgrep -f codex` on a
// dev machine matches the Claude Code process itself and exits early with code 7.
spyOn(childProcess, 'spawnSync').mockReturnValue({
status: 1,
stdout: '',
stderr: '',
pid: 0,
output: [],
signal: null,
error: undefined,
});
});
afterEach(() => {
if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME;
else process.env.CCS_HOME = ORIG_CCS_HOME;
if (ORIG_LEGACY_CODEX_HOME === undefined) delete process.env.LEGACY_CODEX_HOME;
else process.env.LEGACY_CODEX_HOME = ORIG_LEGACY_CODEX_HOME;
fs.rmSync(tempDir, { recursive: true, force: true });
mock.restore();
});
async function makeCtx() {
const { CodexProfileRegistry } = await import(
'../../../../src/codex-auth/codex-profile-registry'
);
return {
registry: new CodexProfileRegistry(),
version: '0.0.0-test',
};
}
function silenceConsole(): () => void {
const origLog = console.log;
const origErr = console.error;
const origWarn = console.warn;
const origStdErr = process.stderr.write.bind(process.stderr);
console.log = () => {};
console.error = () => {};
console.warn = () => {};
process.stderr.write = () => true;
return () => {
console.log = origLog;
console.error = origErr;
console.warn = origWarn;
process.stderr.write = origStdErr;
};
}
function captureOutput(): { stderr: string[]; restore: () => void } {
const stderr: string[] = [];
const origStdErr = process.stderr.write.bind(process.stderr);
const origLog = console.log;
const origErr = console.error;
console.log = () => {};
console.error = (...args: unknown[]) => {
stderr.push(args.join(' '));
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
process.stderr.write = (chunk: any) => {
stderr.push(String(chunk));
return true;
};
return {
stderr,
restore: () => {
console.log = origLog;
console.error = origErr;
process.stderr.write = origStdErr;
},
};
}
function mockProcessTable(pgrepStdout: string, psStdout: string) {
spyOn(childProcess, 'spawnSync').mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(cmd: string, _args: string[]): any => {
if (cmd === 'pgrep') {
return {
status: pgrepStdout.trim().length > 0 ? 0 : 1,
stdout: pgrepStdout,
stderr: '',
pid: 0,
output: [],
signal: null,
error: undefined,
};
}
if (cmd === 'ps') {
return {
status: psStdout.trim().length > 0 ? 0 : 1,
stdout: psStdout,
stderr: '',
pid: 0,
output: [],
signal: null,
error: undefined,
};
}
return {
status: 1,
stdout: '',
stderr: '',
pid: 0,
output: [],
signal: null,
error: undefined,
};
}
);
}
// ─────────────────────────────────────────────────────────────────────────────
describe('import-default — missing legacy auth.json', () => {
it('exits with clear error when ~/.codex/auth.json does not exist', async () => {
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
let exitCalled = false;
const origExit = process.exit;
process.exit = () => {
exitCalled = true;
throw new Error('exit');
};
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['myprofile']);
} catch {
/* expected */
} finally {
restore();
process.exit = origExit;
}
expect(exitCalled).toBe(true);
expect(ctx.registry.hasProfile('myprofile')).toBe(false);
});
});
describe('import-default — option validation', () => {
it('rejects unsupported flags before importing legacy auth', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
let exitCount = 0;
const origExit = process.exit;
process.exit = () => {
exitCount++;
throw new Error('exit');
};
const captured = captureOutput();
try {
await handleImportDefaultCodex(ctx, ['typo', '--with-historyy']);
} catch {
/* expected */
}
try {
await handleImportDefaultCodex(ctx, ['shellleak', '--shell', 'fish']);
} catch {
/* expected */
}
try {
await handleImportDefaultCodex(ctx, ['jsonleak', '--json']);
} catch {
/* expected */
}
try {
await handleImportDefaultCodex(ctx, ['yesleak', '--yes']);
} catch {
/* expected */
} finally {
captured.restore();
process.exit = origExit;
}
expect(exitCount).toBe(4);
expect(ctx.registry.hasProfile('typo')).toBe(false);
expect(ctx.registry.hasProfile('shellleak')).toBe(false);
expect(ctx.registry.hasProfile('jsonleak')).toBe(false);
expect(ctx.registry.hasProfile('yesleak')).toBe(false);
expect(captured.stderr.join('')).toContain('Usage:');
expect(captured.stderr.join('')).toContain('--shell');
});
});
describe('import-default — profile collision without --force', () => {
it('refuses when profile exists without --force', async () => {
// Write valid legacy auth
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
// Pre-create the profile
ctx.registry.createProfile('myprofile');
let exitCalled = false;
const origExit = process.exit;
process.exit = () => {
exitCalled = true;
throw new Error('exit');
};
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['myprofile']);
} catch {
/* expected */
} finally {
restore();
process.exit = origExit;
}
expect(exitCalled).toBe(true);
});
});
describe('import-default — --force overwrites and creates backup', () => {
it('creates .bak file and overwrites auth.json when --force passed', async () => {
// Write valid legacy auth
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
// First import (no --force needed since profile doesn't exist)
const restore1 = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['backuptest']);
} finally {
restore1();
}
// Verify profile was created
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'backuptest');
const destAuth = path.join(profileDir, 'auth.json');
expect(fs.existsSync(destAuth)).toBe(true);
// Update legacy with different data
const newJwt = makeJwt({ email: 'new@example.com' });
fs.writeFileSync(
path.join(legacyCodexHome, 'auth.json'),
JSON.stringify({ tokens: { id_token: newJwt } })
);
// Re-run with --force
const restore2 = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['backuptest', '--force']);
} finally {
restore2();
}
// Backup file should exist
const files = fs.readdirSync(profileDir);
const bakFile = files.find((f) => f.startsWith('auth.json.bak-'));
expect(bakFile).toBeDefined();
// New auth.json should contain the new JWT (which encodes new@example.com)
// Verify by checking registry metadata which decodes the JWT
const meta = ctx.registry.getProfile('backuptest');
expect(meta.email).toBe('new@example.com');
});
});
describe('import-default — cliproxy-format rejection', () => {
it('rejects auth files with type field (CLIProxy wrapper format)', async () => {
const cliproxyAuth = JSON.stringify({
type: 'codex',
account_id: 'abc',
tokens: { id_token: VALID_JWT },
});
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), cliproxyAuth);
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
let exitCalled = false;
const origExit = process.exit;
process.exit = () => {
exitCalled = true;
throw new Error('exit');
};
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['cliptest']);
} catch {
/* expected */
} finally {
restore();
process.exit = origExit;
}
expect(exitCalled).toBe(true);
expect(ctx.registry.hasProfile('cliptest')).toBe(false);
});
});
describe('import-default — torn-write retry', () => {
it('retries on truncated JSON and succeeds after file is fixed', async () => {
const authPath = path.join(legacyCodexHome, 'auth.json');
// Write truncated JSON initially — simulates torn write mid-file
fs.writeFileSync(authPath, '{truncated');
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
// After 50ms (before 2nd retry at 100ms) replace with valid JSON
const fixTimer = setTimeout(() => {
fs.writeFileSync(authPath, VALID_AUTH_JSON);
}, 50);
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['retrytest']);
} finally {
restore();
clearTimeout(fixTimer);
}
// The retry succeeded once file was repaired
expect(ctx.registry.hasProfile('retrytest')).toBe(true);
});
it('fails cleanly on persistent torn state (all retries fail)', async () => {
const authPath = path.join(legacyCodexHome, 'auth.json');
// Write persistently invalid JSON — all retries will fail
fs.writeFileSync(authPath, '{always-truncated');
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
let exitCalled = false;
const origExit = process.exit;
process.exit = () => {
exitCalled = true;
throw new Error('exit');
};
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['torntest']);
} catch {
/* expected */
} finally {
restore();
process.exit = origExit;
}
expect(exitCalled).toBe(true);
expect(ctx.registry.hasProfile('torntest')).toBe(false);
});
it('rejects a malformed 3-segment id_token payload', async () => {
const authPath = path.join(legacyCodexHome, 'auth.json');
fs.writeFileSync(authPath, JSON.stringify({ tokens: { id_token: 'header.not-json.sig' } }));
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
let exitCalled = false;
const origExit = process.exit;
process.exit = () => {
exitCalled = true;
throw new Error('exit');
};
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['badjwt']);
} catch {
/* expected */
} finally {
restore();
process.exit = origExit;
}
expect(exitCalled).toBe(true);
expect(ctx.registry.hasProfile('badjwt')).toBe(false);
});
it('rejects an id_token payload with invalid base64url characters', async () => {
const authPath = path.join(legacyCodexHome, 'auth.json');
fs.writeFileSync(authPath, JSON.stringify({ tokens: { id_token: 'h.e30$.s' } }));
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
let exitCalled = false;
const origExit = process.exit;
process.exit = () => {
exitCalled = true;
throw new Error('exit');
};
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['bad-base64url']);
} catch {
/* expected */
} finally {
restore();
process.exit = origExit;
}
expect(exitCalled).toBe(true);
expect(ctx.registry.hasProfile('bad-base64url')).toBe(false);
});
it('rejects an id_token signature with impossible base64url length', async () => {
const authPath = path.join(legacyCodexHome, 'auth.json');
const [header, payload] = VALID_JWT.split('.');
fs.writeFileSync(authPath, JSON.stringify({ tokens: { id_token: `${header}.${payload}.a` } }));
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
let exitCalled = false;
const origExit = process.exit;
process.exit = () => {
exitCalled = true;
throw new Error('exit');
};
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['bad-signature']);
} catch {
/* expected */
} finally {
restore();
process.exit = origExit;
}
expect(exitCalled).toBe(true);
expect(ctx.registry.hasProfile('bad-signature')).toBe(false);
});
});
describe('import-default — Codex running detection', () => {
it('warns and refuses when pgrep finds a codex PID', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
mockProcessTable('12345\n', '12345 /usr/local/bin/codex login\n');
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
let exitCalled = false;
const origExit = process.exit;
process.exit = () => {
exitCalled = true;
throw new Error('exit');
};
const captured = captureOutput();
try {
await handleImportDefaultCodex(ctx, ['runningtest']);
} catch {
/* expected */
} finally {
captured.restore();
process.exit = origExit;
}
expect(exitCalled).toBe(true);
const stderrMsg = captured.stderr.join('');
expect(stderrMsg).toContain('12345');
});
it('proceeds with --force-while-running even when Codex is running', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
mockProcessTable('12345\n', '12345 /usr/local/bin/codex login\n');
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['forcerunning', '--force-while-running']);
} finally {
restore();
}
// Should have proceeded and created the profile
expect(ctx.registry.hasProfile('forcerunning')).toBe(true);
});
it('warns and refuses when Codex is running through a node shim', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
mockProcessTable('12345\n', '12345 /usr/bin/node /usr/local/bin/codex login\n');
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
let exitCalled = false;
const origExit = process.exit;
process.exit = () => {
exitCalled = true;
throw new Error('exit');
};
const captured = captureOutput();
try {
await handleImportDefaultCodex(ctx, ['nodeshim']);
} catch {
/* expected */
} finally {
captured.restore();
process.exit = origExit;
}
expect(exitCalled).toBe(true);
expect(captured.stderr.join('')).toContain('12345');
expect(ctx.registry.hasProfile('nodeshim')).toBe(false);
});
it('ignores pgrep false positives that are not Codex executables', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
mockProcessTable('11111\n', '11111 /usr/bin/node /tmp/codex-auth-helper.js\n');
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['falsepositive']);
} finally {
restore();
}
expect(ctx.registry.hasProfile('falsepositive')).toBe(true);
});
});
describe('import-default — --with-history', () => {
it('copies history.jsonl and sessions/ when --with-history passed', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
fs.writeFileSync(path.join(legacyCodexHome, 'history.jsonl'), '{"prompt":"hello"}\n');
const sessionsDir = path.join(legacyCodexHome, 'sessions');
fs.mkdirSync(sessionsDir, { recursive: true });
fs.writeFileSync(path.join(sessionsDir, 'sess1.json'), '{}');
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['withhistory', '--with-history']);
} finally {
restore();
}
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'withhistory');
expect(fs.existsSync(path.join(profileDir, 'history.jsonl'))).toBe(true);
expect(fs.existsSync(path.join(profileDir, 'sessions', 'sess1.json'))).toBe(true);
});
it('does NOT copy history.jsonl by default (D8: false default)', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
fs.writeFileSync(path.join(legacyCodexHome, 'history.jsonl'), '{"prompt":"hello"}\n');
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['nohistory']);
} finally {
restore();
}
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'nohistory');
expect(fs.existsSync(path.join(profileDir, 'history.jsonl'))).toBe(false);
});
});
describe('import-default — atomic write', () => {
it('leaves no tmp file after successful import', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['atomictest']);
} finally {
restore();
}
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'atomictest');
const files = fs.readdirSync(profileDir);
const tmpFiles = files.filter((f) => f.includes('.tmp.'));
expect(tmpFiles.length).toBe(0);
});
});
describe('import-default — happy path end-to-end', () => {
it('registers profile with decoded email in registry', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['happypath']);
} finally {
restore();
}
expect(ctx.registry.hasProfile('happypath')).toBe(true);
const meta = ctx.registry.getProfile('happypath');
expect(meta.email).toBe('test@example.com');
expect(meta.plan_type).toBe('plus');
});
});
@@ -0,0 +1,302 @@
/**
* Tests for codex-auth login command.
*/
import { afterEach, beforeEach, describe, expect, it, spyOn, mock } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as childProcess from 'child_process';
let tempDir: string;
let ccsHome: string;
const ORIG_CCS_HOME = process.env.CCS_HOME;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-login-test-'));
ccsHome = path.join(tempDir, 'ccs');
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true });
process.env.CCS_HOME = ccsHome;
});
afterEach(() => {
if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME;
else process.env.CCS_HOME = ORIG_CCS_HOME;
fs.rmSync(tempDir, { recursive: true, force: true });
mock.restore();
});
async function makeCtx() {
const { CodexProfileRegistry } = await import(
'../../../../src/codex-auth/codex-profile-registry'
);
return { registry: new CodexProfileRegistry(), version: '0.0.0-test' };
}
function spawnReturnsCode(code: number, writeAuth = false) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
spyOn(childProcess, 'spawn').mockImplementation((_cmd: string, _args: string[], opts: any) => {
if (writeAuth && opts?.env?.CODEX_HOME) {
const dir = opts.env.CODEX_HOME as string;
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, 'auth.json'),
JSON.stringify({ tokens: { id_token: 'h.e30K.s' } })
);
}
const ee = {
on: (evt: string, cb: (code: number) => void) => {
if (evt === 'exit') setTimeout(() => cb(code), 0);
return ee;
},
};
return ee as ReturnType<typeof childProcess.spawn>;
});
}
function buildToken(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `${header}.${body}.fakesig`;
}
describe('handleLoginCodex — binary missing', () => {
it('exits with BINARY_ERROR when codex not found', async () => {
const detectorMod = await import('../../../../src/targets/codex-detector');
spyOn(detectorMod, 'detectCodexCli').mockReturnValue(null);
const { handleLoginCodex } = await import('../../../../src/codex-auth/commands/login-command');
const ctx = await makeCtx();
let exitCode = -1;
const origExit = process.exit;
process.exit = (code?: number) => {
exitCode = code ?? 0;
throw new Error('process.exit');
};
try {
await handleLoginCodex(ctx, ['myprofile']);
} catch {
/* process.exit throws */
} finally {
process.exit = origExit;
}
expect(exitCode).toBe(5); // ExitCode.BINARY_ERROR
});
});
describe('handleLoginCodex — missing profile auto-creates', () => {
it('auto-creates profile entry when not in registry', async () => {
const detectorMod = await import('../../../../src/targets/codex-detector');
spyOn(detectorMod, 'detectCodexCli').mockReturnValue('/usr/bin/codex');
spawnReturnsCode(0, true);
const { handleLoginCodex } = await import('../../../../src/codex-auth/commands/login-command');
const ctx = await makeCtx();
const out: string[] = [];
const origLog = console.log;
console.log = (...a: unknown[]) => out.push(a.join(' '));
try {
await handleLoginCodex(ctx, ['newprofile']);
} finally {
console.log = origLog;
}
expect(ctx.registry.hasProfile('newprofile')).toBe(true);
expect(out.some((l) => l.includes('Auto-creating'))).toBe(true);
});
it('does not create an orphan registry entry when profile dir setup fails', async () => {
const detectorMod = await import('../../../../src/targets/codex-detector');
spyOn(detectorMod, 'detectCodexCli').mockReturnValue('/usr/bin/codex');
const originalMkdirSync = fs.mkdirSync;
spyOn(fs, 'mkdirSync').mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(target: fs.PathLike, options?: any): string | undefined => {
if (String(target).includes('codex-instances')) {
throw Object.assign(new Error('simulated mkdir failure'), { code: 'EACCES' });
}
return originalMkdirSync(target, options);
}
);
const { handleLoginCodex } = await import('../../../../src/codex-auth/commands/login-command');
const ctx = await makeCtx();
let exitCode = -1;
const origExit = process.exit;
process.exit = (code?: number) => {
exitCode = code ?? 0;
throw new Error('exit');
};
const origLog = console.log;
console.log = () => {};
try {
await handleLoginCodex(ctx, ['orphan']);
} catch {
/* expected */
} finally {
console.log = origLog;
process.exit = origExit;
}
expect(exitCode).toBe(2);
expect(ctx.registry.hasProfile('orphan')).toBe(false);
});
it('rejects command-specific flags that login does not support', async () => {
const { handleLoginCodex } = await import('../../../../src/codex-auth/commands/login-command');
const ctx = await makeCtx();
let exitCalled = false;
const origExit = process.exit;
process.exit = () => {
exitCalled = true;
throw new Error('exit');
};
try {
await handleLoginCodex(ctx, ['flagleak', '--json']);
} catch {
/* expected */
} finally {
process.exit = origExit;
}
expect(exitCalled).toBe(true);
expect(ctx.registry.hasProfile('flagleak')).toBe(false);
});
});
describe('handleLoginCodex — spawn called with CODEX_HOME pinned', () => {
it('passes CODEX_HOME env to spawn', async () => {
const detectorMod = await import('../../../../src/targets/codex-detector');
spyOn(detectorMod, 'detectCodexCli').mockReturnValue('/usr/bin/codex');
spawnReturnsCode(0, true);
const { handleLoginCodex } = await import('../../../../src/codex-auth/commands/login-command');
const ctx = await makeCtx();
ctx.registry.createProfile('pintest', { created: new Date().toISOString(), last_used: null });
const origLog = console.log;
console.log = () => {};
try {
await handleLoginCodex(ctx, ['pintest']);
} finally {
console.log = origLog;
}
expect(childProcess.spawn).toHaveBeenCalled();
const call = (childProcess.spawn as ReturnType<typeof spyOn>).mock.calls[0];
expect(call[2]?.env?.CODEX_HOME).toContain('pintest');
});
});
describe('handleLoginCodex — clean exit updates registry', () => {
it('updates email/plan in registry after successful login', async () => {
const detectorMod = await import('../../../../src/targets/codex-detector');
spyOn(detectorMod, 'detectCodexCli').mockReturnValue('/usr/bin/codex');
spawnReturnsCode(0, true); // writes auth.json with minimal JWT
const { handleLoginCodex } = await import('../../../../src/codex-auth/commands/login-command');
const ctx = await makeCtx();
ctx.registry.createProfile('updatetest', {
created: new Date().toISOString(),
last_used: null,
});
const origLog = console.log;
console.log = () => {};
try {
await handleLoginCodex(ctx, ['updatetest']);
} finally {
console.log = origLog;
}
const meta = ctx.registry.getProfile('updatetest');
// last_used should now be set
expect(meta.last_used).toBeTruthy();
});
it('preserves cached identity metadata when a re-login token is sparse', async () => {
const detectorMod = await import('../../../../src/targets/codex-detector');
spyOn(detectorMod, 'detectCodexCli').mockReturnValue('/usr/bin/codex');
spawnReturnsCode(0, true); // writes auth.json with a valid but sparse JWT payload
const { handleLoginCodex } = await import('../../../../src/codex-auth/commands/login-command');
const ctx = await makeCtx();
ctx.registry.createProfile('preservemeta', {
created: new Date().toISOString(),
last_used: null,
email: 'cached@example.com',
plan_type: 'pro',
account_id: 'acct-cached',
});
const origLog = console.log;
console.log = () => {};
try {
await handleLoginCodex(ctx, ['preservemeta']);
} finally {
console.log = origLog;
}
const meta = ctx.registry.getProfile('preservemeta');
expect(meta.last_used).toBeTruthy();
expect(meta.email).toBe('cached@example.com');
expect(meta.plan_type).toBe('pro');
expect(meta.account_id).toBe('acct-cached');
});
it('persists account_id when login token has account_id only', async () => {
const detectorMod = await import('../../../../src/targets/codex-detector');
spyOn(detectorMod, 'detectCodexCli').mockReturnValue('/usr/bin/codex');
spyOn(childProcess, 'spawn').mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(_cmd: string, _args: string[], opts: any) => {
const dir = (opts?.env?.CODEX_HOME as string) ?? '';
if (dir) {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, 'auth.json'),
JSON.stringify({
tokens: {
id_token: buildToken({
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct-login-only',
},
}),
},
})
);
}
const ee = {
on: (evt: string, cb: (code: number) => void) => {
if (evt === 'exit') setTimeout(() => cb(0), 0);
return ee;
},
};
return ee as ReturnType<typeof childProcess.spawn>;
}
);
const { handleLoginCodex } = await import('../../../../src/codex-auth/commands/login-command');
const ctx = await makeCtx();
ctx.registry.createProfile('accountonly', {
created: new Date().toISOString(),
last_used: null,
});
const origLog = console.log;
console.log = () => {};
try {
await handleLoginCodex(ctx, ['accountonly']);
} finally {
console.log = origLog;
}
const meta = ctx.registry.getProfile('accountonly');
expect(meta.last_used).toBeTruthy();
expect(meta.account_id).toBe('acct-login-only');
});
});
@@ -0,0 +1,438 @@
/**
* Tests for codex-auth remove command.
*/
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
let tempDir: string;
let ccsHome: string;
const ORIG_CCS_HOME = process.env.CCS_HOME;
const ORIG_CCS_CODEX_PROFILE = process.env.CCS_CODEX_PROFILE;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-remove-test-'));
ccsHome = path.join(tempDir, 'ccs');
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true });
process.env.CCS_HOME = ccsHome;
delete process.env.CCS_CODEX_PROFILE;
});
afterEach(() => {
if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME;
else process.env.CCS_HOME = ORIG_CCS_HOME;
if (ORIG_CCS_CODEX_PROFILE === undefined) delete process.env.CCS_CODEX_PROFILE;
else process.env.CCS_CODEX_PROFILE = ORIG_CCS_CODEX_PROFILE;
fs.rmSync(tempDir, { recursive: true, force: true });
mock.restore();
});
async function makeCtx(...names: string[]) {
const { CodexProfileRegistry } = await import(
'../../../../src/codex-auth/codex-profile-registry'
);
const reg = new CodexProfileRegistry();
for (const n of names) {
reg.createProfile(n, { created: new Date().toISOString(), last_used: null });
// Create the profile dir too
const dir = path.join(ccsHome, '.ccs', 'codex-instances', n);
fs.mkdirSync(dir, { recursive: true });
}
return { registry: reg, version: '0.0.0-test' };
}
function mockConfirmYes() {
return import('../../../../src/utils/prompt').then((mod) => {
spyOn(mod.InteractivePrompt, 'confirm').mockResolvedValue(true);
});
}
function mockConfirmNo() {
return import('../../../../src/utils/prompt').then((mod) => {
spyOn(mod.InteractivePrompt, 'confirm').mockResolvedValue(false);
});
}
// ── non-default removes cleanly ───────────────────────────────────────────────
describe('handleRemoveCodex — normal removal', () => {
it('removes a non-default profile cleanly', async () => {
await mockConfirmYes();
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
);
const ctx = await makeCtx('alpha', 'beta');
ctx.registry.setDefault('alpha');
const out: string[] = [];
const origLog = console.log;
console.log = (...a: unknown[]) => out.push(a.join(' '));
try {
await handleRemoveCodex(ctx, ['beta', '--yes']);
} finally {
console.log = origLog;
}
expect(ctx.registry.hasProfile('beta')).toBe(false);
expect(out.some((l) => l.includes('removed'))).toBe(true);
});
});
// ── default with others → refuses without --force ────────────────────────────
describe('handleRemoveCodex — default guard', () => {
it('refuses to remove default when others exist without --force', async () => {
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
);
const ctx = await makeCtx('alpha', 'beta');
ctx.registry.setDefault('alpha');
let exitCode = -1;
const origExit = process.exit;
process.exit = (code?: number) => {
exitCode = code ?? 0;
throw new Error('exit');
};
const out: string[] = [];
const origLog = console.log;
console.log = (...a: unknown[]) => out.push(a.join(' '));
try {
await handleRemoveCodex(ctx, ['alpha']);
} catch {
/* process.exit */
} finally {
process.exit = origExit;
console.log = origLog;
}
expect(exitCode).toBeGreaterThan(0);
expect(ctx.registry.hasProfile('alpha')).toBe(true); // not removed
// Hint lines still go to stdout; user-facing error now goes to stderr via exitWithError
expect(out.some((l) => l.includes('ccsx auth switch'))).toBe(true);
});
it('allows removal of default with --force', async () => {
await mockConfirmYes();
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
);
const ctx = await makeCtx('alpha', 'beta');
ctx.registry.setDefault('alpha');
await handleRemoveCodex(ctx, ['alpha', '--force', '--yes']);
expect(ctx.registry.hasProfile('alpha')).toBe(false);
});
it('restores data when the target becomes default between precheck and registry write', async () => {
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
);
const ctx = await makeCtx('alpha', 'beta');
ctx.registry.setDefault('beta');
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'alpha');
const authJsonPath = path.join(profileDir, 'auth.json');
fs.writeFileSync(authJsonPath, JSON.stringify({ tokens: { id_token: 'h.e30K.s' } }));
const realCpSync = fs.cpSync;
spyOn(fs, 'cpSync').mockImplementation((src, dest, options) => {
const result = realCpSync(src, dest, options);
if (typeof dest === 'string' && dest.includes('.preserved.')) {
ctx.registry.setDefault('alpha');
}
return result;
});
let exitCode = -1;
const origExit = process.exit;
const origErr = console.error;
process.exit = (code?: number) => {
exitCode = code ?? 0;
throw new Error('exit');
};
console.error = () => {};
try {
await handleRemoveCodex(ctx, ['alpha', '--yes']);
} catch {
/* process.exit */
} finally {
process.exit = origExit;
console.error = origErr;
}
expect(exitCode).toBeGreaterThan(0);
expect(fs.existsSync(authJsonPath)).toBe(true);
expect(ctx.registry.hasProfile('alpha')).toBe(true);
expect(ctx.registry.getDefault()).toBe('alpha');
});
});
// ── only profile → allows removal ────────────────────────────────────────────
describe('handleRemoveCodex — only profile', () => {
it('allows removal of the only profile (even if default)', async () => {
await mockConfirmYes();
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
);
const ctx = await makeCtx('solo');
ctx.registry.setDefault('solo');
await handleRemoveCodex(ctx, ['solo', '--yes']);
expect(ctx.registry.hasProfile('solo')).toBe(false);
});
});
// ── confirmation prompt ───────────────────────────────────────────────────────
describe('handleRemoveCodex — confirmation', () => {
it('rejects extra positional arguments before deleting anything', async () => {
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
);
const ctx = await makeCtx('work');
let exitCode = -1;
const err: string[] = [];
const origExit = process.exit;
const origError = console.error;
const origWrite = process.stderr.write.bind(process.stderr);
process.exit = (code?: number) => {
exitCode = code ?? 0;
throw new Error('exit');
};
console.error = (...a: unknown[]) => err.push(a.join(' '));
process.stderr.write = (chunk: string | Uint8Array) => {
err.push(String(chunk));
return true;
};
try {
await handleRemoveCodex(ctx, ['work', 'accidental', '--yes']);
} catch {
/* process.exit */
} finally {
process.exit = origExit;
console.error = origError;
process.stderr.write = origWrite;
}
expect(exitCode).toBeGreaterThan(0);
expect(ctx.registry.hasProfile('work')).toBe(true);
expect(err.join('')).toContain('Unexpected arguments: "accidental"');
});
it('cancels when user declines confirmation', async () => {
await mockConfirmNo();
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
);
const ctx = await makeCtx('keepme');
const out: string[] = [];
const origLog = console.log;
console.log = (...a: unknown[]) => out.push(a.join(' '));
try {
await handleRemoveCodex(ctx, ['keepme']); // no --yes
} finally {
console.log = origLog;
}
expect(ctx.registry.hasProfile('keepme')).toBe(true); // not removed
expect(out.some((l) => l.includes('Cancelled'))).toBe(true);
});
it('--yes skips prompt entirely', async () => {
// No mock — if prompt were called it would hang/throw in test
const promptMod = await import('../../../../src/utils/prompt');
let promptCalled = false;
spyOn(promptMod.InteractivePrompt, 'confirm').mockImplementation(async () => {
promptCalled = true;
return true;
});
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
);
const ctx = await makeCtx('skipconfirm');
const origLog = console.log;
console.log = () => {};
try {
await handleRemoveCodex(ctx, ['skipconfirm', '--yes']);
} finally {
console.log = origLog;
}
expect(promptCalled).toBe(false);
expect(ctx.registry.hasProfile('skipconfirm')).toBe(false);
});
it('preserves profile data when registry removal fails', async () => {
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
);
const ctx = await makeCtx('preserveme');
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'preserveme');
const authJsonPath = path.join(profileDir, 'auth.json');
fs.writeFileSync(authJsonPath, JSON.stringify({ tokens: { id_token: 'h.e30K.s' } }));
spyOn(ctx.registry, 'removeProfile').mockImplementation(() => {
throw new Error('registry write denied');
});
let exitCode = -1;
const origExit = process.exit;
const origErr = console.error;
process.exit = (code?: number) => {
exitCode = code ?? 0;
throw new Error('exit');
};
console.error = () => {};
try {
await handleRemoveCodex(ctx, ['preserveme', '--yes']);
} catch {
/* process.exit */
} finally {
process.exit = origExit;
console.error = origErr;
}
expect(exitCode).toBeGreaterThan(0);
expect(fs.existsSync(authJsonPath)).toBe(true);
expect(ctx.registry.hasProfile('preserveme')).toBe(true);
});
it('cleans a partial preservation copy when delete preparation fails', async () => {
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
);
const ctx = await makeCtx('copyfail');
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'copyfail');
const parentDir = path.dirname(profileDir);
const authJsonPath = path.join(profileDir, 'auth.json');
fs.writeFileSync(authJsonPath, JSON.stringify({ tokens: { id_token: 'h.e30K.s' } }));
const realCpSync = fs.cpSync;
spyOn(fs, 'cpSync').mockImplementation((src, dest, options) => {
if (typeof dest === 'string' && dest.includes('.preserved.')) {
fs.mkdirSync(dest, { recursive: true });
fs.writeFileSync(path.join(dest, 'auth.json'), '{}');
throw new Error('copy failed after partial write');
}
return realCpSync(src, dest, options);
});
let exitCode = -1;
const origExit = process.exit;
const origErr = console.error;
process.exit = (code?: number) => {
exitCode = code ?? 0;
throw new Error('exit');
};
console.error = () => {};
try {
await handleRemoveCodex(ctx, ['copyfail', '--yes']);
} catch {
/* process.exit */
} finally {
process.exit = origExit;
console.error = origErr;
}
expect(exitCode).toBeGreaterThan(0);
expect(fs.existsSync(authJsonPath)).toBe(true);
expect(ctx.registry.hasProfile('copyfail')).toBe(true);
expect(fs.readdirSync(parentDir).some((entry) => entry.startsWith('copyfail.preserved.'))).toBe(
false
);
});
it('restores profile data and registry when final deletion fails', async () => {
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
);
const ctx = await makeCtx('restoreme');
ctx.registry.setDefault('restoreme');
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'restoreme');
const authJsonPath = path.join(profileDir, 'auth.json');
fs.writeFileSync(authJsonPath, JSON.stringify({ tokens: { id_token: 'h.e30K.s' } }));
const realRmSync = fs.rmSync;
spyOn(fs, 'rmSync').mockImplementation((target, options) => {
if (typeof target === 'string' && target.includes('.deleting.')) {
throw new Error('delete denied');
}
return realRmSync(target, options);
});
let exitCode = -1;
const origExit = process.exit;
const origErr = console.error;
process.exit = (code?: number) => {
exitCode = code ?? 0;
throw new Error('exit');
};
console.error = () => {};
try {
await handleRemoveCodex(ctx, ['restoreme', '--yes']);
} catch {
/* process.exit */
} finally {
process.exit = origExit;
console.error = origErr;
}
expect(exitCode).toBeGreaterThan(0);
expect(fs.existsSync(authJsonPath)).toBe(true);
expect(ctx.registry.hasProfile('restoreme')).toBe(true);
expect(ctx.registry.getDefault()).toBe('restoreme');
});
it('restores from preserved copy when final deletion partially removes auth.json', async () => {
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
);
const ctx = await makeCtx('partialrestore');
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'partialrestore');
const authJsonPath = path.join(profileDir, 'auth.json');
fs.writeFileSync(authJsonPath, JSON.stringify({ tokens: { id_token: 'h.e30K.s' } }));
const realRmSync = fs.rmSync;
spyOn(fs, 'rmSync').mockImplementation((target, options) => {
if (typeof target === 'string' && target.includes('.deleting.')) {
realRmSync(path.join(target, 'auth.json'), { force: true });
throw new Error('delete failed after auth removal');
}
return realRmSync(target, options);
});
let exitCode = -1;
const origExit = process.exit;
const origErr = console.error;
process.exit = (code?: number) => {
exitCode = code ?? 0;
throw new Error('exit');
};
console.error = () => {};
try {
await handleRemoveCodex(ctx, ['partialrestore', '--yes']);
} catch {
/* process.exit */
} finally {
process.exit = origExit;
console.error = origErr;
}
expect(exitCode).toBeGreaterThan(0);
expect(fs.existsSync(authJsonPath)).toBe(true);
expect(ctx.registry.hasProfile('partialrestore')).toBe(true);
});
});
@@ -0,0 +1,239 @@
/**
* Tests for codex-auth show command.
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
let tempDir: string;
let ccsHome: string;
const ORIG_CCS_HOME = process.env.CCS_HOME;
const ORIG_CCS_CODEX_PROFILE = process.env.CCS_CODEX_PROFILE;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-show-test-'));
ccsHome = path.join(tempDir, 'ccs');
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true });
process.env.CCS_HOME = ccsHome;
delete process.env.CCS_CODEX_PROFILE;
});
afterEach(() => {
if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME;
else process.env.CCS_HOME = ORIG_CCS_HOME;
if (ORIG_CCS_CODEX_PROFILE === undefined) delete process.env.CCS_CODEX_PROFILE;
else process.env.CCS_CODEX_PROFILE = ORIG_CCS_CODEX_PROFILE;
fs.rmSync(tempDir, { recursive: true, force: true });
});
async function makeCtx(...names: string[]) {
const { CodexProfileRegistry } = await import(
'../../../../src/codex-auth/codex-profile-registry'
);
const reg = new CodexProfileRegistry();
for (const n of names) {
reg.createProfile(n, { created: new Date().toISOString(), last_used: null });
}
return { registry: reg, version: '0.0.0-test' };
}
async function captureStdout(fn: () => Promise<void>): Promise<string> {
const chunks: string[] = [];
const origLog = console.log;
const origWrite = process.stdout.write.bind(process.stdout);
console.log = (...a: unknown[]) => chunks.push(a.map(String).join(' ') + '\n');
process.stdout.write = (chunk: string | Uint8Array) => {
chunks.push(String(chunk));
return true;
};
try {
await fn();
} finally {
console.log = origLog;
process.stdout.write = origWrite;
}
return chunks.join('');
}
function buildToken(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `${header}.${body}.fakesig`;
}
// ── empty list ────────────────────────────────────────────────────────────────
describe('handleShowCodex — empty list', () => {
it('shows "No Codex profiles" message when registry is empty', async () => {
const { handleShowCodex } = await import('../../../../src/codex-auth/commands/show-command');
const ctx = await makeCtx();
const out = await captureStdout(() => handleShowCodex(ctx, []));
expect(out).toContain('No Codex profiles');
expect(out).toContain('ccsx auth create');
});
});
// ── list with default marker ──────────────────────────────────────────────────
describe('handleShowCodex — default marker', () => {
it('marks default profile in STATE column', async () => {
const { handleShowCodex } = await import('../../../../src/codex-auth/commands/show-command');
const ctx = await makeCtx('alpha', 'beta');
ctx.registry.setDefault('alpha');
const out = await captureStdout(() => handleShowCodex(ctx, []));
expect(out).toContain('alpha');
expect(out).toContain('default');
});
});
// ── JSON account metadata ───────────────────────────────────────────────────
describe('handleShowCodex — JSON account_id', () => {
it('includes account_id from registry metadata or auth.json identity', async () => {
const { handleShowCodex } = await import('../../../../src/codex-auth/commands/show-command');
const ctx = await makeCtx('registryid', 'authid');
ctx.registry.updateProfile('registryid', {
account_id: 'acct-from-registry',
email: 'registry@example.com',
});
const authProfileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'authid');
fs.mkdirSync(authProfileDir, { recursive: true });
fs.writeFileSync(
path.join(authProfileDir, 'auth.json'),
JSON.stringify({
tokens: {
id_token: buildToken({
email: 'auth@example.com',
'https://api.openai.com/auth': {
chatgpt_plan_type: 'plus',
chatgpt_account_id: 'acct-from-auth-json',
},
}),
},
})
);
const out = await captureStdout(() => handleShowCodex(ctx, ['--json']));
const parsed = JSON.parse(out) as {
profiles: Array<{ name: string; account_id: string | null; email: string | null }>;
};
expect(parsed.profiles.find((p) => p.name === 'registryid')?.account_id).toBe(
'acct-from-registry'
);
expect(parsed.profiles.find((p) => p.name === 'authid')?.account_id).toBe(
'acct-from-auth-json'
);
});
});
// ── active(missing) row at top (D14) ─────────────────────────────────────────
describe('handleShowCodex — active(missing) at top', () => {
it('shows active(missing) row at top when CCS_CODEX_PROFILE points to deleted profile', async () => {
const { handleShowCodex } = await import('../../../../src/codex-auth/commands/show-command');
const ctx = await makeCtx('realprofile');
process.env.CCS_CODEX_PROFILE = 'deletedprofile'; // not in registry
const out = await captureStdout(() => handleShowCodex(ctx, []));
expect(out).toContain('active(missing)');
// Table truncates long names — match on prefix
expect(out).toContain('deletedprof');
// active(missing) row appears before realprofile in the table
const missingIdx = out.indexOf('deletedprof');
const realIdx = out.indexOf('realprofile');
expect(missingIdx).toBeLessThan(realIdx);
});
});
// ── detail view ───────────────────────────────────────────────────────────────
describe('handleShowCodex — detail view', () => {
it('shows detail for named profile', async () => {
const { handleShowCodex } = await import('../../../../src/codex-auth/commands/show-command');
const ctx = await makeCtx('myprofile');
const out = await captureStdout(() => handleShowCodex(ctx, ['myprofile']));
expect(out).toContain('myprofile');
expect(out).toContain('auth.json');
expect(out).toContain('missing'); // auth.json not present
});
it('detail view shows <unknown> for email when auth.json missing', async () => {
const { handleShowCodex } = await import('../../../../src/codex-auth/commands/show-command');
const ctx = await makeCtx('noauth');
const out = await captureStdout(() => handleShowCodex(ctx, ['noauth']));
expect(out).toContain('<unknown>');
});
it('detail JSON includes cached registry identity when auth.json is missing', async () => {
const { handleShowCodex } = await import('../../../../src/codex-auth/commands/show-command');
const ctx = await makeCtx('registrydetail');
ctx.registry.updateProfile('registrydetail', {
account_id: 'acct-from-registry-detail',
email: 'detail@example.com',
plan_type: 'team',
});
const out = await captureStdout(() => handleShowCodex(ctx, ['registrydetail', '--json']));
const parsed = JSON.parse(out) as {
account_id: string | null;
email: string | null;
plan: string | null;
};
expect(parsed.account_id).toBe('acct-from-registry-detail');
expect(parsed.email).toBe('detail@example.com');
expect(parsed.plan).toBe('team');
});
it('rejects extra positional arguments instead of ignoring them', async () => {
const { handleShowCodex } = await import('../../../../src/codex-auth/commands/show-command');
const ctx = await makeCtx('myprofile');
let exitCode = -1;
const err: string[] = [];
const origExit = process.exit;
const origError = console.error;
const origWrite = process.stderr.write.bind(process.stderr);
process.exit = (code?: number) => {
exitCode = code ?? 0;
throw new Error('exit');
};
console.error = (...a: unknown[]) => err.push(a.join(' '));
process.stderr.write = (chunk: string | Uint8Array) => {
err.push(String(chunk));
return true;
};
try {
await handleShowCodex(ctx, ['myprofile', 'extra']);
} catch {
/* process.exit */
} finally {
process.exit = origExit;
console.error = origError;
process.stderr.write = origWrite;
}
expect(exitCode).toBeGreaterThan(0);
expect(err.join('')).toContain('Unexpected arguments: "extra"');
});
it('does not crash with malformed auth.json', async () => {
const { handleShowCodex } = await import('../../../../src/codex-auth/commands/show-command');
const ctx = await makeCtx('malformed');
// Write malformed auth.json
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'malformed');
fs.mkdirSync(profileDir, { recursive: true });
fs.writeFileSync(path.join(profileDir, 'auth.json'), 'NOT_JSON{{{');
const out = await captureStdout(() => handleShowCodex(ctx, ['malformed']));
// Should show present but not crash; email shows <invalid> or <unknown>
expect(out).toContain('present');
expect(out).not.toContain('Error');
});
});
@@ -0,0 +1,103 @@
/**
* Tests for codex-auth switch command.
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
let tempDir: string;
let ccsHome: string;
const ORIG_CCS_HOME = process.env.CCS_HOME;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-switch-test-'));
ccsHome = path.join(tempDir, 'ccs');
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true });
process.env.CCS_HOME = ccsHome;
});
afterEach(() => {
if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME;
else process.env.CCS_HOME = ORIG_CCS_HOME;
fs.rmSync(tempDir, { recursive: true, force: true });
});
async function makeCtxWithProfiles(...names: string[]) {
const { CodexProfileRegistry } = await import(
'../../../../src/codex-auth/codex-profile-registry'
);
const reg = new CodexProfileRegistry();
for (const n of names) {
reg.createProfile(n, { created: new Date().toISOString(), last_used: null });
}
return { registry: reg, version: '0.0.0-test' };
}
describe('handleSwitchCodex — sets default', () => {
it('switches default to named profile', async () => {
const { handleSwitchCodex } = await import(
'../../../../src/codex-auth/commands/switch-command'
);
const ctx = await makeCtxWithProfiles('alpha', 'beta');
const out: string[] = [];
const origLog = console.log;
console.log = (...a: unknown[]) => out.push(a.join(' '));
try {
await handleSwitchCodex(ctx, ['beta']);
} finally {
console.log = origLog;
}
expect(ctx.registry.getDefault()).toBe('beta');
expect(out.some((l) => l.includes('beta'))).toBe(true);
});
});
describe('handleSwitchCodex — unknown profile', () => {
it('exits non-zero for unknown profile name', async () => {
const { handleSwitchCodex } = await import(
'../../../../src/codex-auth/commands/switch-command'
);
const ctx = await makeCtxWithProfiles('alpha');
let exitCode = -1;
const origExit = process.exit;
process.exit = (code?: number) => {
exitCode = code ?? 0;
throw new Error('exit');
};
try {
await handleSwitchCodex(ctx, ['doesnotexist']);
} catch {
/* expected */
} finally {
process.exit = origExit;
}
expect(exitCode).toBeGreaterThan(0);
});
});
describe('handleSwitchCodex — output format', () => {
it('includes [OK] in output on success', async () => {
const { handleSwitchCodex } = await import(
'../../../../src/codex-auth/commands/switch-command'
);
const ctx = await makeCtxWithProfiles('myprofile');
const out: string[] = [];
const origLog = console.log;
console.log = (...a: unknown[]) => out.push(a.join(' '));
try {
await handleSwitchCodex(ctx, ['myprofile']);
} finally {
console.log = origLog;
}
const combined = out.join('\n');
expect(combined).toContain('myprofile');
// Should mention persistent default note
expect(combined).toContain('persistent default');
});
});
@@ -0,0 +1,246 @@
/**
* Tests for codex-auth use command.
*
* CRITICAL: stdout discipline — stdout must contain ONLY shell-eval lines.
* All info/error/hint text must go to stderr.
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
let tempDir: string;
let ccsHome: string;
const ORIG_CCS_HOME = process.env.CCS_HOME;
const ORIG_NO_PRE_DISPATCH = process.env.CCS_NO_PRE_DISPATCH;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-use-test-'));
ccsHome = path.join(tempDir, 'ccs');
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true });
process.env.CCS_HOME = ccsHome;
// Ensure guard is active for tests
process.env.CCS_NO_PRE_DISPATCH = '1';
});
afterEach(() => {
if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME;
else process.env.CCS_HOME = ORIG_CCS_HOME;
if (ORIG_NO_PRE_DISPATCH === undefined) delete process.env.CCS_NO_PRE_DISPATCH;
else process.env.CCS_NO_PRE_DISPATCH = ORIG_NO_PRE_DISPATCH;
fs.rmSync(tempDir, { recursive: true, force: true });
});
async function makeCtxWithProfile(name: string) {
const { CodexProfileRegistry } = await import(
'../../../../src/codex-auth/codex-profile-registry'
);
const reg = new CodexProfileRegistry();
reg.createProfile(name, { created: new Date().toISOString(), last_used: null });
return { registry: reg, version: '0.0.0-test' };
}
/** Capture stdout and stderr separately while calling fn. */
async function captureStreams(
fn: () => Promise<void>
): Promise<{ stdout: string; stderr: string }> {
const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];
const origOut = process.stdout.write.bind(process.stdout);
const origErr = process.stderr.write.bind(process.stderr);
const origConsoleLog = console.log;
const origConsoleError = console.error;
process.stdout.write = (chunk: string | Uint8Array) => {
stdoutChunks.push(String(chunk));
return true;
};
process.stderr.write = (chunk: string | Uint8Array) => {
stderrChunks.push(String(chunk));
return true;
};
console.log = (...args: unknown[]) => {
stdoutChunks.push(`${args.map(String).join(' ')}\n`);
};
console.error = (...args: unknown[]) => {
stderrChunks.push(`${args.map(String).join(' ')}\n`);
};
try {
await fn();
} finally {
process.stdout.write = origOut;
process.stderr.write = origErr;
console.log = origConsoleLog;
console.error = origConsoleError;
}
return { stdout: stdoutChunks.join(''), stderr: stderrChunks.join('') };
}
// ── stdout discipline (CRITICAL) ──────────────────────────────────────────────
describe('handleUseCodex — stdout discipline', () => {
it('stdout contains ONLY export lines for bash', async () => {
const { handleUseCodex } = await import('../../../../src/codex-auth/commands/use-command');
const ctx = await makeCtxWithProfile('work');
const { stdout, stderr } = await captureStreams(() =>
handleUseCodex(ctx, ['work', '--shell', 'bash'])
);
const lines = stdout.trim().split('\n').filter(Boolean);
// Every stdout line must be a shell export statement
for (const line of lines) {
expect(line).toMatch(/^export [A-Z_]+=|^set -gx |^\$env:|^set [A-Z_]+=|^export [A-Z]/);
}
// Hint must be on stderr only
expect(stderr).toContain('active in this shell');
expect(stdout).not.toContain('[i]');
expect(stdout).not.toContain('[X]');
expect(stdout).not.toContain('[!]');
expect(stdout).not.toContain('[OK]');
});
it('stdout empty, non-zero exit for unknown profile', async () => {
const { handleUseCodex } = await import('../../../../src/codex-auth/commands/use-command');
const ctx = await makeCtxWithProfile('work');
let exitCode = -1;
const origExit = process.exit;
process.exit = (code?: number) => {
exitCode = code ?? 0;
throw new Error('process.exit');
};
const { stdout, stderr } = await captureStreams(async () => {
try {
await handleUseCodex(ctx, ['nonexistent', '--shell', 'bash']);
} catch {
/* process.exit */
}
}).finally(() => {
process.exit = origExit;
});
expect(stdout).toBe('');
expect(exitCode).toBeGreaterThan(0);
expect(stderr).toContain('Profile not found');
});
});
// ── shell syntax variants ─────────────────────────────────────────────────────
describe('handleUseCodex — shell syntax', () => {
it('bash: export KEY=value', async () => {
const { handleUseCodex } = await import('../../../../src/codex-auth/commands/use-command');
const ctx = await makeCtxWithProfile('work');
const { stdout } = await captureStreams(() => handleUseCodex(ctx, ['work', '--shell', 'bash']));
expect(stdout).toContain('export CODEX_HOME=');
expect(stdout).toContain("export CCS_CODEX_PROFILE='work'");
});
it('fish: set -gx KEY value;', async () => {
const { handleUseCodex } = await import('../../../../src/codex-auth/commands/use-command');
const ctx = await makeCtxWithProfile('work');
const { stdout } = await captureStreams(() => handleUseCodex(ctx, ['work', '--shell', 'fish']));
expect(stdout).toContain('set -gx CODEX_HOME');
expect(stdout).toContain("set -gx CCS_CODEX_PROFILE 'work';");
});
it('pwsh: $env:KEY = value', async () => {
const { handleUseCodex } = await import('../../../../src/codex-auth/commands/use-command');
const ctx = await makeCtxWithProfile('work');
const { stdout } = await captureStreams(() => handleUseCodex(ctx, ['work', '--shell', 'pwsh']));
expect(stdout).toContain('$env:CODEX_HOME');
expect(stdout).toContain('$env:CCS_CODEX_PROFILE');
});
it('cmd: quoted set assignment syntax', async () => {
const { handleUseCodex } = await import('../../../../src/codex-auth/commands/use-command');
const ctx = await makeCtxWithProfile('work');
const { stdout } = await captureStreams(() => handleUseCodex(ctx, ['work', '--shell', 'cmd']));
expect(stdout).toContain('set "CODEX_HOME=');
expect(stdout).toContain('set "CCS_CODEX_PROFILE=work"');
});
it('invalid --shell value → stderr error, empty stdout', async () => {
const { handleUseCodex } = await import('../../../../src/codex-auth/commands/use-command');
const ctx = await makeCtxWithProfile('work');
let exitCode = -1;
const origExit = process.exit;
process.exit = (code?: number) => {
exitCode = code ?? 0;
throw new Error('exit');
};
const { stdout, stderr } = await captureStreams(async () => {
try {
await handleUseCodex(ctx, ['work', '--shell', 'ksh']);
} catch {
/* process.exit */
}
}).finally(() => {
process.exit = origExit;
});
expect(stdout).toBe('');
expect(exitCode).toBeGreaterThan(0);
expect(stderr).toContain('Unsupported');
});
it('unknown option → stderr usage, empty stdout', async () => {
const { handleUseCodex } = await import('../../../../src/codex-auth/commands/use-command');
const ctx = await makeCtxWithProfile('work');
let exitCode = -1;
const origExit = process.exit;
process.exit = (code?: number) => {
exitCode = code ?? 0;
throw new Error('exit');
};
const { stdout, stderr } = await captureStreams(async () => {
try {
await handleUseCodex(ctx, ['work', '--bad-flag']);
} catch {
/* process.exit */
}
}).finally(() => {
process.exit = origExit;
});
expect(stdout).toBe('');
expect(exitCode).toBeGreaterThan(0);
expect(stderr).toContain('Usage:');
expect(stderr).toContain('Unknown options');
});
});
// ── missing profile → stderr only ────────────────────────────────────────────
describe('handleUseCodex — missing profile stderr only', () => {
it('missing profile name → stderr, empty stdout', async () => {
const { handleUseCodex } = await import('../../../../src/codex-auth/commands/use-command');
const ctx = await makeCtxWithProfile('work');
let exitCode = -1;
const origExit = process.exit;
process.exit = (code?: number) => {
exitCode = code ?? 0;
throw new Error('exit');
};
const { stdout, stderr } = await captureStreams(async () => {
try {
await handleUseCodex(ctx, []);
} catch {
/* process.exit */
}
}).finally(() => {
process.exit = origExit;
});
expect(stdout).toBe('');
expect(exitCode).toBeGreaterThan(0);
expect(stderr).toContain('required');
});
});
@@ -0,0 +1,142 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
// Lazy import so tests can run before implementation
let decodeIdToken: (idToken: string) => { email?: string; plan_type?: string; account_id?: string };
let hasStructurallyValidIdToken: (idToken: string) => boolean;
// Fixture: real-shape JWT with nested claims
// Payload (base64url): {"email":"user@example.com","https://api.openai.com/auth":{"chatgpt_plan_type":"pro","chatgpt_account_id":"4b0448c0-e4a2-4cc0-a70d-77065d613553"}}
const VALID_NESTED_TOKEN = buildToken({
email: 'user@example.com',
'https://api.openai.com/auth': {
chatgpt_plan_type: 'pro',
chatgpt_account_id: '4b0448c0-e4a2-4cc0-a70d-77065d613553',
},
});
// Fixture: email only via profile fallback path
const PROFILE_EMAIL_TOKEN = buildToken({
'https://api.openai.com/profile': { email: 'profile@example.com' },
'https://api.openai.com/auth': { chatgpt_plan_type: 'plus', chatgpt_account_id: 'acct-xyz' },
});
// Fixture: neither top-level email nor profile email — both absent
const NO_EMAIL_TOKEN = buildToken({
sub: 'user-abc',
'https://api.openai.com/auth': {
chatgpt_plan_type: 'free',
chatgpt_account_id: 'acct-123',
},
});
// Fixture: missing plan_type in auth claim
const MISSING_PLAN_TOKEN = buildToken({
email: 'user@example.com',
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct-no-plan',
},
});
// Fixture: missing account_id in auth claim
const MISSING_ACCOUNT_ID_TOKEN = buildToken({
email: 'user@example.com',
'https://api.openai.com/auth': {
chatgpt_plan_type: 'pro',
},
});
// Fixture: no https://api.openai.com/auth claim at all
const NO_AUTH_CLAIM_TOKEN = buildToken({
email: 'user@example.com',
sub: 'user-abc',
});
function buildToken(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `${header}.${body}.fakesig`;
}
beforeEach(async () => {
const mod = await import('../../../src/codex-auth/decode-id-token');
decodeIdToken = mod.decodeIdToken;
hasStructurallyValidIdToken = mod.hasStructurallyValidIdToken;
});
describe('decodeIdToken', () => {
it('extracts email, plan_type, account_id from standard nested JWT', () => {
const result = decodeIdToken(VALID_NESTED_TOKEN);
expect(result.email).toBe('user@example.com');
expect(result.plan_type).toBe('pro');
expect(result.account_id).toBe('4b0448c0-e4a2-4cc0-a70d-77065d613553');
});
it('falls back to profile email when top-level email is absent', () => {
const result = decodeIdToken(PROFILE_EMAIL_TOKEN);
expect(result.email).toBe('profile@example.com');
expect(result.plan_type).toBe('plus');
expect(result.account_id).toBe('acct-xyz');
});
it('returns {} for token with only 2 segments (malformed)', () => {
const result = decodeIdToken('header.payload');
expect(result).toEqual({});
});
it('returns {} for non-base64 garbage input', () => {
const result = decodeIdToken('!!!.%%%.$$$');
expect(result).toEqual({});
});
it('returns empty object when email is absent in both paths', () => {
const result = decodeIdToken(NO_EMAIL_TOKEN);
expect(result.email).toBeUndefined();
expect(result.plan_type).toBe('free');
expect(result.account_id).toBe('acct-123');
});
it('returns undefined plan_type when chatgpt_plan_type is absent', () => {
const result = decodeIdToken(MISSING_PLAN_TOKEN);
expect(result.email).toBe('user@example.com');
expect(result.plan_type).toBeUndefined();
expect(result.account_id).toBe('acct-no-plan');
});
it('returns undefined account_id when chatgpt_account_id is absent', () => {
const result = decodeIdToken(MISSING_ACCOUNT_ID_TOKEN);
expect(result.email).toBe('user@example.com');
expect(result.plan_type).toBe('pro');
expect(result.account_id).toBeUndefined();
});
it('returns partial result when https://api.openai.com/auth claim is absent', () => {
const result = decodeIdToken(NO_AUTH_CLAIM_TOKEN);
expect(result.email).toBe('user@example.com');
expect(result.plan_type).toBeUndefined();
expect(result.account_id).toBeUndefined();
});
it('does not throw on empty string input', () => {
expect(() => decodeIdToken('')).not.toThrow();
expect(decodeIdToken('')).toEqual({});
});
it('reports valid sparse JWT payloads as structurally valid', () => {
expect(hasStructurallyValidIdToken(buildToken({}))).toBe(true);
});
it('rejects JWT segments with invalid base64url characters', () => {
const [header, payload, signature] = buildToken({}).split('.');
expect(hasStructurallyValidIdToken(`${header}.${payload}$.${signature}`)).toBe(false);
expect(hasStructurallyValidIdToken(`${header}=.${payload}.${signature}`)).toBe(false);
});
it('rejects JWT segments with impossible base64url length', () => {
const [header, payload] = buildToken({}).split('.');
expect(hasStructurallyValidIdToken(`${header}.${payload}.a`)).toBe(false);
expect(decodeIdToken(`${header}.${payload}.a`)).toEqual({});
});
});
@@ -0,0 +1,298 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as yaml from 'js-yaml';
let resolveActiveProfile: (
env?: NodeJS.ProcessEnv
) => { name: string; dir: string; source: 'env' | 'default' } | null;
const ORIGINAL_CCS_HOME = process.env.CCS_HOME;
let tempDir: string;
let ccsHome: string;
let registryPath: string;
let instancesDir: string;
beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-profile-test-'));
ccsHome = path.join(tempDir, 'ccs');
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true, mode: 0o700 });
process.env.CCS_HOME = ccsHome;
registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml');
instancesDir = path.join(ccsHome, '.ccs', 'codex-instances');
// Re-import after setting CCS_HOME so module picks up updated dir
const mod = await import('../../../src/codex-auth/resolve-active-profile');
resolveActiveProfile = mod.resolveActiveProfile;
});
afterEach(() => {
if (ORIGINAL_CCS_HOME === undefined) {
delete process.env.CCS_HOME;
} else {
process.env.CCS_HOME = ORIGINAL_CCS_HOME;
}
fs.rmSync(tempDir, { recursive: true, force: true });
});
// Helper to write a registry YAML fixture
function writeRegistry(data: object): void {
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(registryPath, yaml.dump(data, { indent: 2 }), { mode: 0o600 });
}
function makeProfileDir(name: string): string {
const dir = path.join(instancesDir, name);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
return dir;
}
describe('resolveActiveProfile', () => {
it('returns null silently when registry file does not exist', () => {
// No registry written
const result = resolveActiveProfile({});
expect(result).toBeNull();
});
it('throws when registry YAML is corrupt even without an env override', () => {
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(registryPath, '{ invalid yaml: [[[', { mode: 0o600 });
expect(() => resolveActiveProfile({})).toThrow(/registry YAML could not be parsed/);
try {
resolveActiveProfile({});
} catch (err) {
expect(String(err)).toContain('$CCS_HOME/.ccs/codex-profiles.yaml');
expect(String(err)).not.toContain(registryPath);
expect(String(err)).not.toContain('invalid yaml');
}
});
it('throws when CCS_CODEX_PROFILE is set and registry YAML is corrupt', () => {
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(registryPath, '{ invalid yaml: [[[', { mode: 0o600 });
let thrown: unknown;
try {
resolveActiveProfile({ CCS_CODEX_PROFILE: 'work' });
} catch (err) {
thrown = err;
}
expect(thrown).toBeDefined();
expect(String(thrown)).toContain('Refusing to fall back to ~/.codex');
expect(String(thrown)).toContain('$CCS_HOME/.ccs/codex-profiles.yaml');
expect(String(thrown)).not.toContain(registryPath);
expect(String(thrown)).not.toContain('invalid yaml');
});
it('throws when registry is missing a valid profiles map', () => {
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(registryPath, 'version: "1.0"\ndefault: null\nprofiles: []\n', {
mode: 0o600,
});
expect(() => resolveActiveProfile({})).toThrow(/object profiles map/);
});
it('throws instead of falling back when registry default has an invalid falsy type', () => {
writeRegistry({
version: '1.0',
default: false,
profiles: {},
});
expect(() => resolveActiveProfile({})).toThrow(/default must be a string or null/i);
});
it('throws when CCS_CODEX_PROFILE contains an unsafe profile name', () => {
writeRegistry({
version: '1.0',
default: null,
profiles: {},
});
expect(() => resolveActiveProfile({ CCS_CODEX_PROFILE: '../escape' })).toThrow(
/invalid.*path separators/i
);
});
it('throws when registry default contains an unsafe profile name', () => {
writeRegistry({
version: '1.0',
default: '../escape',
profiles: {
'../escape': { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null },
},
});
expect(() => resolveActiveProfile({})).toThrow(/invalid.*path separators/i);
});
it('throws when registry default points to a missing profile', () => {
writeRegistry({
version: '1.0',
default: 'ghost',
profiles: {},
});
expect(() => resolveActiveProfile({})).toThrow(/default profile is missing from profiles map/i);
});
it('throws when matched registry profile entry is malformed', () => {
writeRegistry({
version: '1.0',
default: 'work',
profiles: {
work: 1,
},
});
expect(() => resolveActiveProfile({})).toThrow(/profile "work" must be an object/i);
});
it('throws when a registry profile is missing required metadata', () => {
writeRegistry({
version: '1.0',
default: 'work',
profiles: {
work: { type: 'codex', last_used: null },
},
});
expect(() => resolveActiveProfile({})).toThrow(/created timestamp/i);
});
it('throws when a registry profile has invalid optional metadata types', () => {
writeRegistry({
version: '1.0',
default: 'work',
profiles: {
work: {
type: 'codex',
created: '2026-01-01T00:00:00.000Z',
last_used: null,
account_id: 123,
},
},
});
expect(() => resolveActiveProfile({})).toThrow(/account_id must be a string/i);
});
it('returns source=env when CCS_CODEX_PROFILE matches a registry entry', () => {
const profileDir = makeProfileDir('work');
writeRegistry({
version: '1.0',
default: null,
profiles: {
work: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null },
},
});
const result = resolveActiveProfile({ CCS_CODEX_PROFILE: 'work' });
expect(result).not.toBeNull();
expect(result?.name).toBe('work');
expect(result?.source).toBe('env');
expect(result?.dir).toBe(profileDir);
});
it('returns source=default when no env var and registry has a default profile', () => {
const profileDir = makeProfileDir('personal');
writeRegistry({
version: '1.0',
default: 'personal',
profiles: {
personal: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null },
},
});
const result = resolveActiveProfile({});
expect(result).not.toBeNull();
expect(result?.name).toBe('personal');
expect(result?.source).toBe('default');
expect(result?.dir).toBe(profileDir);
});
it('env > default precedence: CCS_CODEX_PROFILE overrides registry default', () => {
makeProfileDir('work');
makeProfileDir('personal');
writeRegistry({
version: '1.0',
default: 'personal',
profiles: {
work: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null },
personal: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null },
},
});
const result = resolveActiveProfile({ CCS_CODEX_PROFILE: 'work' });
expect(result?.name).toBe('work');
expect(result?.source).toBe('env');
});
it('throws when CCS_CODEX_PROFILE names a profile not in registry', () => {
writeRegistry({
version: '1.0',
default: null,
profiles: {},
});
expect(() => resolveActiveProfile({ CCS_CODEX_PROFILE: 'ghost' })).toThrow(
/CCS_CODEX_PROFILE='ghost'/
);
});
it('throws when CCS_CODEX_PROFILE is set but registry file is missing', () => {
expect(() => resolveActiveProfile({ CCS_CODEX_PROFILE: 'ghost' })).toThrow(/does not exist/);
});
it('treats empty/whitespace-only CCS_CODEX_PROFILE as unset, falls back to default', () => {
makeProfileDir('default-profile');
writeRegistry({
version: '1.0',
default: 'default-profile',
profiles: {
'default-profile': {
type: 'codex',
created: '2026-01-01T00:00:00.000Z',
last_used: null,
},
},
});
const resultEmpty = resolveActiveProfile({ CCS_CODEX_PROFILE: '' });
expect(resultEmpty?.name).toBe('default-profile');
expect(resultEmpty?.source).toBe('default');
const resultWhitespace = resolveActiveProfile({ CCS_CODEX_PROFILE: ' ' });
expect(resultWhitespace?.name).toBe('default-profile');
expect(resultWhitespace?.source).toBe('default');
});
it('resolves the profile dir to an absolute path', () => {
makeProfileDir('absolute-test');
writeRegistry({
version: '1.0',
default: 'absolute-test',
profiles: {
'absolute-test': {
type: 'codex',
created: '2026-01-01T00:00:00.000Z',
last_used: null,
},
},
});
const result = resolveActiveProfile({});
expect(result?.dir).toBe(path.resolve(result?.dir ?? ''));
expect(path.isAbsolute(result?.dir ?? '')).toBe(true);
});
});
+157
View File
@@ -0,0 +1,157 @@
import { describe, expect, it } from 'bun:test';
import { detectShell, formatExport } from '../../../src/codex-auth/shell-detect';
import type { Shell } from '../../../src/codex-auth/shell-detect';
// ── detectShell ───────────────────────────────────────────────────────────────
describe('detectShell — Unix', () => {
it('returns bash for /bin/bash', () => {
expect(detectShell({ SHELL: '/bin/bash' }, 'linux')).toBe('bash');
});
it('returns zsh for /usr/bin/zsh', () => {
expect(detectShell({ SHELL: '/usr/bin/zsh' }, 'darwin')).toBe('zsh');
});
it('returns fish for /usr/local/bin/fish', () => {
expect(detectShell({ SHELL: '/usr/local/bin/fish' }, 'linux')).toBe('fish');
});
it('returns bash for /bin/sh (generic POSIX)', () => {
expect(detectShell({ SHELL: '/bin/sh' }, 'linux')).toBe('bash');
});
it('returns bash when SHELL is unset', () => {
expect(detectShell({}, 'linux')).toBe('bash');
});
it('returns bash for /usr/local/bin/bash (Homebrew)', () => {
expect(detectShell({ SHELL: '/usr/local/bin/bash' }, 'darwin')).toBe('bash');
});
});
describe('detectShell — Windows', () => {
it('does not treat PSModulePath alone as PowerShell', () => {
expect(
detectShell(
{ PSModulePath: 'C:\\Windows\\system32\\...', ComSpec: 'C:\\Windows\\System32\\cmd.exe' },
'win32'
)
).toBe('cmd');
});
it('returns pwsh when SHELL points to PowerShell', () => {
expect(
detectShell(
{
SHELL: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
PSModulePath: 'C:\\Windows\\system32\\...',
},
'win32'
)
).toBe('pwsh');
});
it('returns pwsh when the parent process is PowerShell and ComSpec points to cmd', () => {
expect(
detectShell(
{ PSModulePath: 'C:\\Windows\\system32\\...', ComSpec: 'C:\\Windows\\System32\\cmd.exe' },
'win32',
'pwsh.exe'
)
).toBe('pwsh');
});
it('honors Git Bash style SHELL on Windows', () => {
expect(detectShell({ SHELL: '/usr/bin/bash', PSModulePath: 'C:\\ps' }, 'win32')).toBe('bash');
});
it('returns cmd when no explicit shell hint is available', () => {
expect(detectShell({ PSModulePath: 'C:\\ps' }, 'win32')).toBe('cmd');
});
});
// ── formatExport ──────────────────────────────────────────────────────────────
describe('formatExport — bash', () => {
it('wraps value in single quotes', () => {
expect(formatExport('bash', 'CODEX_HOME', '/home/user/.ccs/codex-instances/work')).toBe(
"export CODEX_HOME='/home/user/.ccs/codex-instances/work'"
);
});
it('escapes single quotes in value', () => {
const result = formatExport('bash', 'X', "it's");
expect(result).toBe("export X='it'\\''s'");
});
});
describe('formatExport — zsh', () => {
it('uses same syntax as bash', () => {
expect(formatExport('zsh', 'CCS_CODEX_PROFILE', 'work')).toBe(
"export CCS_CODEX_PROFILE='work'"
);
});
});
describe('formatExport — fish', () => {
it('uses set -gx syntax with semicolon', () => {
expect(formatExport('fish', 'CODEX_HOME', '/path/to/dir')).toBe(
"set -gx CODEX_HOME '/path/to/dir';"
);
});
it('escapes single quotes', () => {
const result = formatExport('fish', 'X', "a'b");
expect(result).toContain('set -gx X');
expect(result).toContain("'a'\\''b'");
});
});
describe('formatExport — pwsh', () => {
it('uses $env: assignment with double quotes', () => {
expect(formatExport('pwsh', 'CODEX_HOME', 'C:\\Users\\foo')).toBe(
'$env:CODEX_HOME = "C:\\Users\\foo"'
);
});
it('doubles internal double quotes', () => {
const result = formatExport('pwsh', 'X', 'say "hello"');
expect(result).toBe('$env:X = "say ""hello"""');
});
it('escapes backticks before interpolation-sensitive characters', () => {
const result = formatExport('pwsh', 'CODEX_HOME', 'C:\\Users\\kai`$tmp');
expect(result).toBe('$env:CODEX_HOME = "C:\\Users\\kai```$tmp"');
});
});
describe('formatExport — cmd', () => {
it('uses quoted set assignment syntax', () => {
expect(formatExport('cmd', 'CODEX_HOME', 'C:\\Users\\foo\\.ccs\\codex-instances\\work')).toBe(
'set "CODEX_HOME=C:\\Users\\foo\\.ccs\\codex-instances\\work"'
);
});
it('keeps cmd metacharacters inside the quoted set assignment', () => {
expect(formatExport('cmd', 'CODEX_HOME', 'C:\\Users\\Kai & Co\\x|y<z>')).toBe(
'set "CODEX_HOME=C:\\Users\\Kai & Co\\x|y<z>"'
);
});
it('escapes cmd expansion-sensitive characters', () => {
expect(formatExport('cmd', 'CODEX_HOME', 'C:\\Users\\100% ^ "quoted" !bang!')).toBe(
'set "CODEX_HOME=C:\\Users\\100%% ^^ ^"quoted^" ^^!bang^^!"'
);
});
});
describe('formatExport — each shell produces distinct syntax', () => {
const shells: Shell[] = ['bash', 'zsh', 'fish', 'pwsh', 'cmd'];
it('all shells produce different output for same input', () => {
const outputs = shells.map((s) => formatExport(s, 'K', 'val'));
const unique = new Set(outputs);
// fish and bash differ; pwsh and cmd differ; bash and zsh are identical by design
expect(unique.size).toBeGreaterThanOrEqual(4);
});
});
@@ -19,7 +19,8 @@ describe('pr ci workflow', () => {
expect(workflow).toContain('name: CI');
expect(workflow).toContain('pull_request:');
expect(workflow).toContain('branches: [main, dev]');
expect(workflow.split(trustedAuthorGate).length - 1).toBe(3);
// 4 jobs: validate (matrix), build, test, compose-parity — each gated
expect(workflow.split(trustedAuthorGate).length - 1).toBe(4);
expect(workflow).toContain('group: ci-${{ github.ref }}');
expect(workflow).toContain('cancel-in-progress: true');
expect(workflow).toContain('fail-fast: false');
@@ -7,6 +7,17 @@ function workflowsDir() {
return path.resolve(import.meta.dir, '../../../../.github/workflows');
}
// Documented exceptions to the self-hosted-first policy (see CLAUDE.md "Self-Hosted Runner Policy").
// Each entry must include a justification comment explaining why GitHub-hosted runners
// are required for correctness (not just convenience).
const GITHUB_HOSTED_RUNNER_EXCEPTIONS: Record<string, string> = {
// Pure YAML diff parser — no untrusted code execution. Must cover ALL PRs including
// forks to prevent forked contributors from bypassing the breaking-change check.
// Gating on trusted-author association would silently allow contract-breaking changes
// from forks. No build, install, or arbitrary PR-branch scripts are run here.
'breaking-change-guard.yml': 'ubuntu-latest — fork-safe YAML diff check; no untrusted code execution',
};
describe('self-hosted runner policy', () => {
test('keeps active workflows on local runners', () => {
const hostedRunnerLabels = [
@@ -23,6 +34,9 @@ describe('self-hosted runner policy', () => {
expect(workflowFiles.length).toBeGreaterThan(0);
for (const file of workflowFiles) {
// Skip files with documented, justified exceptions to the self-hosted-first policy
if (GITHUB_HOSTED_RUNNER_EXCEPTIONS[file]) continue;
const workflow = fs.readFileSync(path.join(workflowsDir(), file), 'utf8');
for (const label of hostedRunnerLabels) {
@@ -35,6 +49,17 @@ describe('self-hosted runner policy', () => {
}
});
test('documented exceptions use github-hosted runners for justified safety reasons', () => {
// Verify each documented exception actually uses a GitHub-hosted runner
// (prevents stale exception entries that no longer reflect the workflow)
for (const [file, reason] of Object.entries(GITHUB_HOSTED_RUNNER_EXCEPTIONS)) {
const workflow = fs.readFileSync(path.join(workflowsDir(), file), 'utf8');
const hasGitHubHosted = ['ubuntu-latest', 'ubuntu-24.04', 'ubuntu-22.04', 'macos-latest', 'windows-latest']
.some((label) => workflow.includes(`runs-on: ${label}`));
expect(hasGitHubHosted, `${file} is in exceptions list (reason: ${reason}) but does not use a GitHub-hosted runner — remove the exception or restore the runner type`).toBe(true);
}
});
test('gates pull-request self-hosted worker deploys to trusted authors', () => {
const workflow = fs.readFileSync(path.join(workflowsDir(), 'deploy-ccs-worker.yml'), 'utf8');
@@ -60,6 +85,11 @@ describe('self-hosted runner policy', () => {
);
}
// Documented exceptions run on GitHub-hosted runners for justified safety reasons
// (e.g. must cover forked PRs, no untrusted code execution). These workflows do not
// use self-hosted runners for their PR jobs, so the trusted-author gate does not apply.
if (GITHUB_HOSTED_RUNNER_EXCEPTIONS[file]) continue;
if (
workflow.includes('pull_request:') &&
workflow.includes('self-hosted') &&
@@ -28,6 +28,12 @@ describe('run-test-bucket', () => {
}
});
test('keeps web-server integration tests that bind ports in the slow bucket', () => {
const slowSet = bucket.getSlowSet();
expect(slowSet.has('tests/integration/web-server/codex-profiles-endpoint.test.ts')).toBe(true);
});
test('forces npm tests into the slow bucket', () => {
expect(bucket.shouldForceSlow('tests/npm/cli.test.js')).toBe(true);
});
@@ -0,0 +1,294 @@
/**
* Read-only dashboard card displaying codex-auth profile state.
*
* Distinct from codex-profiles-card.tsx (which edits config.toml [profiles]).
* This card shows CCS-side shell profiles: active account, email, plan tier,
* last-used timestamp, and auth validity.
*
* All mutating actions (switch, remove) are disabled with a terminal redirect
* tooltip per the read-only dashboard spec (D5).
*/
import { Loader2 } from 'lucide-react';
import type { ReactNode } from 'react';
import type { TFunction } from 'i18next';
import { Trans, useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useCodexAuthProfiles } from '@/hooks/use-codex-auth-profiles';
import type { CodexAuthProfileEntry } from '@/hooks/use-codex-auth-profiles';
// ── Helpers ─────────────────────────────────────────────────────────────────
function InlineCode({ children }: { children?: ReactNode }) {
return (
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em] text-foreground">
{children}
</code>
);
}
function formatLastUsed(iso: string | null): string {
if (!iso) return 'never';
try {
const d = new Date(iso);
const diffMs = Date.now() - d.getTime();
const diffMin = Math.floor(diffMs / 60_000);
if (diffMin < 2) return 'just now';
if (diffMin < 60) return `${diffMin} min ago`;
const diffH = Math.floor(diffMin / 60);
if (diffH < 24) return `${diffH}h ago`;
const diffD = Math.floor(diffH / 24);
if (diffD === 1) return 'yesterday';
return `${diffD}d ago`;
} catch {
return iso;
}
}
function sourceLabel(source: 'default' | 'env' | 'explicit-codex-home', t: TFunction): string {
switch (source) {
case 'default':
return t('codex.auth.sourceDefault');
case 'env':
return t('codex.auth.sourceEnv');
case 'explicit-codex-home':
return t('codex.auth.sourceExplicitCodexHome');
}
}
// ── Disabled action button with terminal-redirect tooltip ───────────────────
function TerminalOnlyButton({ label }: { label: string }) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{/* span wrapper needed — disabled buttons don't trigger mouse events */}
<span tabIndex={0} className="inline-block">
<Button variant="outline" size="sm" disabled className="pointer-events-none">
{label}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<Trans
i18nKey="codex.auth.terminalOnlyTooltipRich"
components={{ code: <InlineCode /> }}
/>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
// ── Profile table row ────────────────────────────────────────────────────────
function ProfileRow({
entry,
isActive,
activeSource,
}: {
entry: CodexAuthProfileEntry;
isActive: boolean;
activeSource?: 'default' | 'env' | 'explicit-codex-home';
}) {
const { t } = useTranslation();
return (
<TableRow className={isActive ? 'bg-muted/40' : undefined}>
<TableCell className="font-medium">
<span className="flex items-center gap-2">
{entry.name}
{isActive && activeSource && (
<Badge variant="secondary" className="text-xs">
{t('codex.auth.activeSourceBadge', { source: sourceLabel(activeSource, t) })}
</Badge>
)}
</span>
</TableCell>
<TableCell>{entry.email ?? '—'}</TableCell>
<TableCell>{entry.plan ?? '—'}</TableCell>
<TableCell>{formatLastUsed(entry.lastUsed)}</TableCell>
<TableCell>
{entry.authValid ? (
<Badge variant="secondary" className="text-xs text-green-700 dark:text-green-400">
{t('codex.auth.statusOk')}
</Badge>
) : (
<Badge variant="destructive" className="text-xs">
{t('codex.auth.statusInvalid')}
</Badge>
)}
</TableCell>
<TableCell>
<span className="flex gap-1">
<TerminalOnlyButton label={t('codex.auth.switchAction')} />
<TerminalOnlyButton label={t('codex.auth.removeAction')} />
</span>
</TableCell>
</TableRow>
);
}
// ── Main card ────────────────────────────────────────────────────────────────
export function CodexAuthProfilesCard() {
const { t } = useTranslation();
const { data, isLoading, error } = useCodexAuthProfiles();
if (isLoading) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground p-4">
<Loader2 className="h-4 w-4 animate-spin" />
{t('codex.auth.loading')}
</div>
);
}
if (error || !data) {
return (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{t('codex.auth.loadError')}
</div>
);
}
// Empty registry — no profiles at all
if (data.profiles.length === 0) {
return (
<div className="rounded-md border bg-muted/30 px-4 py-3 text-sm text-muted-foreground space-y-1">
<p>
<Trans i18nKey="codex.auth.emptyRegistryRich" components={{ code: <InlineCode /> }} />
</p>
<p>
<Trans i18nKey="codex.auth.legacyCodexHomeRich" components={{ code: <InlineCode /> }} />
</p>
</div>
);
}
// Legacy mode — profiles exist but none active
if (!data.active) {
return (
<div className="space-y-3">
<div className="rounded-md border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
<Trans i18nKey="codex.auth.legacyModeRich" components={{ code: <InlineCode /> }} />
</div>
<ProfileTable data={data} />
</div>
);
}
// External CODEX_HOME with no registry match
if (data.active.source === 'explicit-codex-home' && data.active.name === null) {
return (
<div className="space-y-3">
<div className="rounded-md border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
<Trans
i18nKey="codex.auth.externalCodexHomeRich"
values={{ path: data.active.codexHome }}
components={{ code: <InlineCode /> }}
/>
</div>
<ProfileTable data={data} />
</div>
);
}
return (
<div className="space-y-3">
<ActiveBanner name={data.active.name} source={data.active.source} profiles={data.profiles} />
<ProfileTable data={data} />
</div>
);
}
// ── Active profile highlight banner ─────────────────────────────────────────
function ActiveBanner({
name,
source,
profiles,
}: {
name: string | null;
source: 'default' | 'env' | 'explicit-codex-home';
profiles: CodexAuthProfileEntry[];
}) {
const { t } = useTranslation();
const activeEntry = profiles.find((p) => p.name === name);
return (
<div className="rounded-md border bg-muted/20 px-4 py-3 text-sm space-y-1">
<div className="flex items-center gap-2 font-medium">
{t('codex.auth.activeProfile')}
<span>{name ?? t('codex.auth.unknownProfile')}</span>
<Badge variant="secondary" className="text-xs">
{sourceLabel(source, t)}
</Badge>
</div>
{activeEntry && (
<div className="text-muted-foreground text-xs space-x-3">
{activeEntry.email && <span>{activeEntry.email}</span>}
{activeEntry.plan && (
<span>
{t('codex.auth.planLabel')} <strong>{activeEntry.plan}</strong>
</span>
)}
{!activeEntry.authValid && (
<span className="text-destructive">{t('codex.auth.statusInvalid')}</span>
)}
</div>
)}
</div>
);
}
// ── Profile table ────────────────────────────────────────────────────────────
function ProfileTable({
data,
}: {
data: {
active: { name: string | null; source: 'default' | 'env' | 'explicit-codex-home' } | null;
profiles: CodexAuthProfileEntry[];
};
}) {
const { t } = useTranslation();
return (
<div className="rounded-md border overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('codex.auth.col.name')}</TableHead>
<TableHead>{t('codex.auth.col.email')}</TableHead>
<TableHead>{t('codex.auth.col.plan')}</TableHead>
<TableHead>{t('codex.auth.col.lastUsed')}</TableHead>
<TableHead>{t('codex.auth.col.status')}</TableHead>
<TableHead>{t('codex.auth.col.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.profiles.map((entry) => (
<ProfileRow
key={entry.name}
entry={entry}
isActive={data.active?.name === entry.name}
activeSource={data.active?.name === entry.name ? data.active.source : undefined}
/>
))}
</TableBody>
</Table>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
/**
* React hook for fetching codex-auth profile summary from
* GET /api/codex/profiles. Returns the active profile, default,
* and per-profile list with decoded identity fields.
*
* Mirrors the useCodex pattern (use-codex.ts:70) with a 15s refetch
* interval — dashboard polls are low-frequency; the server-side 5s
* cache absorbs bursts.
*/
import { useQuery } from '@tanstack/react-query';
import { withApiBase } from '@/lib/api-client';
export interface CodexAuthProfileEntry {
name: string;
codexHome: string;
email: string | null;
plan: string | null;
/** accountId returned by API but not displayed in UI per D6. */
accountId: string | null;
lastUsed: string | null;
authValid: boolean;
}
export interface CodexAuthActiveProfile {
name: string | null;
source: 'default' | 'env' | 'explicit-codex-home';
codexHome: string;
}
export interface CodexAuthProfilesResponse {
active: CodexAuthActiveProfile | null;
default: string | null;
profiles: CodexAuthProfileEntry[];
}
async function fetchCodexAuthProfiles(): Promise<CodexAuthProfilesResponse> {
const res = await fetch(withApiBase('/codex/profiles'));
if (!res.ok) {
throw new Error('Failed to fetch Codex auth profiles');
}
return res.json() as Promise<CodexAuthProfilesResponse>;
}
export function useCodexAuthProfiles() {
return useQuery({
queryKey: ['codex-auth-profiles'],
queryFn: fetchCodexAuthProfiles,
refetchInterval: 15000,
});
}
+205
View File
@@ -2175,6 +2175,46 @@ const resources = {
featureAppsDesc: 'Enable ChatGPT Apps and connectors support.',
featureSmartApprovalsLabel: 'Smart approvals',
featureSmartApprovalsDesc: 'Route eligible approvals through the guardian flow.',
auth: {
terminalOnlyTooltip:
'Use ccsx auth switch <name> or ccsx auth remove <name> in terminal.',
terminalOnlyTooltipRich:
'Use <code>ccsx auth switch &lt;name&gt;</code> or <code>ccsx auth remove &lt;name&gt;</code> in terminal.',
activeSourceBadge: '{{source}}',
statusOk: 'OK',
statusInvalid: '[!] auth invalid',
loading: 'Loading auth profiles...',
loadError: '[!] Failed to load codex-auth profiles.',
emptyRegistry: '[i] No codex-auth profiles. Run ccsx auth create <name> to create one.',
emptyRegistryRich:
'[i] No codex-auth profiles. Run <code>ccsx auth create &lt;name&gt;</code> to create one.',
legacyCodexHome: 'Codex will use the default ~/.codex location.',
legacyCodexHomeRich: 'Codex will use the default <code>~/.codex</code> location.',
legacyMode:
'[i] No active profile. Using ~/.codex (legacy). Run ccsx auth switch <name> in terminal to activate one.',
legacyModeRich:
'[i] No active profile. Using <code>~/.codex</code> (legacy). Run <code>ccsx auth switch &lt;name&gt;</code> in terminal to activate one.',
externalCodexHome:
'[i] $CODEX_HOME set externally to {{path}}. Profile registry not in use for this session.',
externalCodexHomeRich:
'[i] <code>$CODEX_HOME</code> set externally to <code>{{path}}</code>. Profile registry not in use for this session.',
activeProfile: 'Active profile:',
unknownProfile: '(unknown)',
planLabel: 'Plan:',
switchAction: 'Switch',
removeAction: 'Remove',
sourceDefault: 'default',
sourceEnv: '$CCS_CODEX_PROFILE',
sourceExplicitCodexHome: '$CODEX_HOME',
col: {
name: 'Name',
email: 'Email',
plan: 'Plan',
lastUsed: 'Last used',
status: 'Status',
actions: 'Actions',
},
},
},
droidSettings: {
quickControls: 'Quick Controls',
@@ -2628,6 +2668,7 @@ const resources = {
controlCenter: 'Control Center',
overview: 'Overview',
docs: 'Docs',
authProfiles: 'Auth Profiles',
nativeRuntime: 'Native Runtime',
ccsProvider: 'CCS Provider',
setup: 'Setup',
@@ -4739,6 +4780,44 @@ const resources = {
yes: '是',
no: '否',
warningsTitle: '警告',
auth: {
terminalOnlyTooltip: '在终端使用 ccsx auth switch <name> 或 ccsx auth remove <name>。',
terminalOnlyTooltipRich:
'在终端使用 <code>ccsx auth switch &lt;name&gt;</code> 或 <code>ccsx auth remove &lt;name&gt;</code>。',
activeSourceBadge: '{{source}}',
statusOk: '正常',
statusInvalid: '[!] 认证无效',
loading: '正在加载认证配置...',
loadError: '[!] 加载 codex-auth 配置失败。',
emptyRegistry: '[i] 没有 codex-auth 配置。运行 ccsx auth create <name> 创建一个。',
emptyRegistryRich:
'[i] 没有 codex-auth 配置。运行 <code>ccsx auth create &lt;name&gt;</code> 创建一个。',
legacyCodexHome: 'Codex 将使用默认 ~/.codex 位置。',
legacyCodexHomeRich: 'Codex 将使用默认 <code>~/.codex</code> 位置。',
legacyMode:
'[i] 没有活动配置。正在使用 ~/.codex(旧模式)。在终端运行 ccsx auth switch <name> 激活一个。',
legacyModeRich:
'[i] 没有活动配置。正在使用 <code>~/.codex</code>(旧模式)。在终端运行 <code>ccsx auth switch &lt;name&gt;</code> 激活一个。',
externalCodexHome: '[i] $CODEX_HOME 外部设置为 {{path}}。本会话未使用配置注册表。',
externalCodexHomeRich:
'[i] <code>$CODEX_HOME</code> 外部设置为 <code>{{path}}</code>。本会话未使用配置注册表。',
activeProfile: '活动配置:',
unknownProfile: '(未知)',
planLabel: '套餐:',
switchAction: '切换',
removeAction: '移除',
sourceDefault: '默认',
sourceEnv: '$CCS_CODEX_PROFILE',
sourceExplicitCodexHome: '$CODEX_HOME',
col: {
name: '名称',
email: '邮箱',
plan: '套餐',
lastUsed: '上次使用',
status: '状态',
actions: '操作',
},
},
},
droidSettings: {
quickControls: '快捷控制',
@@ -5168,6 +5247,7 @@ const resources = {
controlCenter: '控制中心',
overview: '概览',
docs: '文档',
authProfiles: '认证配置',
nativeRuntime: '原生运行时',
ccsProvider: 'CCS 提供商',
setup: '安装',
@@ -7389,6 +7469,46 @@ const resources = {
yes: 'Có',
no: 'Không',
warningsTitle: 'Cảnh báo',
auth: {
terminalOnlyTooltip:
'Dùng ccsx auth switch <name> hoặc ccsx auth remove <name> trong terminal.',
terminalOnlyTooltipRich:
'Dùng <code>ccsx auth switch &lt;name&gt;</code> hoặc <code>ccsx auth remove &lt;name&gt;</code> trong terminal.',
activeSourceBadge: '{{source}}',
statusOk: 'OK',
statusInvalid: '[!] auth không hợp lệ',
loading: 'Đang tải hồ sơ auth...',
loadError: '[!] Không tải được hồ sơ codex-auth.',
emptyRegistry: '[i] Chưa có hồ sơ codex-auth. Chạy ccsx auth create <name> để tạo.',
emptyRegistryRich:
'[i] Chưa có hồ sơ codex-auth. Chạy <code>ccsx auth create &lt;name&gt;</code> để tạo.',
legacyCodexHome: 'Codex sẽ dùng vị trí mặc định ~/.codex.',
legacyCodexHomeRich: 'Codex sẽ dùng vị trí mặc định <code>~/.codex</code>.',
legacyMode:
'[i] Chưa có hồ sơ active. Đang dùng ~/.codex (legacy). Chạy ccsx auth switch <name> trong terminal để kích hoạt.',
legacyModeRich:
'[i] Chưa có hồ sơ active. Đang dùng <code>~/.codex</code> (legacy). Chạy <code>ccsx auth switch &lt;name&gt;</code> trong terminal để kích hoạt.',
externalCodexHome:
'[i] $CODEX_HOME được đặt bên ngoài là {{path}}. Registry hồ sơ không dùng trong phiên này.',
externalCodexHomeRich:
'[i] <code>$CODEX_HOME</code> được đặt bên ngoài là <code>{{path}}</code>. Registry hồ sơ không dùng trong phiên này.',
activeProfile: 'Hồ sơ active:',
unknownProfile: '(không rõ)',
planLabel: 'Gói:',
switchAction: 'Chuyển',
removeAction: 'Xóa',
sourceDefault: 'mặc định',
sourceEnv: '$CCS_CODEX_PROFILE',
sourceExplicitCodexHome: '$CODEX_HOME',
col: {
name: 'Tên',
email: 'Email',
plan: 'Gói',
lastUsed: 'Dùng lần cuối',
status: 'Trạng thái',
actions: 'Thao tác',
},
},
},
droidSettings: {
quickControls: 'Điều khiển nhanh',
@@ -7830,6 +7950,7 @@ const resources = {
controlCenter: 'Trung tâm điều khiển',
overview: 'Tổng quan',
docs: 'Tài liệu',
authProfiles: 'Hồ sơ auth',
nativeRuntime: 'Runtime gốc',
ccsProvider: 'CCS Provider',
setup: 'Thiết lập',
@@ -9765,12 +9886,54 @@ const resources = {
yes: 'はい',
no: 'いいえ',
warningsTitle: '警告',
auth: {
terminalOnlyTooltip:
'ターミナルで ccsx auth switch <name> または ccsx auth remove <name> を使用します。',
terminalOnlyTooltipRich:
'ターミナルで <code>ccsx auth switch &lt;name&gt;</code> または <code>ccsx auth remove &lt;name&gt;</code> を使用します。',
activeSourceBadge: '{{source}}',
statusOk: 'OK',
statusInvalid: '[!] 認証が無効',
loading: '認証プロファイルを読み込み中...',
loadError: '[!] codex-auth プロファイルの読み込みに失敗しました。',
emptyRegistry:
'[i] codex-auth プロファイルがありません。ccsx auth create <name> を実行して作成します。',
emptyRegistryRich:
'[i] codex-auth プロファイルがありません。<code>ccsx auth create &lt;name&gt;</code> を実行して作成します。',
legacyCodexHome: 'Codex はデフォルトの ~/.codex を使用します。',
legacyCodexHomeRich: 'Codex はデフォルトの <code>~/.codex</code> を使用します。',
legacyMode:
'[i] アクティブなプロファイルがありません。~/.codex(レガシー)を使用中です。ターミナルで ccsx auth switch <name> を実行して有効化します。',
legacyModeRich:
'[i] アクティブなプロファイルがありません。<code>~/.codex</code>(レガシー)を使用中です。ターミナルで <code>ccsx auth switch &lt;name&gt;</code> を実行して有効化します。',
externalCodexHome:
'[i] $CODEX_HOME は外部で {{path}} に設定されています。このセッションではプロファイル registry は使われません。',
externalCodexHomeRich:
'[i] <code>$CODEX_HOME</code> は外部で <code>{{path}}</code> に設定されています。このセッションではプロファイル registry は使われません。',
activeProfile: 'アクティブプロファイル:',
unknownProfile: '(不明)',
planLabel: 'プラン:',
switchAction: '切り替え',
removeAction: '削除',
sourceDefault: 'デフォルト',
sourceEnv: '$CCS_CODEX_PROFILE',
sourceExplicitCodexHome: '$CODEX_HOME',
col: {
name: '名前',
email: 'メール',
plan: 'プラン',
lastUsed: '最終使用',
status: 'ステータス',
actions: '操作',
},
},
},
codexPage: {
title: 'Codex',
controlCenter: 'コントロールセンター',
overview: '概要',
docs: 'ドキュメント',
authProfiles: '認証プロファイル',
nativeRuntime: 'ネイティブランタイム',
ccsProvider: 'CCS プロバイダー',
setup: 'セットアップ',
@@ -12735,6 +12898,47 @@ const resources = {
featureAppsDesc: 'ChatGPT 앱 및 커넥터 지원을 활성화합니다.',
featureSmartApprovalsLabel: '스마트 승인',
featureSmartApprovalsDesc: '가디언 흐름을 통해 적격 승인을 라우팅합니다.',
auth: {
terminalOnlyTooltip:
'터미널에서 ccsx auth switch <name> 또는 ccsx auth remove <name>을 사용하세요.',
terminalOnlyTooltipRich:
'터미널에서 <code>ccsx auth switch &lt;name&gt;</code> 또는 <code>ccsx auth remove &lt;name&gt;</code>을 사용하세요.',
activeSourceBadge: '{{source}}',
statusOk: 'OK',
statusInvalid: '[!] 인증이 유효하지 않음',
loading: '인증 프로필 로드 중...',
loadError: '[!] codex-auth 프로필을 로드하지 못했습니다.',
emptyRegistry:
'[i] codex-auth 프로필이 없습니다. ccsx auth create <name>을 실행해 생성하세요.',
emptyRegistryRich:
'[i] codex-auth 프로필이 없습니다. <code>ccsx auth create &lt;name&gt;</code>을 실행해 생성하세요.',
legacyCodexHome: 'Codex는 기본 ~/.codex 위치를 사용합니다.',
legacyCodexHomeRich: 'Codex는 기본 <code>~/.codex</code> 위치를 사용합니다.',
legacyMode:
'[i] 활성 프로필이 없습니다. ~/.codex(레거시)를 사용 중입니다. 터미널에서 ccsx auth switch <name>을 실행해 활성화하세요.',
legacyModeRich:
'[i] 활성 프로필이 없습니다. <code>~/.codex</code>(레거시)를 사용 중입니다. 터미널에서 <code>ccsx auth switch &lt;name&gt;</code>을 실행해 활성화하세요.',
externalCodexHome:
'[i] $CODEX_HOME이 외부에서 {{path}}로 설정되었습니다. 이 세션에서는 프로필 registry를 사용하지 않습니다.',
externalCodexHomeRich:
'[i] <code>$CODEX_HOME</code>이 외부에서 <code>{{path}}</code>로 설정되었습니다. 이 세션에서는 프로필 registry를 사용하지 않습니다.',
activeProfile: '활성 프로필:',
unknownProfile: '(알 수 없음)',
planLabel: '플랜:',
switchAction: '전환',
removeAction: '제거',
sourceDefault: '기본값',
sourceEnv: '$CCS_CODEX_PROFILE',
sourceExplicitCodexHome: '$CODEX_HOME',
col: {
name: '이름',
email: '이메일',
plan: '플랜',
lastUsed: '마지막 사용',
status: '상태',
actions: '작업',
},
},
},
droidSettings: {
quickControls: '빠른 제어',
@@ -13190,6 +13394,7 @@ const resources = {
controlCenter: '제어 센터',
overview: '개요',
docs: '문서',
authProfiles: '인증 프로필',
nativeRuntime: '네이티브 런타임',
ccsProvider: 'CCS 프로바이더',
setup: '설정',
+9 -1
View File
@@ -3,6 +3,7 @@ import { toast } from 'sonner';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { GripVertical, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { CodexAuthProfilesCard } from '@/components/compatible-cli/codex-auth-profiles-card';
import { CodexControlCenterTab } from '@/components/compatible-cli/codex-control-center-tab';
import { CodexDocsTab } from '@/components/compatible-cli/codex-docs-tab';
import { useCodex } from '@/hooks/use-codex';
@@ -178,10 +179,11 @@ export function CodexPage() {
return (
<Tabs defaultValue="overview" className="flex h-full flex-col">
<div className="shrink-0 px-4 pt-4">
<TabsList className="grid w-full grid-cols-3">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="overview">{t('codexPage.overview')}</TabsTrigger>
<TabsTrigger value="controls">{t('codexPage.controlCenter')}</TabsTrigger>
<TabsTrigger value="docs">{t('codexPage.docs')}</TabsTrigger>
<TabsTrigger value="auth-profiles">{t('codexPage.authProfiles')}</TabsTrigger>
</TabsList>
</div>
@@ -211,6 +213,12 @@ export function CodexPage() {
<TabsContent value="docs" className={tabContentClassName}>
<CodexDocsTab diagnostics={diagnostics} />
</TabsContent>
<TabsContent value="auth-profiles" className={tabContentClassName}>
<div className="h-full overflow-auto p-1">
<CodexAuthProfilesCard />
</div>
</TabsContent>
</div>
</Tabs>
);
+58
View File
@@ -0,0 +1,58 @@
import { afterAll, describe, expect, it } from 'vitest';
import i18n from '@/lib/i18n';
const locales = ['en', 'zh-CN', 'vi', 'ja', 'ko'] as const;
const codexAuthKeys = [
['codex.auth.sourceDefault'],
['codex.auth.sourceEnv'],
['codex.auth.sourceExplicitCodexHome'],
['codex.auth.terminalOnlyTooltipRich'],
['codex.auth.activeSourceBadge', { source: 'default' }],
['codex.auth.statusOk'],
['codex.auth.statusInvalid'],
['codex.auth.loading'],
['codex.auth.loadError'],
['codex.auth.emptyRegistryRich'],
['codex.auth.legacyCodexHomeRich'],
['codex.auth.legacyModeRich'],
['codex.auth.externalCodexHomeRich', { path: '/tmp/codex-home' }],
['codex.auth.activeProfile'],
['codex.auth.unknownProfile'],
['codex.auth.planLabel'],
['codex.auth.switchAction'],
['codex.auth.removeAction'],
['codex.auth.col.name'],
['codex.auth.col.email'],
['codex.auth.col.plan'],
['codex.auth.col.lastUsed'],
['codex.auth.col.status'],
['codex.auth.col.actions'],
['codexPage.authProfiles'],
] as const;
const originalLanguage = i18n.language;
afterAll(async () => {
await i18n.changeLanguage(originalLanguage);
});
describe('codex auth i18n', () => {
it.each(locales)('resolves codex auth dashboard keys for %s', async (locale) => {
await i18n.changeLanguage(locale);
for (const [key, options] of codexAuthKeys) {
const translated = i18n.t(key, options);
expect(translated).not.toBe(key);
expect(translated).not.toContain('codex.auth.');
expect(translated).not.toContain('codexPage.');
if (key === 'codex.auth.externalCodexHomeRich') {
expect(translated).toContain('/tmp/codex-home');
}
if (key === 'codex.auth.activeSourceBadge') {
expect(translated).toContain('default');
}
}
});
});