mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 16:17:02 +00:00
Merge remote-tracking branch 'origin/dev' into codex/fix-cliproxy-v3-snapshot-migration-issue
# Conflicts: # src/web-server/usage/cliproxy-usage-transformer.ts # tests/unit/web-server/cliproxy-usage-transformer.test.ts
This commit is contained in:
commit
ced9317565
210 files changed
+5627
-1065
No files matched your search
@@ -106,8 +106,9 @@ assert_case "skips at the larger configured window boundary" 0 false 3 \
|
||||
assert_failure "rejects prerelease target tags" \
|
||||
--target "v7.80.0-rc.1" --baseline "7.80.0" --window "2"
|
||||
|
||||
assert_failure "fails loudly when baseline tag is unavailable after baseline" \
|
||||
--target "v7.81.2" --baseline "7.81.0" --window "2"
|
||||
assert_case "skips deprecated publish when baseline tag is unavailable after baseline" 0 false 2 \
|
||||
"v7.81.2" "7.81.0" "2" \
|
||||
$'v7.80.0'
|
||||
|
||||
echo ""
|
||||
echo "Dashboard sunset guard tests complete: ${PASS} passed, ${FAIL} failed."
|
||||
|
||||
@@ -159,6 +159,54 @@ MOCK_EOF
|
||||
chmod +x "${MOCK_DIR}/docker"
|
||||
}
|
||||
|
||||
make_mock_docker_platform_raw_index() {
|
||||
# First inspect returns a multi-arch index; digest inspect returns the
|
||||
# platform manifest with real layer sizes. This mirrors GHCR OCI output.
|
||||
cat > "${MOCK_DIR}/docker" <<'MOCK_EOF'
|
||||
#!/usr/bin/env bash
|
||||
if [[ "$1" == "buildx" && "$2" == "imagetools" && "$3" == "inspect" ]]; then
|
||||
ref="$4"
|
||||
if [[ "$ref" == "mock-image:tag" ]]; then
|
||||
cat <<'JSON'
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.index.v1+json",
|
||||
"manifests": [
|
||||
{
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"digest": "sha256:amd64digest",
|
||||
"platform": { "os": "linux", "architecture": "amd64" }
|
||||
},
|
||||
{
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"digest": "sha256:arm64digest",
|
||||
"platform": { "os": "linux", "architecture": "arm64" }
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$ref" == "mock-image:tag@sha256:amd64digest" ]]; then
|
||||
cat <<'JSON'
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"layers": [
|
||||
{ "size": 100000000 },
|
||||
{ "size": 57671680 }
|
||||
]
|
||||
}
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
MOCK_EOF
|
||||
chmod +x "${MOCK_DIR}/docker"
|
||||
}
|
||||
|
||||
run_platform_test() {
|
||||
local name="$1"
|
||||
local expected_exit="$2"
|
||||
@@ -190,6 +238,10 @@ run_platform_test "--platform: pass when platform-scoped size < budget" 0 "20971
|
||||
make_mock_docker_platform "150000000 112000000"
|
||||
run_platform_test "--platform: fail when platform-scoped size > budget" 1 "209715200"
|
||||
|
||||
# --platform raw OCI index: resolve the platform digest then sum manifest layers
|
||||
make_mock_docker_platform_raw_index
|
||||
run_platform_test "--platform: resolves raw OCI index before summing layers" 0 "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"
|
||||
|
||||
+72
-12
@@ -50,27 +50,87 @@ fi
|
||||
|
||||
MAX_MB=$(( MAX_BYTES / 1048576 ))
|
||||
|
||||
sum_manifest_layer_sizes() {
|
||||
local sum
|
||||
sum="$(jq -sr '
|
||||
.[0] as $manifest
|
||||
| if ($manifest | type) == "object" and ($manifest.layers | type) == "array" then
|
||||
([$manifest.layers[]?.size | numbers] | add) // 0
|
||||
else
|
||||
0
|
||||
end
|
||||
' 2>/dev/null)" || {
|
||||
echo "0"
|
||||
return
|
||||
}
|
||||
echo "${sum:-0}"
|
||||
}
|
||||
|
||||
select_platform_digest() {
|
||||
local os="$1"
|
||||
local arch="$2"
|
||||
local variant="$3"
|
||||
|
||||
jq -r \
|
||||
--arg os "$os" \
|
||||
--arg arch "$arch" \
|
||||
--arg variant "$variant" \
|
||||
'
|
||||
.manifests[]?
|
||||
| select((.platform.os // "") == $os)
|
||||
| select((.platform.architecture // "") == $arch)
|
||||
| select($variant == "" or (.platform.variant // "") == $variant)
|
||||
| .digest
|
||||
' 2>/dev/null | head -1 || true
|
||||
}
|
||||
|
||||
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.
|
||||
# Read raw OCI/Docker manifests directly. Multi-arch tags first resolve the
|
||||
# requested platform digest from the index, then sum that manifest's layer
|
||||
# sizes. A single-platform tag can be summed immediately from its raw manifest.
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "[X] jq is required for platform-scoped image size inspection" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$PLATFORM" != */* ]]; then
|
||||
echo "[X] --platform must use os/arch format, got: ${PLATFORM}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PLATFORM_OS="${PLATFORM%%/*}"
|
||||
PLATFORM_REST="${PLATFORM#*/}"
|
||||
PLATFORM_ARCH="${PLATFORM_REST%%/*}"
|
||||
PLATFORM_VARIANT=""
|
||||
if [[ "$PLATFORM_REST" == */* ]]; then
|
||||
PLATFORM_VARIANT="${PLATFORM_REST#*/}"
|
||||
fi
|
||||
|
||||
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 ""
|
||||
)
|
||||
RAW_MANIFEST="$(docker buildx imagetools inspect "${IMAGE}" --raw 2>/dev/null || true)"
|
||||
ACTUAL_BYTES="$(printf '%s' "$RAW_MANIFEST" | sum_manifest_layer_sizes)"
|
||||
|
||||
if [[ -z "$ACTUAL_BYTES" || "$ACTUAL_BYTES" == "0" ]]; then
|
||||
# Fallback: try the platform-specific sub-manifest
|
||||
PLATFORM_DIGEST="$(printf '%s' "$RAW_MANIFEST" | select_platform_digest "$PLATFORM_OS" "$PLATFORM_ARCH" "$PLATFORM_VARIANT")"
|
||||
if [[ -n "$PLATFORM_DIGEST" && "$PLATFORM_DIGEST" != "null" ]]; then
|
||||
# Fallback: inspect the selected platform sub-manifest.
|
||||
echo "[i] Falling back to platform-scoped manifest ${PLATFORM_DIGEST}..." >&2
|
||||
RAW_PLATFORM_MANIFEST="$(docker buildx imagetools inspect "${IMAGE}@${PLATFORM_DIGEST}" --raw 2>/dev/null || true)"
|
||||
ACTUAL_BYTES="$(printf '%s' "$RAW_PLATFORM_MANIFEST" | sum_manifest_layer_sizes)"
|
||||
else
|
||||
ACTUAL_BYTES="0"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$ACTUAL_BYTES" || "$ACTUAL_BYTES" == "0" ]]; then
|
||||
# Last resort: support older buildx versions that expose layer sizes only
|
||||
# through the templated manifest object.
|
||||
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 }}" \
|
||||
--format "{{ range .Manifest.Manifests }}{{ if eq .Platform.OS \"${PLATFORM_OS}\" }}{{ if eq .Platform.Architecture \"${PLATFORM_ARCH}\" }}{{ .Digest }}{{ end }}{{ end }}{{ end }}" \
|
||||
2>/dev/null | head -1
|
||||
)" --format "{{ range .Manifest.Layers }}{{ .Size }} {{ end }}" 2>/dev/null \
|
||||
| tr ' ' '\n' \
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# Unit test for network-contract.sh Docker Compose env and args.
|
||||
# Uses a fake docker binary; no Docker daemon required.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SCRIPT="${SCRIPT_DIR}/network-contract.sh"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
MOCK_DIR="$(mktemp -d)"
|
||||
LOG_FILE="${MOCK_DIR}/docker.log"
|
||||
COMPOSE_FILE="${MOCK_DIR}/compose.yaml"
|
||||
trap 'rm -rf "$MOCK_DIR"' EXIT
|
||||
|
||||
cat > "$COMPOSE_FILE" <<'YAML'
|
||||
services:
|
||||
ccs:
|
||||
image: ${CCS_IMAGE:-ghcr.io/kaitranntt/ccs:latest}
|
||||
YAML
|
||||
|
||||
cat > "${MOCK_DIR}/docker" <<'MOCK'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
printf 'cmd=%s env_image=%s env_dashboard=%s env_cliproxy=%s args=%s\n' \
|
||||
"$1" "${CCS_IMAGE:-}" "${CCS_DASHBOARD_PORT:-}" "${CCS_CLIPROXY_PORT:-}" "$*" \
|
||||
>> "$DOCKER_MOCK_LOG"
|
||||
|
||||
if [[ "$1" == "compose" ]]; then
|
||||
if printf '%s\n' "$*" | grep -q 'ps --format json'; then
|
||||
printf '[{"Service":"ccs","Health":"healthy"}]\n'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$1" == "network" && "$2" == "inspect" && "$3" == "ccs-net" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$1" == "run" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit 1
|
||||
MOCK
|
||||
chmod +x "${MOCK_DIR}/docker"
|
||||
|
||||
run_test() {
|
||||
local name="$1"
|
||||
shift
|
||||
|
||||
if "$@"; then
|
||||
echo "[OK] ${name}"
|
||||
(( PASS++ )) || true
|
||||
else
|
||||
echo "[X] ${name}"
|
||||
(( FAIL++ )) || true
|
||||
fi
|
||||
}
|
||||
|
||||
run_contract() {
|
||||
PATH="${MOCK_DIR}:${PATH}" \
|
||||
DOCKER_MOCK_LOG="$LOG_FILE" \
|
||||
bash "$SCRIPT" "$COMPOSE_FILE" "ghcr.io/kaitranntt/ccs:test" >/dev/null
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "Running network-contract.sh env tests..."
|
||||
echo ""
|
||||
|
||||
run_contract
|
||||
|
||||
run_test "compose up receives safe host ports and image override" \
|
||||
grep -q 'env_image=ghcr.io/kaitranntt/ccs:test env_dashboard=13001 env_cliproxy=18318 args=compose -f .* up -d --remove-orphans' "$LOG_FILE"
|
||||
|
||||
run_test "compose ps receives the same safe host ports" \
|
||||
grep -q 'env_image=ghcr.io/kaitranntt/ccs:test env_dashboard=13001 env_cliproxy=18318 args=compose -f .* ps --format json' "$LOG_FILE"
|
||||
|
||||
run_test "compose down removes volumes and orphans through cleanup trap" \
|
||||
grep -q 'env_image=ghcr.io/kaitranntt/ccs:test env_dashboard=13001 env_cliproxy=18318 args=compose -f .* down -v --remove-orphans' "$LOG_FILE"
|
||||
|
||||
run_test "sibling probes keep the ccs-net DNS contract" \
|
||||
grep -q 'args=run --rm --network ccs-net curlimages/curl:latest -fsS --max-time 10 http://ccs:8317/' "$LOG_FILE"
|
||||
|
||||
run_test "dashboard sibling probe keeps internal port 3000" \
|
||||
grep -q 'args=run --rm --network ccs-net curlimages/curl:latest -fsS --max-time 10 http://ccs:3000/' "$LOG_FILE"
|
||||
|
||||
echo ""
|
||||
echo "Results: ${PASS} passed, ${FAIL} failed"
|
||||
echo ""
|
||||
|
||||
if [[ "$FAIL" -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -19,6 +19,8 @@ set -euo pipefail
|
||||
|
||||
COMPOSE_FILE="${1:-docker/compose.yaml}"
|
||||
IMAGE_OVERRIDE="${2:-}"
|
||||
DASHBOARD_HOST_PORT="${CCS_NETWORK_CONTRACT_DASHBOARD_PORT:-${CCS_DASHBOARD_PORT:-13001}}"
|
||||
CLIPROXY_HOST_PORT="${CCS_NETWORK_CONTRACT_CLIPROXY_PORT:-${CCS_CLIPROXY_PORT:-18318}}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -27,22 +29,35 @@ log() { printf '[i] %s\n' "$*"; }
|
||||
ok() { printf '[OK] %s\n' "$*"; }
|
||||
err() { printf '[X] %s\n' "$*" >&2; }
|
||||
|
||||
compose() {
|
||||
if [[ -n "$IMAGE_OVERRIDE" ]]; then
|
||||
CCS_IMAGE="$IMAGE_OVERRIDE" \
|
||||
CCS_DASHBOARD_PORT="$DASHBOARD_HOST_PORT" \
|
||||
CCS_CLIPROXY_PORT="$CLIPROXY_HOST_PORT" \
|
||||
docker compose -f "$COMPOSE_FILE" "$@"
|
||||
return
|
||||
fi
|
||||
|
||||
CCS_DASHBOARD_PORT="$DASHBOARD_HOST_PORT" \
|
||||
CCS_CLIPROXY_PORT="$CLIPROXY_HOST_PORT" \
|
||||
docker compose -f "$COMPOSE_FILE" "$@"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
log "Tearing down stack..."
|
||||
compose down -v --remove-orphans 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bring stack up; register teardown on any exit
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Bringing CCS stack up: $COMPOSE_FILE"
|
||||
log "Using host ports: dashboard=${DASHBOARD_HOST_PORT}, cliproxy=${CLIPROXY_HOST_PORT}"
|
||||
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
|
||||
compose up -d --remove-orphans
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wait for healthcheck (max 90s) — use jq instead of python3 for CI portability
|
||||
@@ -52,7 +67,7 @@ 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 \
|
||||
compose 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"
|
||||
)
|
||||
|
||||
@@ -31,13 +31,51 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe('cursor daemon lifecycle smoke', () => {
|
||||
it('requires Anthropic caller auth token when credentials are present', async () => {
|
||||
const port = 10000 + Math.floor(Math.random() * 50000);
|
||||
|
||||
saveCredentials({
|
||||
accessToken: 'a'.repeat(60),
|
||||
machineId: '1234567890abcdef1234567890abcdef',
|
||||
authMethod: 'manual',
|
||||
importedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const result = await startDaemon({ port, ghost_mode: true });
|
||||
expect(result.success).toBe(true);
|
||||
const daemonToken = result.daemonToken as string;
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'x-ccs-cursor-token': daemonToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'claude-sonnet-4.5',
|
||||
max_tokens: 64,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
const body = (await response.json()) as {
|
||||
type?: string;
|
||||
error?: { type?: string; message?: string };
|
||||
};
|
||||
expect(body.type).toBe('error');
|
||||
expect(body.error?.type).toBe('authentication_error');
|
||||
expect(body.error?.message).toContain('Invalid Anthropic auth token');
|
||||
});
|
||||
it('starts, serves expected routes, and stops cleanly', async () => {
|
||||
const port = 10000 + Math.floor(Math.random() * 50000);
|
||||
const result = await startDaemon({ port, ghost_mode: true });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.pid).toBeDefined();
|
||||
const daemonToken = result.daemonToken as string;
|
||||
|
||||
expect(await isDaemonRunning(port)).toBe(true);
|
||||
expect(await isDaemonRunning(port, daemonToken)).toBe(true);
|
||||
|
||||
const modelsResponse = await fetch(`http://127.0.0.1:${port}/v1/models`);
|
||||
expect(modelsResponse.status).toBe(200);
|
||||
@@ -47,7 +85,7 @@ describe('cursor daemon lifecycle smoke', () => {
|
||||
|
||||
const chatResponse = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', 'x-ccs-cursor-token': daemonToken },
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4.1',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
@@ -60,6 +98,7 @@ describe('cursor daemon lifecycle smoke', () => {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'x-ccs-cursor-token': daemonToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'claude-sonnet-4.5',
|
||||
@@ -78,15 +117,18 @@ describe('cursor daemon lifecycle smoke', () => {
|
||||
|
||||
const stopResult = await stopDaemon();
|
||||
expect(stopResult.success).toBe(true);
|
||||
expect(await isDaemonRunning(port)).toBe(false);
|
||||
expect(await isDaemonRunning(port, daemonToken)).toBe(false);
|
||||
}, 35000);
|
||||
|
||||
it('returns 404 for unknown routes', async () => {
|
||||
const port = 10000 + Math.floor(Math.random() * 50000);
|
||||
const result = await startDaemon({ port, ghost_mode: true });
|
||||
expect(result.success).toBe(true);
|
||||
const daemonToken = result.daemonToken as string;
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/unknown`);
|
||||
const response = await fetch(`http://127.0.0.1:${port}/unknown`, {
|
||||
headers: { 'x-ccs-cursor-token': daemonToken },
|
||||
});
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
@@ -103,10 +145,11 @@ describe('cursor daemon lifecycle smoke', () => {
|
||||
|
||||
const result = await startDaemon({ port, ghost_mode: true });
|
||||
expect(result.success).toBe(true);
|
||||
const daemonToken = result.daemonToken as string;
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', 'x-ccs-cursor-token': daemonToken },
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4.1',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
@@ -122,17 +165,19 @@ describe('cursor daemon lifecycle smoke', () => {
|
||||
const port = 10000 + Math.floor(Math.random() * 50000);
|
||||
const result = await startDaemon({ port, ghost_mode: true });
|
||||
expect(result.success).toBe(true);
|
||||
const daemonToken = result.daemonToken as string;
|
||||
const tokenHeader = { 'x-ccs-cursor-token': daemonToken };
|
||||
|
||||
const invalidJson = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...tokenHeader },
|
||||
body: '{invalid-json',
|
||||
});
|
||||
expect(invalidJson.status).toBe(400);
|
||||
|
||||
const invalidSchema = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...tokenHeader },
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4.1',
|
||||
messages: { role: 'user', content: 'hello' },
|
||||
@@ -145,6 +190,7 @@ describe('cursor daemon lifecycle smoke', () => {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
...tokenHeader,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'claude-sonnet-4.5',
|
||||
@@ -163,7 +209,7 @@ describe('cursor daemon lifecycle smoke', () => {
|
||||
|
||||
const oversized = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...tokenHeader },
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4.1',
|
||||
messages: [
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as path from 'path';
|
||||
import getPort from 'get-port';
|
||||
import {
|
||||
getOpenAICompatProxyStatus,
|
||||
isOpenAICompatProxyRunning,
|
||||
listOpenAICompatProxyStatuses,
|
||||
startOpenAICompatProxy,
|
||||
stopOpenAICompatProxy,
|
||||
@@ -919,6 +920,62 @@ describe('openai proxy daemon lifecycle', () => {
|
||||
expect(secondStart.authToken).not.toBe('stale-token-a');
|
||||
});
|
||||
|
||||
it('stops pid-only profile daemons instead of only deleting their state', async () => {
|
||||
const port = await getPort();
|
||||
const settingsPath = path.join(tempDir, 'pid-only-stop.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-pid-only-stop',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const profile = resolveOpenAICompatProfileConfig('pid-only-stop', settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-pid-only-stop',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
});
|
||||
if (!profile) {
|
||||
throw new Error('Expected pid-only-stop OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const started = await startOpenAICompatProxy(profile, { port });
|
||||
expect(started.success).toBe(true);
|
||||
expect(started.pid).toBeDefined();
|
||||
|
||||
const pid = started.pid;
|
||||
if (!pid) {
|
||||
throw new Error('Expected pid-only-stop daemon pid');
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(getOpenAICompatProxySessionPath('pid-only-stop'));
|
||||
|
||||
const statuses = await listOpenAICompatProxyStatuses();
|
||||
expect(statuses).toContainEqual({
|
||||
running: false,
|
||||
profileName: 'pid-only-stop',
|
||||
pid,
|
||||
});
|
||||
|
||||
const stopped = await stopOpenAICompatProxy();
|
||||
expect(stopped.success).toBe(true);
|
||||
expect(fs.existsSync(getOpenAICompatProxyPidPath('pid-only-stop'))).toBe(false);
|
||||
expect(await isOpenAICompatProxyRunning(port, 'pid-only-stop')).toBe(false);
|
||||
} finally {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Already stopped.
|
||||
}
|
||||
}
|
||||
}, 35000);
|
||||
|
||||
it('replaces pid-only proxy state before starting a new daemon', async () => {
|
||||
const firstPort = await getPort();
|
||||
const replacementPort = await getPort();
|
||||
|
||||
@@ -206,9 +206,8 @@ describe('GET /api/codex/profiles', () => {
|
||||
// 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 { requireLocalAccessWhenAuthDisabled } =
|
||||
await import('../../../src/web-server/middleware/auth-middleware');
|
||||
const { isDashboardAuthEnabled } = await import('../../../src/config/config-loader-facade');
|
||||
|
||||
if (!isDashboardAuthEnabled()) {
|
||||
@@ -245,6 +244,43 @@ describe('GET /api/codex/profiles', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 403 for loopback remote addresses when host/origin indicate non-local origin', async () => {
|
||||
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;
|
||||
|
||||
const testApp = express();
|
||||
testApp.get('/test', (req, res) => {
|
||||
Object.defineProperty(req, 'socket', {
|
||||
value: { remoteAddress: '127.0.0.1' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
req.headers.host = 'attacker.example.test';
|
||||
req.headers.origin = 'http://attacker.example.test';
|
||||
|
||||
guardResult = requireLocalAccessWhenAuthDisabled(req, res, 'localhost only');
|
||||
if (guardResult) {
|
||||
res.json({ ok: true });
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('profile reader target sanitization', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes legacy stored codex targets back to claude for profiles and variants', async () => {
|
||||
it('normalizes legacy invalid stored targets back to claude for profiles and variants', async () => {
|
||||
const ccsDir = getScopedCcsDir();
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
@@ -64,12 +64,12 @@ describe('profile reader target sanitization', () => {
|
||||
JSON.stringify(
|
||||
{
|
||||
profiles: { demo: '~/.ccs/demo.settings.json' },
|
||||
profile_targets: { demo: 'codex' },
|
||||
profile_targets: { demo: 'glm' },
|
||||
cliproxy: {
|
||||
routed: {
|
||||
provider: 'codex',
|
||||
settings: '~/.ccs/routed.settings.json',
|
||||
target: 'codex',
|
||||
target: 'glm',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -94,7 +94,7 @@ describe('profile reader target sanitization', () => {
|
||||
expect(result.variants[0]?.target).toBe('claude');
|
||||
});
|
||||
|
||||
it('normalizes unified stored codex targets back to claude for profiles and variants', async () => {
|
||||
it('normalizes unified invalid stored targets back to claude for profiles and variants', async () => {
|
||||
const ccsDir = getScopedCcsDir();
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
process.env.CCS_UNIFIED_CONFIG = '1';
|
||||
@@ -106,7 +106,7 @@ describe('profile reader target sanitization', () => {
|
||||
' demo:',
|
||||
' type: api',
|
||||
' settings: ~/.ccs/demo.settings.json',
|
||||
' target: codex',
|
||||
' target: glm',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: []',
|
||||
@@ -114,7 +114,7 @@ describe('profile reader target sanitization', () => {
|
||||
' routed:',
|
||||
' provider: codex',
|
||||
' settings: ~/.ccs/routed.settings.json',
|
||||
' target: codex',
|
||||
' target: glm',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
|
||||
@@ -97,6 +97,7 @@ describe('ProfileDetector', () => {
|
||||
expect(detector.detectProfileType('gitlab').provider).toBe('gitlab');
|
||||
expect(detector.detectProfileType('codebuddy').provider).toBe('codebuddy');
|
||||
expect(detector.detectProfileType('kilo').provider).toBe('kilo');
|
||||
expect(detector.detectProfileType('qoder').provider).toBe('qoder');
|
||||
});
|
||||
|
||||
it('should detect settings-based profile from unified config', () => {
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CCSXP_CLIPROXY_SHORTCUT_ENV } from '../../../src/targets/codex-cliproxy-provider-config';
|
||||
|
||||
const wrapperPath = require.resolve('../../../src/bin/ccsxp-runtime.ts');
|
||||
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 originalCcsCodexProfile = process.env.CCS_CODEX_PROFILE;
|
||||
const originalCcsxpCodexHome = process.env.CCSXP_CODEX_HOME;
|
||||
const originalEntryTarget = process.env.CCS_INTERNAL_ENTRY_TARGET;
|
||||
const originalShortcut = process.env[CCSXP_CLIPROXY_SHORTCUT_ENV];
|
||||
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];
|
||||
@@ -25,6 +27,11 @@ describe('ccsxp runtime wrapper', () => {
|
||||
} else {
|
||||
process.env.CCS_INTERNAL_ENTRY_TARGET = originalEntryTarget;
|
||||
}
|
||||
if (originalShortcut === undefined) {
|
||||
delete process.env[CCSXP_CLIPROXY_SHORTCUT_ENV];
|
||||
} else {
|
||||
process.env[CCSXP_CLIPROXY_SHORTCUT_ENV] = originalShortcut;
|
||||
}
|
||||
if (originalCodexHome === undefined) {
|
||||
delete process.env.CODEX_HOME;
|
||||
} else {
|
||||
|
||||
@@ -143,6 +143,19 @@ describe('ensureSharedConfigSymlink', () => {
|
||||
expect(stderrChunks.join('')).toContain('symlink unavailable');
|
||||
});
|
||||
|
||||
|
||||
it('rethrows symlink errors that are not fallback-safe', () => {
|
||||
fs.writeFileSync(sharedConfigPath, 'model = "gpt-5.5"\n', { mode: 0o600 });
|
||||
const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation(() => {
|
||||
throw Object.assign(new Error('simulated race'), { code: 'EEXIST' });
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => ensureSharedConfigSymlink(profileDir, sharedConfigPath)).toThrow(/simulated race/);
|
||||
} finally {
|
||||
symlinkSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
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');
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* - 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
|
||||
* - process-table Codex 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
|
||||
@@ -54,9 +54,9 @@ beforeEach(() => {
|
||||
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.
|
||||
// Default: process-table lookup finds nothing (no Codex running). Tests that
|
||||
// need a positive result override this per-test. This keeps local developer
|
||||
// processes from affecting import-default tests.
|
||||
spyOn(childProcess, 'spawnSync').mockReturnValue({
|
||||
status: 1,
|
||||
stdout: '',
|
||||
@@ -78,9 +78,8 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
async function makeCtx() {
|
||||
const { CodexProfileRegistry } = await import(
|
||||
'../../../../src/codex-auth/codex-profile-registry'
|
||||
);
|
||||
const { CodexProfileRegistry } =
|
||||
await import('../../../../src/codex-auth/codex-profile-registry');
|
||||
return {
|
||||
registry: new CodexProfileRegistry(),
|
||||
version: '0.0.0-test',
|
||||
@@ -128,21 +127,10 @@ function captureOutput(): { stderr: string[]; restore: () => void } {
|
||||
};
|
||||
}
|
||||
|
||||
function mockProcessTable(pgrepStdout: string, psStdout: string) {
|
||||
function mockProcessTable(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,
|
||||
@@ -171,9 +159,8 @@ function mockProcessTable(pgrepStdout: string, psStdout: string) {
|
||||
|
||||
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 { handleImportDefaultCodex } =
|
||||
await import('../../../../src/codex-auth/commands/import-default-command');
|
||||
const ctx = await makeCtx();
|
||||
|
||||
let exitCalled = false;
|
||||
@@ -201,9 +188,8 @@ 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 { handleImportDefaultCodex } =
|
||||
await import('../../../../src/codex-auth/commands/import-default-command');
|
||||
const ctx = await makeCtx();
|
||||
|
||||
let exitCount = 0;
|
||||
@@ -252,9 +238,8 @@ describe('import-default — profile collision without --force', () => {
|
||||
// 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 { handleImportDefaultCodex } =
|
||||
await import('../../../../src/codex-auth/commands/import-default-command');
|
||||
const ctx = await makeCtx();
|
||||
// Pre-create the profile
|
||||
ctx.registry.createProfile('myprofile');
|
||||
@@ -284,9 +269,8 @@ describe('import-default — --force overwrites and creates backup', () => {
|
||||
// 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 { handleImportDefaultCodex } =
|
||||
await import('../../../../src/codex-auth/commands/import-default-command');
|
||||
const ctx = await makeCtx();
|
||||
|
||||
// First import (no --force needed since profile doesn't exist)
|
||||
@@ -338,9 +322,8 @@ describe('import-default — cliproxy-format rejection', () => {
|
||||
});
|
||||
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), cliproxyAuth);
|
||||
|
||||
const { handleImportDefaultCodex } = await import(
|
||||
'../../../../src/codex-auth/commands/import-default-command'
|
||||
);
|
||||
const { handleImportDefaultCodex } =
|
||||
await import('../../../../src/codex-auth/commands/import-default-command');
|
||||
const ctx = await makeCtx();
|
||||
|
||||
let exitCalled = false;
|
||||
@@ -370,9 +353,8 @@ describe('import-default — torn-write retry', () => {
|
||||
// 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 { 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
|
||||
@@ -397,9 +379,8 @@ describe('import-default — torn-write retry', () => {
|
||||
// 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 { handleImportDefaultCodex } =
|
||||
await import('../../../../src/codex-auth/commands/import-default-command');
|
||||
const ctx = await makeCtx();
|
||||
|
||||
let exitCalled = false;
|
||||
@@ -426,9 +407,8 @@ describe('import-default — torn-write retry', () => {
|
||||
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 { handleImportDefaultCodex } =
|
||||
await import('../../../../src/codex-auth/commands/import-default-command');
|
||||
const ctx = await makeCtx();
|
||||
|
||||
let exitCalled = false;
|
||||
@@ -455,9 +435,8 @@ describe('import-default — torn-write retry', () => {
|
||||
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 { handleImportDefaultCodex } =
|
||||
await import('../../../../src/codex-auth/commands/import-default-command');
|
||||
const ctx = await makeCtx();
|
||||
|
||||
let exitCalled = false;
|
||||
@@ -485,9 +464,8 @@ describe('import-default — torn-write retry', () => {
|
||||
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 { handleImportDefaultCodex } =
|
||||
await import('../../../../src/codex-auth/commands/import-default-command');
|
||||
const ctx = await makeCtx();
|
||||
|
||||
let exitCalled = false;
|
||||
@@ -512,14 +490,13 @@ describe('import-default — torn-write retry', () => {
|
||||
});
|
||||
|
||||
describe('import-default — Codex running detection', () => {
|
||||
it('warns and refuses when pgrep finds a codex PID', async () => {
|
||||
it('warns and refuses when process table finds a same-user Codex PID', async () => {
|
||||
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
|
||||
|
||||
mockProcessTable('12345\n', '12345 /usr/local/bin/codex login\n');
|
||||
mockProcessTable(`12345 ${process.getuid()} /usr/local/bin/codex login\n`);
|
||||
|
||||
const { handleImportDefaultCodex } = await import(
|
||||
'../../../../src/codex-auth/commands/import-default-command'
|
||||
);
|
||||
const { handleImportDefaultCodex } =
|
||||
await import('../../../../src/codex-auth/commands/import-default-command');
|
||||
const ctx = await makeCtx();
|
||||
|
||||
let exitCalled = false;
|
||||
@@ -546,11 +523,10 @@ describe('import-default — Codex running detection', () => {
|
||||
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');
|
||||
mockProcessTable(`12345 ${process.getuid()} /usr/local/bin/codex login\n`);
|
||||
|
||||
const { handleImportDefaultCodex } = await import(
|
||||
'../../../../src/codex-auth/commands/import-default-command'
|
||||
);
|
||||
const { handleImportDefaultCodex } =
|
||||
await import('../../../../src/codex-auth/commands/import-default-command');
|
||||
const ctx = await makeCtx();
|
||||
|
||||
const restore = silenceConsole();
|
||||
@@ -567,11 +543,10 @@ describe('import-default — Codex running detection', () => {
|
||||
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');
|
||||
mockProcessTable(`12345 ${process.getuid()} /usr/bin/node /usr/local/bin/codex login\n`);
|
||||
|
||||
const { handleImportDefaultCodex } = await import(
|
||||
'../../../../src/codex-auth/commands/import-default-command'
|
||||
);
|
||||
const { handleImportDefaultCodex } =
|
||||
await import('../../../../src/codex-auth/commands/import-default-command');
|
||||
const ctx = await makeCtx();
|
||||
|
||||
let exitCalled = false;
|
||||
@@ -595,14 +570,13 @@ describe('import-default — Codex running detection', () => {
|
||||
expect(ctx.registry.hasProfile('nodeshim')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores pgrep false positives that are not Codex executables', async () => {
|
||||
it('ignores process-table 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');
|
||||
mockProcessTable(`11111 ${process.getuid()} /usr/bin/node /tmp/codex-auth-helper.js\n`);
|
||||
|
||||
const { handleImportDefaultCodex } = await import(
|
||||
'../../../../src/codex-auth/commands/import-default-command'
|
||||
);
|
||||
const { handleImportDefaultCodex } =
|
||||
await import('../../../../src/codex-auth/commands/import-default-command');
|
||||
const ctx = await makeCtx();
|
||||
|
||||
const restore = silenceConsole();
|
||||
@@ -614,6 +588,47 @@ describe('import-default — Codex running detection', () => {
|
||||
|
||||
expect(ctx.registry.hasProfile('falsepositive')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores same-process codex-runtime invocation paths', async () => {
|
||||
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
|
||||
|
||||
mockProcessTable(
|
||||
`${process.pid} ${process.getuid()} node /workspace/ccs/dist/bin/codex-runtime.js auth import-default self\n`
|
||||
);
|
||||
|
||||
const { handleImportDefaultCodex } =
|
||||
await import('../../../../src/codex-auth/commands/import-default-command');
|
||||
const ctx = await makeCtx();
|
||||
|
||||
const restore = silenceConsole();
|
||||
try {
|
||||
await handleImportDefaultCodex(ctx, ['selfmatch']);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(ctx.registry.hasProfile('selfmatch')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores Codex processes owned by another uid', async () => {
|
||||
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
|
||||
|
||||
const otherUid = process.getuid() + 1;
|
||||
mockProcessTable(`22222 ${otherUid} /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, ['otheruid']);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(ctx.registry.hasProfile('otheruid')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('import-default — --with-history', () => {
|
||||
@@ -624,9 +639,8 @@ describe('import-default — --with-history', () => {
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(sessionsDir, 'sess1.json'), '{}');
|
||||
|
||||
const { handleImportDefaultCodex } = await import(
|
||||
'../../../../src/codex-auth/commands/import-default-command'
|
||||
);
|
||||
const { handleImportDefaultCodex } =
|
||||
await import('../../../../src/codex-auth/commands/import-default-command');
|
||||
const ctx = await makeCtx();
|
||||
|
||||
const restore = silenceConsole();
|
||||
@@ -645,9 +659,8 @@ describe('import-default — --with-history', () => {
|
||||
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 { handleImportDefaultCodex } =
|
||||
await import('../../../../src/codex-auth/commands/import-default-command');
|
||||
const ctx = await makeCtx();
|
||||
|
||||
const restore = silenceConsole();
|
||||
@@ -666,9 +679,8 @@ 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 { handleImportDefaultCodex } =
|
||||
await import('../../../../src/codex-auth/commands/import-default-command');
|
||||
const ctx = await makeCtx();
|
||||
|
||||
const restore = silenceConsole();
|
||||
@@ -689,9 +701,8 @@ 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 { handleImportDefaultCodex } =
|
||||
await import('../../../../src/codex-auth/commands/import-default-command');
|
||||
const ctx = await makeCtx();
|
||||
|
||||
const restore = silenceConsole();
|
||||
|
||||
@@ -71,15 +71,15 @@ describe('api-command arg parser', () => {
|
||||
|
||||
expect(parsed.target).toBeUndefined();
|
||||
expect(parsed.errors).toEqual([
|
||||
'Invalid --target value "invalid-target". Use: claude or droid',
|
||||
'Invalid --target value "invalid-target". Use: claude, droid, or codex',
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects runtime-only codex as a persisted API target value', () => {
|
||||
test('accepts codex as a persisted API target value', () => {
|
||||
const parsed = parseApiCommandArgs(['my-api', '--target', 'codex']);
|
||||
|
||||
expect(parsed.target).toBeUndefined();
|
||||
expect(parsed.errors).toEqual(['Invalid --target value "codex". Use: claude or droid']);
|
||||
expect(parsed.target).toBe('codex');
|
||||
expect(parsed.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('collects missing-value error for --target with no value', () => {
|
||||
|
||||
@@ -53,4 +53,122 @@ describe('cleanup command', () => {
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not follow a symlinked CCS archive directory during cleanup', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const ccsLogsDir = getNativeLogsDir();
|
||||
const archiveDir = getLogArchiveDir();
|
||||
const victimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cleanup-victim-'));
|
||||
|
||||
fs.mkdirSync(ccsLogsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(victimDir, 'keepme.log'), 'do not delete');
|
||||
fs.symlinkSync(victimDir, archiveDir, 'dir');
|
||||
|
||||
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await handleCleanupCommand(['--force']);
|
||||
|
||||
const output = logSpy.mock.calls
|
||||
.flatMap((call) => call.map((value) => String(value)))
|
||||
.join('\n');
|
||||
|
||||
expect(output).toContain('No CCS or CLIProxy logs found.');
|
||||
expect(fs.existsSync(path.join(victimDir, 'keepme.log'))).toBe(true);
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
fs.rmSync(victimDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('warns instead of reporting no logs when a cleanup directory cannot be read', async () => {
|
||||
const archiveDir = getLogArchiveDir();
|
||||
fs.mkdirSync(archiveDir, { recursive: true });
|
||||
|
||||
const originalReaddirSync = fs.readdirSync;
|
||||
const readdirSpy = spyOn(fs, 'readdirSync').mockImplementation((dirPath, options) => {
|
||||
if (String(dirPath) === archiveDir) {
|
||||
throw Object.assign(new Error('permission denied'), { code: 'EACCES' });
|
||||
}
|
||||
|
||||
return originalReaddirSync(dirPath, options as never);
|
||||
});
|
||||
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await handleCleanupCommand(['--dry-run']);
|
||||
|
||||
const output = logSpy.mock.calls
|
||||
.flatMap((call) => call.map((value) => String(value)))
|
||||
.join('\n');
|
||||
|
||||
expect(output).toContain('Could not read CCS Log Archives');
|
||||
expect(output).toContain('permission denied');
|
||||
expect(output).not.toContain('No CCS or CLIProxy logs found.');
|
||||
} finally {
|
||||
readdirSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('warns instead of reporting no error logs when CLIProxy logs cannot be read', async () => {
|
||||
const cliproxyLogsDir = path.join(getCliproxyDir(), 'logs');
|
||||
fs.mkdirSync(cliproxyLogsDir, { recursive: true });
|
||||
|
||||
const originalReaddirSync = fs.readdirSync;
|
||||
const readdirSpy = spyOn(fs, 'readdirSync').mockImplementation((dirPath, options) => {
|
||||
if (String(dirPath) === cliproxyLogsDir) {
|
||||
throw Object.assign(new Error('disk I/O failed'), { code: 'EIO' });
|
||||
}
|
||||
|
||||
return originalReaddirSync(dirPath, options as never);
|
||||
});
|
||||
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await handleCleanupCommand(['--errors']);
|
||||
|
||||
const output = logSpy.mock.calls
|
||||
.flatMap((call) => call.map((value) => String(value)))
|
||||
.join('\n');
|
||||
|
||||
expect(output).toContain('Could not read CLIProxy logs');
|
||||
expect(output).toContain('disk I/O failed');
|
||||
expect(output).not.toContain('No error logs found.');
|
||||
} finally {
|
||||
readdirSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('warns instead of treating CLIProxy log inspection errors as missing directories', async () => {
|
||||
const cliproxyLogsDir = path.join(getCliproxyDir(), 'logs');
|
||||
fs.mkdirSync(cliproxyLogsDir, { recursive: true });
|
||||
|
||||
const originalLstatSync = fs.lstatSync;
|
||||
const lstatSpy = spyOn(fs, 'lstatSync').mockImplementation((targetPath, options) => {
|
||||
if (String(targetPath) === cliproxyLogsDir) {
|
||||
throw Object.assign(new Error('stat failed'), { code: 'EIO' });
|
||||
}
|
||||
|
||||
return originalLstatSync(targetPath, options as never);
|
||||
});
|
||||
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await handleCleanupCommand(['--errors']);
|
||||
|
||||
const output = logSpy.mock.calls
|
||||
.flatMap((call) => call.map((value) => String(value)))
|
||||
.join('\n');
|
||||
|
||||
expect(output).toContain('Could not inspect CLIProxy logs');
|
||||
expect(output).toContain('stat failed');
|
||||
expect(output).not.toContain('No CLIProxy logs directory found.');
|
||||
} finally {
|
||||
lstatSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -84,3 +84,48 @@ describe('cliproxy quota subcommand failure formatting', () => {
|
||||
expect(resolveDisplayedTier('pro', 'unknown')).toBe('pro');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cliproxy quota subcommand Codex label formatting', () => {
|
||||
it('falls back to the cached window label for invalid Codex feature labels', async () => {
|
||||
const { getCodexWindowDisplayLabel } = await loadQuotaCommandTestExports();
|
||||
|
||||
const cases = [
|
||||
{ featureLabel: '', cadence: '5h', expected: 'Codex Spark (5h)' },
|
||||
{ featureLabel: ' ', cadence: 'weekly', expected: 'Codex Spark (weekly)' },
|
||||
{
|
||||
featureLabel: '\u001b[2J\u001b]52;c;payload\u0007',
|
||||
cadence: '5h',
|
||||
expected: 'Codex Spark (5h)',
|
||||
},
|
||||
{ featureLabel: { unexpected: true }, cadence: '5h', expected: 'Codex Spark (5h)' },
|
||||
] as const;
|
||||
|
||||
for (const { featureLabel, cadence, expected } of cases) {
|
||||
const label = getCodexWindowDisplayLabel({
|
||||
label: 'GPT-5.3-Codex-Spark',
|
||||
resetAfterSeconds: 3600,
|
||||
category: 'additional',
|
||||
cadence,
|
||||
featureLabel,
|
||||
} as never);
|
||||
|
||||
expect(label).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('removes terminal control characters from cached Codex feature labels', async () => {
|
||||
const { getCodexWindowDisplayLabel } = await loadQuotaCommandTestExports();
|
||||
|
||||
const label = getCodexWindowDisplayLabel({
|
||||
label: 'ignored',
|
||||
resetAfterSeconds: 3600,
|
||||
category: 'additional',
|
||||
cadence: 'weekly',
|
||||
featureLabel: '\u001b[2JGPT-5.3-Codex-Spark\u001b]52;c;payload\u0007',
|
||||
});
|
||||
|
||||
expect(label).toBe('Codex Spark (weekly)');
|
||||
expect(label).not.toContain('\u001b');
|
||||
expect(label).not.toContain('\u0007');
|
||||
});
|
||||
});
|
||||
@@ -26,11 +26,11 @@ describe('cliproxy variant arg parser', () => {
|
||||
expect(parsed.errors).toEqual(['Missing value for --target']);
|
||||
});
|
||||
|
||||
test('rejects runtime-only codex as a persisted variant target value', () => {
|
||||
test('accepts codex as a persisted variant target value', () => {
|
||||
const parsed = parseProfileArgs(['variant-a', '--target', 'codex']);
|
||||
|
||||
expect(parsed.target).toBeUndefined();
|
||||
expect(parsed.errors).toEqual(['Invalid --target value "codex". Use: claude or droid']);
|
||||
expect(parsed.target).toBe('codex');
|
||||
expect(parsed.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('uses last --target value when repeated', () => {
|
||||
|
||||
@@ -85,6 +85,7 @@ describe('completion backend', () => {
|
||||
expect(values).toContain('gitlab');
|
||||
expect(values).toContain('codebuddy');
|
||||
expect(values).toContain('kilo');
|
||||
expect(values).toContain('qoder');
|
||||
expect(values).toContain('localglm');
|
||||
expect(values).toContain('work');
|
||||
expect(values).toContain('my-codex');
|
||||
|
||||
@@ -2,35 +2,32 @@ import { describe, expect, it } from 'bun:test';
|
||||
import { parseChannelsCommandArgs } from '../../../src/commands/config-channels-command';
|
||||
|
||||
describe('config channels command parser', () => {
|
||||
it('parses selection, unattended mode, and token input', () => {
|
||||
it('parses selection, unattended mode, and token channel input', () => {
|
||||
const result = parseChannelsCommandArgs([
|
||||
'--set',
|
||||
'telegram,discord',
|
||||
'--unattended',
|
||||
'--set-token',
|
||||
'telegram=telegram-secret',
|
||||
'telegram',
|
||||
]);
|
||||
|
||||
expect(result.setSelection).toBe('telegram,discord');
|
||||
expect(result.unattended).toBe(true);
|
||||
expect(result.setToken).toEqual({
|
||||
channelId: 'telegram',
|
||||
token: 'telegram-secret',
|
||||
});
|
||||
expect(result.setTokenChannel).toBe('telegram');
|
||||
});
|
||||
|
||||
it('supports inline token assignment, legacy flags, and clear-token variants', () => {
|
||||
it('supports legacy flags and clear-token variants', () => {
|
||||
const result = parseChannelsCommandArgs([
|
||||
'--disable',
|
||||
'--no-unattended',
|
||||
'--set-token=abc',
|
||||
'--set-token=discord',
|
||||
]);
|
||||
const clearAll = parseChannelsCommandArgs(['--clear-token']);
|
||||
const clearOne = parseChannelsCommandArgs(['--clear-token', 'discord']);
|
||||
|
||||
expect(result.disable).toBe(true);
|
||||
expect(result.noUnattended).toBe(true);
|
||||
expect(result.setToken).toEqual({ channelId: 'discord', token: 'abc' });
|
||||
expect(result.setTokenChannel).toBe('discord');
|
||||
expect(clearAll.clearTokenAll).toBe(true);
|
||||
expect(clearOne.clearTokenChannel).toBe('discord');
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ describe('help command parity', () => {
|
||||
expect(rendered.includes('gitlab')).toBe(true);
|
||||
expect(rendered.includes('codebuddy')).toBe(true);
|
||||
expect(rendered.includes('kilo')).toBe(true);
|
||||
expect(rendered.includes('qoder')).toBe(true);
|
||||
expect(rendered.includes('--gitlab-token-login')).toBe(true);
|
||||
expect(rendered.includes('--token-login')).toBe(true);
|
||||
expect(rendered.includes('--gitlab-url <url>')).toBe(true);
|
||||
|
||||
@@ -415,6 +415,43 @@ describe('persist command Claude extension parity', () => {
|
||||
expect(renderedLogs).toContain('Native Codex target: ccsxp or ccs codex --target codex');
|
||||
});
|
||||
|
||||
it('does not fail after writing settings when a cleared env value is deeply nested', async () => {
|
||||
await writeUnifiedConfig();
|
||||
|
||||
const settingsPath = path.join(tempRoot, '.claude', 'settings.json');
|
||||
await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true });
|
||||
const deepValue = `${'{"nested":'.repeat(20000)}"leaf"${'}'.repeat(20000)}`;
|
||||
await fs.promises.writeFile(
|
||||
settingsPath,
|
||||
`{"env":{"KEEP_ME":"still-here","ANTHROPIC_AUTH_TOKEN":${deepValue}}}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const originalConsoleLog = console.log;
|
||||
const capturedLogs: string[] = [];
|
||||
console.log = (...args: unknown[]) => {
|
||||
capturedLogs.push(args.map((arg) => String(arg)).join(' '));
|
||||
};
|
||||
|
||||
try {
|
||||
await withScopedHome(() => handlePersistCommand(['default', '--yes']));
|
||||
} finally {
|
||||
console.log = originalConsoleLog;
|
||||
}
|
||||
|
||||
const persisted = JSON.parse(await fs.promises.readFile(settingsPath, 'utf8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
|
||||
expect(persisted.env.KEEP_ME).toBe('still-here');
|
||||
expect(persisted.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
|
||||
const renderedLogs = capturedLogs.join('\n');
|
||||
expect(renderedLogs).toContain("Profile 'default' written to");
|
||||
expect(renderedLogs).toContain('Config Receipt');
|
||||
expect(renderedLogs).toContain('Codex translator URL: not found');
|
||||
expect(renderedLogs).not.toContain('Failed to write settings');
|
||||
});
|
||||
|
||||
it('warns in the persist receipt when a Codex translator URL remains in settings', async () => {
|
||||
await writeUnifiedConfig();
|
||||
|
||||
|
||||
@@ -143,6 +143,27 @@ describe('shell-completion command', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('completion adapters only delegate to the ccs command on PATH', () => {
|
||||
const completionScripts = [
|
||||
'../../../scripts/completion/ccs.bash',
|
||||
'../../../scripts/completion/ccs.zsh',
|
||||
'../../../scripts/completion/ccs.fish',
|
||||
'../../../scripts/completion/ccs.ps1',
|
||||
];
|
||||
|
||||
for (const scriptPath of completionScripts) {
|
||||
const script = readFileSync(join(import.meta.dir, scriptPath), 'utf8');
|
||||
|
||||
expect(script).not.toContain('../..');
|
||||
expect(script).not.toContain('repo_root');
|
||||
expect(script).not.toContain('repoRoot');
|
||||
expect(script).not.toContain('repo_cli');
|
||||
expect(script).not.toContain('repoCli');
|
||||
expect(script).not.toContain('node ');
|
||||
expect(script).not.toContain('& node');
|
||||
}
|
||||
});
|
||||
|
||||
it('fish completion strips a duplicated partial token before delegating to __complete', () => {
|
||||
const fishScript = readFileSync(
|
||||
join(import.meta.dir, '../../../scripts/completion/ccs.fish'),
|
||||
|
||||
@@ -197,7 +197,7 @@ describe('isDaemonRunning', () => {
|
||||
throw new Error('Unable to resolve test server port');
|
||||
}
|
||||
|
||||
const result = await isDaemonRunning(address.port);
|
||||
const result = await isDaemonRunning(address.port, "bad-token");
|
||||
expect(result).toBe(false);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => {
|
||||
|
||||
@@ -46,11 +46,12 @@ describe('cursor-profile-executor', () => {
|
||||
sonnet_model: 'cursor-sonnet',
|
||||
haiku_model: 'cursor-haiku',
|
||||
},
|
||||
'test-token',
|
||||
'/tmp/claude-config'
|
||||
);
|
||||
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:20129');
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('cursor-managed');
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('test-token');
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('cursor-opus');
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('cursor-sonnet');
|
||||
|
||||
@@ -150,6 +150,40 @@ describe('aggregateDailyUsage', () => {
|
||||
expect(result[0].modelBreakdowns[0].provider).toBe('github-copilot');
|
||||
expect(result[0].modelBreakdowns[0].inputTokens).toBe(3000);
|
||||
});
|
||||
|
||||
test('handles provider names inherited from Object.prototype', () => {
|
||||
const entries: RawUsageEntry[] = [
|
||||
createEntry({ model: 'model-a', target: '__proto__' }),
|
||||
createEntry({ model: 'model-b', target: 'constructor' }),
|
||||
createEntry({ model: 'model-c', target: 'toString' }),
|
||||
];
|
||||
|
||||
const daily = aggregateDailyUsage(entries);
|
||||
const hourly = aggregateHourlyUsage(entries);
|
||||
const monthly = aggregateMonthlyUsage(entries);
|
||||
const session = aggregateSessionUsage(entries);
|
||||
|
||||
expect(daily[0].modelBreakdowns.map((item) => item.provider)).toEqual([
|
||||
'__proto__',
|
||||
'constructor',
|
||||
'tostring',
|
||||
]);
|
||||
expect(hourly[0].modelBreakdowns.map((item) => item.provider)).toEqual([
|
||||
'__proto__',
|
||||
'constructor',
|
||||
'tostring',
|
||||
]);
|
||||
expect(monthly[0].modelBreakdowns.map((item) => item.provider)).toEqual([
|
||||
'__proto__',
|
||||
'constructor',
|
||||
'tostring',
|
||||
]);
|
||||
expect(session[0].modelBreakdowns.map((item) => item.provider)).toEqual([
|
||||
'__proto__',
|
||||
'constructor',
|
||||
'tostring',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { isBareClaudeSubcommandPassthrough } from '../../../src/dispatcher/profile-resolver';
|
||||
|
||||
/**
|
||||
* Bare Claude subcommand passthrough decision (`ccs agents`, `ccs mcp`, ...).
|
||||
*
|
||||
* The predicate is consulted only on the profile-not-found path, so a real
|
||||
* configured profile of the same name is resolved earlier and never reaches
|
||||
* this gate. These tests pin the decision matrix: forward documented Claude
|
||||
* subcommands on the claude target, leave everything else alone.
|
||||
*/
|
||||
describe('isBareClaudeSubcommandPassthrough', () => {
|
||||
it('reroutes a bare Claude subcommand on the default (claude) target', () => {
|
||||
expect(isBareClaudeSubcommandPassthrough('agents', ['agents'])).toBe(true);
|
||||
expect(isBareClaudeSubcommandPassthrough('mcp', ['mcp'])).toBe(true);
|
||||
expect(isBareClaudeSubcommandPassthrough('plugin', ['plugin'])).toBe(true);
|
||||
expect(isBareClaudeSubcommandPassthrough('setup-token', ['setup-token'])).toBe(true);
|
||||
});
|
||||
|
||||
it('reroutes when the subcommand carries its own flags', () => {
|
||||
expect(
|
||||
isBareClaudeSubcommandPassthrough('agents', [
|
||||
'agents',
|
||||
'--permission-mode',
|
||||
'bypassPermissions',
|
||||
])
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not reroute the implicit default profile', () => {
|
||||
expect(isBareClaudeSubcommandPassthrough('default', [])).toBe(false);
|
||||
});
|
||||
|
||||
it('does not reroute an unknown non-subcommand token', () => {
|
||||
expect(isBareClaudeSubcommandPassthrough('notaprofile', ['notaprofile'])).toBe(false);
|
||||
expect(isBareClaudeSubcommandPassthrough('glm', ['glm'])).toBe(false);
|
||||
});
|
||||
|
||||
it('does not reroute when an explicit non-claude target is selected', () => {
|
||||
expect(isBareClaudeSubcommandPassthrough('agents', ['agents', '--target', 'droid'])).toBe(
|
||||
false
|
||||
);
|
||||
expect(isBareClaudeSubcommandPassthrough('mcp', ['mcp', '--target', 'codex'])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
const originalGraceDays = process.env.CCS_DOCKER_LEGACY_KEY_GRACE_DAYS;
|
||||
const originalRestoreLegacyKey = process.env.CCS_DOCKER_RESTORE_LEGACY_API_KEY;
|
||||
const originalEnableLegacyKeyAuth = process.env.CCS_DOCKER_ENABLE_LEGACY_KEY_AUTH;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function useTempCcsHome(): string {
|
||||
@@ -48,6 +49,11 @@ afterEach(() => {
|
||||
} else {
|
||||
process.env.CCS_DOCKER_RESTORE_LEGACY_API_KEY = originalRestoreLegacyKey;
|
||||
}
|
||||
if (originalEnableLegacyKeyAuth === undefined) {
|
||||
delete process.env.CCS_DOCKER_ENABLE_LEGACY_KEY_AUTH;
|
||||
} else {
|
||||
process.env.CCS_DOCKER_ENABLE_LEGACY_KEY_AUTH = originalEnableLegacyKeyAuth;
|
||||
}
|
||||
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
@@ -98,7 +104,7 @@ describe('docker bootstrap auth', () => {
|
||||
expect(readDockerBootstrapState().state?.legacyKeyGrace).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves the legacy key during the default upgrade grace window', () => {
|
||||
it('tracks legacy key grace but does not enable it in config by default', () => {
|
||||
useTempCcsHome();
|
||||
mutateConfig((config) => {
|
||||
config.cliproxy.auth = {
|
||||
@@ -118,7 +124,7 @@ describe('docker bootstrap auth', () => {
|
||||
expect(state?.legacyKeyGrace?.legacyKey).toBe(CCS_INTERNAL_API_KEY);
|
||||
expect(state?.legacyKeyGrace?.replacementKey).toBe(config.cliproxy.auth?.api_key);
|
||||
expect(content).toContain(`"${config.cliproxy.auth?.api_key}"`);
|
||||
expect(content).toContain(`"${CCS_INTERNAL_API_KEY}"`);
|
||||
expect(content).not.toContain(`"${CCS_INTERNAL_API_KEY}"`);
|
||||
});
|
||||
|
||||
it('honors CCS_DOCKER_LEGACY_KEY_GRACE_DAYS for upgrade expiry', () => {
|
||||
@@ -188,7 +194,7 @@ describe('docker bootstrap auth', () => {
|
||||
|
||||
expect(changed).toBe(true);
|
||||
expect(content).toContain(`"${generatedKey}"`);
|
||||
expect(content).toContain(`"${CCS_INTERNAL_API_KEY}"`);
|
||||
expect(content).not.toContain(`"${CCS_INTERNAL_API_KEY}"`);
|
||||
});
|
||||
|
||||
it('restores the legacy key after an earlier run wrote a no-grace marker', () => {
|
||||
@@ -212,7 +218,7 @@ describe('docker bootstrap auth', () => {
|
||||
|
||||
expect(changed).toBe(true);
|
||||
expect(content).toContain(`"${generatedKey}"`);
|
||||
expect(content).toContain(`"${CCS_INTERNAL_API_KEY}"`);
|
||||
expect(content).not.toContain(`"${CCS_INTERNAL_API_KEY}"`);
|
||||
});
|
||||
|
||||
it('recovers safely from a corrupted marker file during broken-install recovery', () => {
|
||||
@@ -232,7 +238,7 @@ describe('docker bootstrap auth', () => {
|
||||
|
||||
expect(getDockerBootstrapStatePath()).toContain(DOCKER_BOOTSTRAP_STATE_FILENAME);
|
||||
expect(readDockerBootstrapState().corrupted).toBe(false);
|
||||
expect(content).toContain(`"${CCS_INTERNAL_API_KEY}"`);
|
||||
expect(content).not.toContain(`"${CCS_INTERNAL_API_KEY}"`);
|
||||
});
|
||||
|
||||
it('does not treat human custom keys as broken Docker-generated keys', () => {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const repoRoot = join(import.meta.dir, '../../..');
|
||||
|
||||
describe('docker release workflow context', () => {
|
||||
test('builds the integrated Dockerfile with the context its COPY paths expect', () => {
|
||||
const workflow = readFileSync(join(repoRoot, '.github/workflows/docker-release.yml'), 'utf8');
|
||||
const dockerfile = readFileSync(join(repoRoot, 'docker/Dockerfile.integrated'), 'utf8');
|
||||
|
||||
expect(workflow).toMatch(
|
||||
/Build and push integrated image[\s\S]*context: docker[\s\S]*file: docker\/Dockerfile\.integrated/,
|
||||
);
|
||||
expect(dockerfile).toContain('COPY supervisord.conf /etc/supervisord.conf');
|
||||
expect(dockerfile).toContain('COPY entrypoint-integrated.sh /entrypoint-integrated.sh');
|
||||
expect(existsSync(join(repoRoot, 'docker/supervisord.conf'))).toBe(true);
|
||||
expect(existsSync(join(repoRoot, 'docker/entrypoint-integrated.sh'))).toBe(true);
|
||||
});
|
||||
|
||||
test('keeps integrated smoke tests independent from legacy dashboard publish', () => {
|
||||
const workflow = readFileSync(join(repoRoot, '.github/workflows/docker-release.yml'), 'utf8');
|
||||
|
||||
expect(workflow).not.toMatch(/publish-integrated:[\s\S]*?needs:\s*\[?publish-dashboard/);
|
||||
expect(workflow).toMatch(/smoke-test:[\s\S]*?needs:\s*\[publish-integrated\]/);
|
||||
});
|
||||
|
||||
test('verifies immutable and promoted tags without registry credentials', () => {
|
||||
const workflow = readFileSync(join(repoRoot, '.github/workflows/docker-release.yml'), 'utf8');
|
||||
|
||||
expect(workflow).toMatch(
|
||||
/Verify anonymous pull access[\s\S]*DOCKER_CONFIG="\$\{CLEAN_DOCKER_CONFIG\}" docker pull/
|
||||
);
|
||||
expect(workflow).toMatch(
|
||||
/Verify promoted tags are anonymously pullable[\s\S]*DOCKER_CONFIG="\$\{CLEAN_DOCKER_CONFIG\}" docker pull/
|
||||
);
|
||||
});
|
||||
|
||||
test('gives the raw integrated image a Docker healthcheck for release smoke tests', () => {
|
||||
const dockerfile = readFileSync(join(repoRoot, 'docker/Dockerfile.integrated'), 'utf8');
|
||||
|
||||
expect(dockerfile).toContain('HEALTHCHECK');
|
||||
expect(dockerfile).toContain('127.0.0.1:3000');
|
||||
expect(dockerfile).toContain('127.0.0.1:8317');
|
||||
});
|
||||
|
||||
test('lets network-contract smoke tests avoid fixed host port collisions', () => {
|
||||
const compose = readFileSync(join(repoRoot, 'docker/compose.yaml'), 'utf8');
|
||||
const contractScript = readFileSync(join(repoRoot, 'tests/docker/network-contract.sh'), 'utf8');
|
||||
|
||||
expect(compose).toContain('${CCS_DASHBOARD_PORT:-3000}:3000');
|
||||
expect(compose).toContain('${CCS_CLIPROXY_PORT:-8317}:8317');
|
||||
expect(contractScript).toContain('CCS_NETWORK_CONTRACT_DASHBOARD_PORT:-${CCS_DASHBOARD_PORT:-13001}');
|
||||
expect(contractScript).toContain('CCS_NETWORK_CONTRACT_CLIPROXY_PORT:-${CCS_CLIPROXY_PORT:-18318}');
|
||||
expect(contractScript).toContain('up -d --remove-orphans');
|
||||
});
|
||||
|
||||
test('passes collision-safe compose env through network-contract calls', () => {
|
||||
const result = spawnSync('bash', ['tests/docker/network-contract-env.test.sh'], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
expect(result.status, result.stdout + result.stderr).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
buildReleaseIssueSet,
|
||||
extractIssueNumbers,
|
||||
extractPrNumbers,
|
||||
planIssueCleanup,
|
||||
} from '../../../scripts/github/stable-release-issue-cleanup-lib.mjs';
|
||||
|
||||
describe('stable release issue cleanup', () => {
|
||||
it('extracts issue numbers from release action verbs', () => {
|
||||
const text = [
|
||||
'feat: promote dev to main (#1351), closes #1340 #1341',
|
||||
'fix: support aliases (#1197)',
|
||||
'Refs #760',
|
||||
].join('\n');
|
||||
|
||||
expect(extractIssueNumbers(text, { includeRefs: true })).toEqual([760, 1340, 1341]);
|
||||
expect(extractIssueNumbers(text, { includeRefs: false })).toEqual([1340, 1341]);
|
||||
});
|
||||
|
||||
it('ignores unrelated issue references after the action reference sequence', () => {
|
||||
const text = [
|
||||
'fix: guard release parser',
|
||||
'',
|
||||
'Fixes #12; see #99 for the follow-up',
|
||||
'Resolves #13, #14 and #15; related to #100',
|
||||
].join('\n');
|
||||
|
||||
expect(extractIssueNumbers(text, { includeRefs: true })).toEqual([12, 13, 14, 15]);
|
||||
expect(extractIssueNumbers(text, { includeRefs: false })).toEqual([12, 13, 14, 15]);
|
||||
});
|
||||
|
||||
it('extracts PR numbers from merge and squash commit subjects', () => {
|
||||
const text = [
|
||||
'Merge pull request #1392 from kaitranntt/kai/fix/foo',
|
||||
'fix(analytics): tighten top-bar layout (#1391)',
|
||||
].join('\n');
|
||||
|
||||
expect(extractPrNumbers(text)).toEqual([1391, 1392]);
|
||||
});
|
||||
|
||||
it('combines release body, commit text, and PR body issue references', () => {
|
||||
const result = buildReleaseIssueSet({
|
||||
releaseBody: 'closes #100',
|
||||
commitText: 'fix: thing (#10)',
|
||||
prText: 'Refs #200\nResolves #300',
|
||||
});
|
||||
|
||||
expect(result.releaseIssues).toEqual([100, 200, 300]);
|
||||
expect(result.resolvedIssues).toEqual([100, 300]);
|
||||
});
|
||||
|
||||
it('closes dev-released issues when promoted to stable even if the PR used refs', () => {
|
||||
const actions = planIssueCleanup({
|
||||
releaseIssues: [760],
|
||||
resolvedIssues: [],
|
||||
issueStates: new Map([[760, { state: 'OPEN', labels: ['released-dev'] }]]),
|
||||
});
|
||||
|
||||
expect(actions[0]).toMatchObject({
|
||||
number: 760,
|
||||
addReleasedLabel: true,
|
||||
close: true,
|
||||
reason: 'promoted from dev to stable',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not close weak refs unless the issue was already marked released-dev', () => {
|
||||
const actions = planIssueCleanup({
|
||||
releaseIssues: [42],
|
||||
resolvedIssues: [],
|
||||
issueStates: new Map([[42, { state: 'OPEN', labels: ['enhancement'] }]]),
|
||||
});
|
||||
|
||||
expect(actions[0]).toMatchObject({
|
||||
number: 42,
|
||||
addReleasedLabel: false,
|
||||
close: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('closes explicitly resolved issues without requiring a manual released label', () => {
|
||||
const actions = planIssueCleanup({
|
||||
releaseIssues: [1340],
|
||||
resolvedIssues: [1340],
|
||||
issueStates: new Map([[1340, { state: 'OPEN', labels: ['bug'] }]]),
|
||||
});
|
||||
|
||||
expect(actions[0]).toMatchObject({
|
||||
number: 1340,
|
||||
addReleasedLabel: true,
|
||||
close: true,
|
||||
reason: 'resolved by stable release',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -46,4 +46,18 @@ describe('SSEParser', () => {
|
||||
?.delta?.content
|
||||
).toBe('Legacy');
|
||||
});
|
||||
|
||||
|
||||
it('does not split events when CRLF is split across chunks', () => {
|
||||
const parser = new SSEParser({ throwOnMalformedJson: true });
|
||||
|
||||
expect(parser.parse('data: {"choices":[\r')).toEqual([]);
|
||||
const events = parser.parse('\ndata: {"delta":{"content":"Hello"}}\r\ndata: ]}\r\n\r\n');
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(
|
||||
(events[0]?.data as { choices?: Array<{ delta?: { content?: string } }> })?.choices?.[0]
|
||||
?.delta?.content
|
||||
).toBe('Hello');
|
||||
});
|
||||
});
|
||||
@@ -722,6 +722,47 @@ describe('ccs-browser MCP server - navigation and query', () => {
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
it('rejects excessive clickCount and repeat values', async () => {
|
||||
const responses = await runMcpRequests(
|
||||
[{ id: 'page-1', title: 'Limits Page', currentUrl: 'https://example.com/' }],
|
||||
[
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 713,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
selector: '#submit',
|
||||
clickCount: 26,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 714,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_press_key',
|
||||
arguments: {
|
||||
key: 'k',
|
||||
repeat: 26,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
const clickResponse = responses.find((message) => message.id === 713);
|
||||
expect((clickResponse?.result as { isError?: boolean }).isError).toBe(true);
|
||||
expect(getResponseText(clickResponse)).toContain('clickCount must be less than or equal to 25');
|
||||
|
||||
const keyResponse = responses.find((message) => message.id === 714);
|
||||
expect((keyResponse?.result as { isError?: boolean }).isError).toBe(true);
|
||||
expect(getResponseText(keyResponse)).toContain('repeat must be less than or equal to 25');
|
||||
});
|
||||
|
||||
it('scrolls an element into view with browser_scroll', async () => {
|
||||
const responses = await runMcpRequests(
|
||||
[
|
||||
|
||||
@@ -79,11 +79,17 @@ describe('ccs-browser MCP server - session and interception', () => {
|
||||
expect(clickTool?.inputSchema?.properties?.clickCount).toMatchObject({
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: 25,
|
||||
});
|
||||
|
||||
const keyTool = tools.find((tool) => tool.name === 'browser_press_key');
|
||||
expect(keyTool?.inputSchema?.properties?.key).toMatchObject({ type: 'string' });
|
||||
expect(keyTool?.inputSchema?.properties?.modifiers).toMatchObject({ type: 'array' });
|
||||
expect(keyTool?.inputSchema?.properties?.repeat).toMatchObject({
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: 25,
|
||||
});
|
||||
|
||||
const scrollTool = tools.find((tool) => tool.name === 'browser_scroll');
|
||||
expect(scrollTool?.inputSchema?.properties?.deltaX).toMatchObject({ type: 'number' });
|
||||
@@ -433,6 +439,45 @@ describe('ccs-browser MCP server - session and interception', () => {
|
||||
expect(listText).not.toContain('Docs');
|
||||
});
|
||||
|
||||
it('closes a page when Chrome DevTools requires PUT and returns text', async () => {
|
||||
const responses = await runMcpRequests(
|
||||
[
|
||||
{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' },
|
||||
{ id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' },
|
||||
],
|
||||
[
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 8331,
|
||||
method: 'tools/call',
|
||||
params: { name: 'browser_select_page', arguments: { pageIndex: 1 } },
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 8332,
|
||||
method: 'tools/call',
|
||||
params: { name: 'browser_close_page', arguments: {} },
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 8333,
|
||||
method: 'tools/call',
|
||||
params: { name: 'browser_get_session_info', arguments: {} },
|
||||
},
|
||||
],
|
||||
{ requirePutForClosePage: true, closePageRespondsWithText: true }
|
||||
);
|
||||
|
||||
const closeText = getResponseText(responses.find((message) => message.id === 8332));
|
||||
expect(closeText).toContain('status: closed');
|
||||
expect(closeText).toContain('selectedPageId: page-1');
|
||||
|
||||
const listText = getResponseText(responses.find((message) => message.id === 8333));
|
||||
expect(listText).toContain('0. Home');
|
||||
expect(listText).toContain('selected: true');
|
||||
expect(listText).not.toContain('Docs');
|
||||
});
|
||||
|
||||
it('keeps the selected page when closing a different page', async () => {
|
||||
const responses = await runMcpRequests(
|
||||
[
|
||||
@@ -780,8 +825,7 @@ describe('ccs-browser MCP server - session and interception', () => {
|
||||
expect(listText).toContain('requestId: req-1');
|
||||
expect(listText).toContain('matchedRuleId: rule-1');
|
||||
expect(listText).toContain('action: fail');
|
||||
expect(listText).toContain('requestId: req-2');
|
||||
expect(listText).toContain('action: continue');
|
||||
expect(listText).not.toContain('requestId: req-2');
|
||||
});
|
||||
|
||||
it('removes rules and recent requests bound to a page after that page is closed', async () => {
|
||||
|
||||
@@ -350,6 +350,8 @@ type RunMcpRequestsOptions = {
|
||||
childEnv?: NodeJS.ProcessEnv;
|
||||
responseTimeoutMs?: number;
|
||||
requirePutForNewPage?: boolean;
|
||||
requirePutForClosePage?: boolean;
|
||||
closePageRespondsWithText?: boolean;
|
||||
};
|
||||
|
||||
function encodeMessage(message: unknown): string {
|
||||
@@ -746,6 +748,12 @@ function createMockBrowser(pagesInput: MockPageState[]) {
|
||||
}
|
||||
|
||||
if (req.url?.startsWith('/json/close/')) {
|
||||
if (options.requirePutForClosePage && req.method !== 'PUT') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'method not allowed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const targetId = decodeURIComponent(req.url.slice('/json/close/'.length));
|
||||
const entry = Array.from(pageStates.entries()).find(([, page]) => page.id === targetId);
|
||||
if (!entry) {
|
||||
@@ -754,6 +762,11 @@ function createMockBrowser(pagesInput: MockPageState[]) {
|
||||
return;
|
||||
}
|
||||
pageStates.delete(entry[0]);
|
||||
if (options.closePageRespondsWithText) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('Target is closing');
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ id: targetId }));
|
||||
return;
|
||||
|
||||
@@ -256,14 +256,14 @@ describe('model-pricing', () => {
|
||||
expect(cost).toBe(36.75); // 5 + 25 + 6.25 + 0.5
|
||||
});
|
||||
|
||||
it('should calculate Claude Opus 4.7 cache cost consistently across repeat lookups', () => {
|
||||
it('should calculate Claude Opus 4.7 thinking cost including cache token rates', () => {
|
||||
const usage: TokenUsage = {
|
||||
inputTokens: 1_000_000,
|
||||
outputTokens: 1_000_000,
|
||||
cacheCreationTokens: 1_000_000,
|
||||
cacheReadTokens: 1_000_000,
|
||||
};
|
||||
const cost = calculateCost(usage, 'claude-opus-4-7');
|
||||
const cost = calculateCost(usage, 'claude-opus-4-7-thinking');
|
||||
expect(cost).toBe(36.75); // 5 + 25 + 6.25 + 0.5
|
||||
});
|
||||
});
|
||||
@@ -469,17 +469,15 @@ describe('model-pricing', () => {
|
||||
});
|
||||
|
||||
it('gracefully ignores malformed cached model entries', () => {
|
||||
setCachedModelsDevRegistry(
|
||||
{
|
||||
openai: {
|
||||
id: 'openai',
|
||||
models: {
|
||||
'null-entry': null,
|
||||
'gpt-5.5': { id: 'gpt-5.5', cost: { input: 5, output: 30 } },
|
||||
},
|
||||
setCachedModelsDevRegistry({
|
||||
openai: {
|
||||
id: 'openai',
|
||||
models: {
|
||||
'null-entry': null,
|
||||
'gpt-5.5': { id: 'gpt-5.5', cost: { input: 5, output: 30 } },
|
||||
},
|
||||
} as unknown as Parameters<typeof setCachedModelsDevRegistry>[0]
|
||||
);
|
||||
},
|
||||
} as unknown as Parameters<typeof setCachedModelsDevRegistry>[0]);
|
||||
|
||||
expect(() => getModelPricing('openai/gpt-5.5')).not.toThrow();
|
||||
expect(getModelPricing('openai/gpt-5.5').inputPerMillion).toBe(5);
|
||||
|
||||
@@ -97,6 +97,12 @@ beforeEach(() => {
|
||||
ANTHROPIC_MODEL: 'sonar-pro',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
}),
|
||||
mm: writeSettings('mm', {
|
||||
ANTHROPIC_BASE_URL: 'https://api.minimax.io/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'minimax_token',
|
||||
ANTHROPIC_MODEL: 'MiniMax-M2.7',
|
||||
CCS_DROID_PROVIDER: 'openai',
|
||||
}),
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
@@ -242,4 +248,102 @@ describe('handleProxyMessagesRequest', () => {
|
||||
expect(closeCalls).toBe(0);
|
||||
expect(res.statusCode).toBe(502);
|
||||
});
|
||||
|
||||
it('moves system messages into the first user message for MiniMax OpenAI-compatible upstreams', async () => {
|
||||
const activeProfile = buildProfile('mm');
|
||||
let capturedBody: unknown;
|
||||
|
||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
capturedBody = JSON.parse(String(init?.body));
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'chatcmpl_1',
|
||||
object: 'chat.completion',
|
||||
created: 1,
|
||||
model: 'MiniMax-M2.7',
|
||||
choices: [{ index: 0, message: { role: 'assistant', content: 'ok' } }],
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } }
|
||||
);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
const req = new FakeRequest({
|
||||
'x-api-key': 'local-token',
|
||||
});
|
||||
const res = new FakeResponse();
|
||||
const pending = handleProxyMessagesRequest(
|
||||
req as never,
|
||||
res as never,
|
||||
activeProfile,
|
||||
'local-token'
|
||||
);
|
||||
req.end(
|
||||
JSON.stringify({
|
||||
model: 'MiniMax-M2.7',
|
||||
stream: false,
|
||||
messages: [
|
||||
{ role: 'system', content: 'Use Turkish.' },
|
||||
{ role: 'user', content: 'bu hangi model' },
|
||||
],
|
||||
})
|
||||
);
|
||||
await pending;
|
||||
|
||||
expect(capturedBody).toMatchObject({
|
||||
model: 'MiniMax-M2.7',
|
||||
messages: [{ role: 'user', content: 'Use Turkish.\n\nbu hangi model' }],
|
||||
});
|
||||
expect((capturedBody as { messages: Array<{ role: string }> }).messages).not.toContainEqual(
|
||||
expect.objectContaining({ role: 'system' })
|
||||
);
|
||||
});
|
||||
|
||||
it('strips blank system messages for MiniMax OpenAI-compatible upstreams', async () => {
|
||||
const activeProfile = buildProfile('mm');
|
||||
let capturedBody: unknown;
|
||||
|
||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
capturedBody = JSON.parse(String(init?.body));
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'chatcmpl_1',
|
||||
object: 'chat.completion',
|
||||
created: 1,
|
||||
model: 'MiniMax-M2.7',
|
||||
choices: [{ index: 0, message: { role: 'assistant', content: 'ok' } }],
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } }
|
||||
);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
const req = new FakeRequest({
|
||||
'x-api-key': 'local-token',
|
||||
});
|
||||
const res = new FakeResponse();
|
||||
const pending = handleProxyMessagesRequest(
|
||||
req as never,
|
||||
res as never,
|
||||
activeProfile,
|
||||
'local-token'
|
||||
);
|
||||
req.end(
|
||||
JSON.stringify({
|
||||
model: 'MiniMax-M2.7',
|
||||
stream: false,
|
||||
messages: [
|
||||
{ role: 'system', content: [{ type: 'text', text: '' }] },
|
||||
{ role: 'user', content: 'bu hangi model' },
|
||||
],
|
||||
})
|
||||
);
|
||||
await pending;
|
||||
|
||||
expect(capturedBody).toMatchObject({
|
||||
model: 'MiniMax-M2.7',
|
||||
messages: [{ role: 'user', content: 'bu hangi model' }],
|
||||
});
|
||||
expect((capturedBody as { messages: Array<{ role: string }> }).messages).not.toContainEqual(
|
||||
expect.objectContaining({ role: 'system' })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,23 @@ describe('ProxyRequestTransformer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts Claude Code system messages in the messages array', () => {
|
||||
const transformer = new ProxyRequestTransformer();
|
||||
const result = transformer.transform({
|
||||
messages: [
|
||||
{ role: 'user', content: 'hello' },
|
||||
{ role: 'system', content: [{ type: 'text', text: 'answer tersely' }] },
|
||||
{ role: 'user', content: 'which model is this?' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{ role: 'user', content: 'hello' },
|
||||
{ role: 'system', content: 'answer tersely' },
|
||||
{ role: 'user', content: 'which model is this?' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('translates base64 image blocks into OpenAI image_url parts', () => {
|
||||
const transformer = new ProxyRequestTransformer();
|
||||
const result = transformer.transform({
|
||||
|
||||
@@ -45,14 +45,18 @@ describe('docker dashboard sunset guard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('fails loudly if the baseline tag is missing after the baseline', () => {
|
||||
expect(() =>
|
||||
evaluateDashboardSunset({
|
||||
targetTag: 'v7.81.2',
|
||||
baselineVersion: '7.81.0',
|
||||
releaseWindow: 2,
|
||||
stableTags: ['v7.80.0'],
|
||||
}),
|
||||
).toThrow('baseline tag v7.81.0 is missing');
|
||||
test('skips deprecated publish if the baseline tag is missing after the baseline', () => {
|
||||
const result = evaluateDashboardSunset({
|
||||
targetTag: 'v7.81.2',
|
||||
baselineVersion: '7.81.0',
|
||||
releaseWindow: 2,
|
||||
stableTags: ['v7.80.0'],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
elapsed: 2,
|
||||
publish: false,
|
||||
});
|
||||
expect(result.reason).toContain('baseline v7.81.0 is missing');
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,9 @@ describe('PR-Agent review lane migration', () => {
|
||||
expect(workflow).toContain('CCS_REVIEWER_APP_ID');
|
||||
expect(workflow).toContain('CCS_REVIEWER_PRIVATE_KEY');
|
||||
expect(workflow).toContain('id: pr-agent-app-token');
|
||||
expect(workflow).toContain('permission-issues: write');
|
||||
expect(workflow).toContain('permission-pull-requests: write');
|
||||
expect(workflow).toContain('permission-contents: read');
|
||||
expect(workflow).toContain('GITHUB_TOKEN: ${{ steps.pr-agent-app-token.outputs.token }}');
|
||||
expect(workflow).not.toContain('GITHUB_TOKEN: ${{ github.token }}');
|
||||
expect(workflow).not.toContain('uses: anthropics/claude-code-action@v1');
|
||||
|
||||
@@ -307,6 +307,31 @@ describe('SharedManager', () => {
|
||||
});
|
||||
|
||||
describe('marketplace registry ownership', () => {
|
||||
it('skips unstatable shared plugin entries during instance linking', () => {
|
||||
const manager = new SharedManager();
|
||||
const instancePath = instanceDir('work');
|
||||
const sharedPluginsPath = path.join(claudeDir(), 'plugins');
|
||||
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
fs.mkdirSync(sharedPluginsPath, { recursive: true });
|
||||
fs.symlinkSync(
|
||||
path.join(sharedPluginsPath, 'missing-plugin'),
|
||||
path.join(sharedPluginsPath, 'broken-plugin'),
|
||||
'dir'
|
||||
);
|
||||
|
||||
expect(() => manager.linkSharedDirectories(instancePath)).not.toThrow();
|
||||
expect(fs.existsSync(path.join(instancePath, 'plugins', 'broken-plugin'))).toBe(false);
|
||||
expect(
|
||||
logSpy.mock.calls.some(([message]) =>
|
||||
String(message).includes(
|
||||
'Skipping plugins/broken-plugin: unable to inspect shared plugin entry'
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('writes global and instance registries with different authoritative install locations', () => {
|
||||
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
|
||||
ensureMarketplacePayload(claudeDir());
|
||||
|
||||
@@ -31,4 +31,14 @@ describe('stale Codex translator settings scanner', () => {
|
||||
'["custom-key"][0]',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not overflow the stack on deeply nested local settings values', () => {
|
||||
let settings: Record<string, unknown> = { value: 'leaf' };
|
||||
for (let depth = 0; depth < 20000; depth += 1) {
|
||||
settings = { nested: settings };
|
||||
}
|
||||
|
||||
expect(() => findCodexTranslatorUrlPaths(settings)).not.toThrow();
|
||||
expect(findCodexTranslatorUrlPaths(settings)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { CodexAdapter } from '../../../src/targets/codex-adapter';
|
||||
import { CCSXP_CLIPROXY_SHORTCUT_ENV } from '../../../src/targets/codex-cliproxy-provider-config';
|
||||
import {
|
||||
buildCodexBrowserMcpOverrides,
|
||||
getCodexBrowserMcpServerName,
|
||||
@@ -97,6 +98,30 @@ describe('CodexAdapter', () => {
|
||||
).toThrow(/does not advertise --config overrides/);
|
||||
});
|
||||
|
||||
test('rejects ccsxp default launches when codex lacks config override support', () => {
|
||||
const originalShortcut = process.env[CCSXP_CLIPROXY_SHORTCUT_ENV];
|
||||
process.env[CCSXP_CLIPROXY_SHORTCUT_ENV] = '1';
|
||||
try {
|
||||
expect(() =>
|
||||
adapter.buildArgs('default', ['--config', 'model_provider="cliproxy"', '--version'], {
|
||||
profileType: 'default',
|
||||
binaryInfo: {
|
||||
path: '/tmp/codex',
|
||||
needsShell: false,
|
||||
version: 'codex-cli 0.1.0',
|
||||
features: [],
|
||||
},
|
||||
})
|
||||
).toThrow(/does not advertise --config overrides/);
|
||||
} finally {
|
||||
if (originalShortcut === undefined) {
|
||||
delete process.env[CCSXP_CLIPROXY_SHORTCUT_ENV];
|
||||
} else {
|
||||
process.env[CCSXP_CLIPROXY_SHORTCUT_ENV] = originalShortcut;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('injects transient config overrides for CCS-backed launches', () => {
|
||||
const runtimeConfigOverrides = buildCodexBrowserMcpOverrides();
|
||||
const args = adapter.buildArgs('codex', ['--search'], {
|
||||
@@ -120,7 +145,9 @@ describe('CodexAdapter', () => {
|
||||
expect(args).toContain('model_provider="ccs_runtime"');
|
||||
expect(args).toContain('model_providers.ccs_runtime.env_key="CCS_CODEX_API_KEY"');
|
||||
expect(args).toContain('model="gpt-5.4"');
|
||||
expect(args).toContain(`mcp_servers.${getCodexBrowserMcpServerName()}.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`);
|
||||
expect(args).toContain(
|
||||
`mcp_servers.${getCodexBrowserMcpServerName()}.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`
|
||||
);
|
||||
expect(args).toContain('model_reasoning_effort="high"');
|
||||
expect(args[args.length - 1]).toBe('--search');
|
||||
});
|
||||
|
||||
@@ -596,7 +596,9 @@ process.exit(0);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(fs.existsSync(freshCodexHome)).toBe(true);
|
||||
expect(fs.statSync(freshCodexHome).isDirectory()).toBe(true);
|
||||
const codexHomeStat = fs.statSync(freshCodexHome);
|
||||
expect(codexHomeStat.isDirectory()).toBe(true);
|
||||
expect(codexHomeStat.mode & 0o777).toBe(0o700);
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([
|
||||
['-c', 'model="gpt-5"', '--version'],
|
||||
['-c', 'model_reasoning_effort="high"', 'fix failing tests'],
|
||||
@@ -714,8 +716,10 @@ process.exit(0);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('codex-cli 9.9.9-test');
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([
|
||||
['--config', 'model_provider="cliproxy"', '--version'],
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath).at(-1)).toEqual([
|
||||
'--config',
|
||||
'model_provider="cliproxy"',
|
||||
'--version',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -737,19 +741,17 @@ process.exit(0);
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([
|
||||
{
|
||||
CODEX_HOME: path.join(tmpHome, '.codex'),
|
||||
CODEX_CI: undefined,
|
||||
CODEX_MANAGED_BY_BUN: undefined,
|
||||
CODEX_THREAD_ID: undefined,
|
||||
ANTHROPIC_BASE_URL: undefined,
|
||||
CLIPROXY_API_KEY: 'ccs-internal-managed',
|
||||
CCS_BROWSER_USER_DATA_DIR: undefined,
|
||||
CCS_BROWSER_PROFILE_DIR: undefined,
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
|
||||
},
|
||||
]);
|
||||
expect(readLoggedCodexEnv(codexEnvLogPath).at(-1)).toEqual({
|
||||
CODEX_HOME: path.join(tmpHome, '.codex'),
|
||||
CODEX_CI: undefined,
|
||||
CODEX_MANAGED_BY_BUN: undefined,
|
||||
CODEX_THREAD_ID: undefined,
|
||||
ANTHROPIC_BASE_URL: undefined,
|
||||
CLIPROXY_API_KEY: 'ccs-internal-managed',
|
||||
CCS_BROWSER_USER_DATA_DIR: undefined,
|
||||
CCS_BROWSER_PROFILE_DIR: undefined,
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('routes default ccsxp launches through native Codex with the cliproxy provider override', () => {
|
||||
@@ -768,24 +770,50 @@ process.exit(0);
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([
|
||||
['--config', 'model_provider="cliproxy"', 'fix failing tests'],
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath).at(-1)).toEqual([
|
||||
'--config',
|
||||
'model_provider="cliproxy"',
|
||||
'fix failing tests',
|
||||
]);
|
||||
const codexConfig = fs.readFileSync(path.join(tmpHome, '.codex', 'config.toml'), 'utf8');
|
||||
expect(codexConfig).toContain('[model_providers.cliproxy]');
|
||||
expect(codexConfig).toContain('env_key = "CLIPROXY_API_KEY"');
|
||||
expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([
|
||||
{
|
||||
CODEX_HOME: path.join(tmpHome, '.codex'),
|
||||
CODEX_CI: undefined,
|
||||
CODEX_MANAGED_BY_BUN: undefined,
|
||||
CODEX_THREAD_ID: undefined,
|
||||
ANTHROPIC_BASE_URL: undefined,
|
||||
CLIPROXY_API_KEY: 'ccs-internal-managed',
|
||||
CCS_BROWSER_USER_DATA_DIR: undefined,
|
||||
CCS_BROWSER_PROFILE_DIR: undefined,
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
|
||||
},
|
||||
expect(readLoggedCodexEnv(codexEnvLogPath).at(-1)).toEqual({
|
||||
CODEX_HOME: path.join(tmpHome, '.codex'),
|
||||
CODEX_CI: undefined,
|
||||
CODEX_MANAGED_BY_BUN: undefined,
|
||||
CODEX_THREAD_ID: undefined,
|
||||
ANTHROPIC_BASE_URL: undefined,
|
||||
CLIPROXY_API_KEY: 'ccs-internal-managed',
|
||||
CCS_BROWSER_USER_DATA_DIR: undefined,
|
||||
CCS_BROWSER_PROFILE_DIR: undefined,
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails fast when ccsxp needs unsupported Codex config overrides', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const result = runCcsxpAlias(['--version'], {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
HOME: tmpHome,
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CODEX_PATH: fakeCodexPath,
|
||||
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
|
||||
CCS_TEST_CODEX_CONFIG_OVERRIDE_STATUS: 'unsupported',
|
||||
CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test',
|
||||
CCS_TEST_CODEX_HELP: ' -p, --profile <CONFIG_PROFILE>\n',
|
||||
});
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain('Codex CLI (codex-cli 9.9.9-test)');
|
||||
expect(result.stderr).toContain('does not advertise --config overrides');
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([
|
||||
['-c', 'model="gpt-5"', '--version'],
|
||||
['--help'],
|
||||
['--version'],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -808,8 +836,10 @@ process.exit(0);
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([
|
||||
['--config', 'model_provider="cliproxy"', 'fix failing tests'],
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath).at(-1)).toEqual([
|
||||
'--config',
|
||||
'model_provider="cliproxy"',
|
||||
'fix failing tests',
|
||||
]);
|
||||
const codexConfig = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
||||
expect(codexConfig).toContain('model = "gpt-5.5"');
|
||||
@@ -846,6 +876,32 @@ process.exit(0);
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves ccsxp positional model-like arguments after the option terminator', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const result = runCcsxpAlias(['--', '--', '-m', 'gpt-5.5-high-fast', 'prompt text'], {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
HOME: tmpHome,
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CODEX_PATH: fakeCodexPath,
|
||||
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
|
||||
CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const codexCalls = readLoggedCodexCalls(codexArgsLogPath);
|
||||
expect(codexCalls.at(-1)).toEqual([
|
||||
'--config',
|
||||
'model_provider="cliproxy"',
|
||||
'--',
|
||||
'-m',
|
||||
'gpt-5.5-high-fast',
|
||||
'prompt text',
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes ccsxp native low Codex tuning aliases in config.toml', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
@@ -903,24 +959,24 @@ supports_websockets = false
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([
|
||||
['--config', 'model_provider="cliproxy"', 'fix failing tests'],
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath).at(-1)).toEqual([
|
||||
'--config',
|
||||
'model_provider="cliproxy"',
|
||||
'fix failing tests',
|
||||
]);
|
||||
const codexConfig = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
||||
expect(codexConfig).toContain('env_key = "CCS_CUSTOM_CLIPROXY_TOKEN"');
|
||||
expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([
|
||||
{
|
||||
CODEX_HOME: codexHome,
|
||||
CODEX_CI: undefined,
|
||||
CODEX_MANAGED_BY_BUN: undefined,
|
||||
CODEX_THREAD_ID: undefined,
|
||||
ANTHROPIC_BASE_URL: undefined,
|
||||
CCS_CUSTOM_CLIPROXY_TOKEN: 'ccs-internal-managed',
|
||||
CCS_BROWSER_USER_DATA_DIR: undefined,
|
||||
CCS_BROWSER_PROFILE_DIR: undefined,
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
|
||||
},
|
||||
]);
|
||||
expect(readLoggedCodexEnv(codexEnvLogPath).at(-1)).toEqual({
|
||||
CODEX_HOME: codexHome,
|
||||
CODEX_CI: undefined,
|
||||
CODEX_MANAGED_BY_BUN: undefined,
|
||||
CODEX_THREAD_ID: undefined,
|
||||
ANTHROPIC_BASE_URL: undefined,
|
||||
CCS_CUSTOM_CLIPROXY_TOKEN: 'ccs-internal-managed',
|
||||
CCS_BROWSER_USER_DATA_DIR: undefined,
|
||||
CCS_BROWSER_PROFILE_DIR: undefined,
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves a custom cliproxy provider base_url for ccsxp launches', () => {
|
||||
@@ -954,27 +1010,27 @@ supports_websockets = false
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([
|
||||
['--config', 'model_provider="cliproxy"', 'fix failing tests'],
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath).at(-1)).toEqual([
|
||||
'--config',
|
||||
'model_provider="cliproxy"',
|
||||
'fix failing tests',
|
||||
]);
|
||||
const codexConfig = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
||||
expect(codexConfig).toContain(
|
||||
'base_url = "https://cliproxy.example.com/api/provider/codex/responses"'
|
||||
);
|
||||
expect(codexConfig).toContain('env_key = "CCS_REMOTE_CLIPROXY_TOKEN"');
|
||||
expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([
|
||||
{
|
||||
CODEX_HOME: codexHome,
|
||||
CODEX_CI: undefined,
|
||||
CODEX_MANAGED_BY_BUN: undefined,
|
||||
CODEX_THREAD_ID: undefined,
|
||||
ANTHROPIC_BASE_URL: undefined,
|
||||
CCS_REMOTE_CLIPROXY_TOKEN: 'ccs-internal-managed',
|
||||
CCS_BROWSER_USER_DATA_DIR: undefined,
|
||||
CCS_BROWSER_PROFILE_DIR: undefined,
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
|
||||
},
|
||||
]);
|
||||
expect(readLoggedCodexEnv(codexEnvLogPath).at(-1)).toEqual({
|
||||
CODEX_HOME: codexHome,
|
||||
CODEX_CI: undefined,
|
||||
CODEX_MANAGED_BY_BUN: undefined,
|
||||
CODEX_THREAD_ID: undefined,
|
||||
ANTHROPIC_BASE_URL: undefined,
|
||||
CCS_REMOTE_CLIPROXY_TOKEN: 'ccs-internal-managed',
|
||||
CCS_BROWSER_USER_DATA_DIR: undefined,
|
||||
CCS_BROWSER_PROFILE_DIR: undefined,
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps ccsxp native when the CCS default profile is a Claude account', () => {
|
||||
@@ -1005,22 +1061,22 @@ supports_websockets = false
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([
|
||||
['--config', 'model_provider="cliproxy"', 'fix failing tests'],
|
||||
]);
|
||||
expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([
|
||||
{
|
||||
CODEX_HOME: path.join(tmpHome, '.codex'),
|
||||
CODEX_CI: undefined,
|
||||
CODEX_MANAGED_BY_BUN: undefined,
|
||||
CODEX_THREAD_ID: undefined,
|
||||
ANTHROPIC_BASE_URL: undefined,
|
||||
CLIPROXY_API_KEY: 'ccs-internal-managed',
|
||||
CCS_BROWSER_USER_DATA_DIR: undefined,
|
||||
CCS_BROWSER_PROFILE_DIR: undefined,
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
|
||||
},
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath).at(-1)).toEqual([
|
||||
'--config',
|
||||
'model_provider="cliproxy"',
|
||||
'fix failing tests',
|
||||
]);
|
||||
expect(readLoggedCodexEnv(codexEnvLogPath).at(-1)).toEqual({
|
||||
CODEX_HOME: path.join(tmpHome, '.codex'),
|
||||
CODEX_CI: undefined,
|
||||
CODEX_MANAGED_BY_BUN: undefined,
|
||||
CODEX_THREAD_ID: undefined,
|
||||
ANTHROPIC_BASE_URL: undefined,
|
||||
CLIPROXY_API_KEY: 'ccs-internal-managed',
|
||||
CCS_BROWSER_USER_DATA_DIR: undefined,
|
||||
CCS_BROWSER_PROFILE_DIR: undefined,
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps implicit ccs --target codex launches native when the CCS default is a Claude account', () => {
|
||||
@@ -1045,6 +1101,7 @@ supports_websockets = false
|
||||
HOME: tmpHome,
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CODEX_PATH: fakeCodexPath,
|
||||
CODEX_HOME: undefined,
|
||||
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
|
||||
CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath,
|
||||
CCS_THINKING: '8192',
|
||||
@@ -1135,7 +1192,9 @@ supports_websockets = false
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).not.toContain('Codex CLI does not support Claude account-based profiles.');
|
||||
expect(result.stderr).not.toContain(
|
||||
'Codex CLI does not support Claude account-based profiles.'
|
||||
);
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([['fix failing tests']]);
|
||||
expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([
|
||||
{
|
||||
@@ -1205,19 +1264,17 @@ supports_websockets = false
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([
|
||||
{
|
||||
CODEX_HOME: explicitCodexHome,
|
||||
CODEX_CI: undefined,
|
||||
CODEX_MANAGED_BY_BUN: undefined,
|
||||
CODEX_THREAD_ID: undefined,
|
||||
ANTHROPIC_BASE_URL: undefined,
|
||||
CLIPROXY_API_KEY: 'ccs-internal-managed',
|
||||
CCS_BROWSER_USER_DATA_DIR: undefined,
|
||||
CCS_BROWSER_PROFILE_DIR: undefined,
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
|
||||
},
|
||||
]);
|
||||
expect(readLoggedCodexEnv(codexEnvLogPath).at(-1)).toEqual({
|
||||
CODEX_HOME: explicitCodexHome,
|
||||
CODEX_CI: undefined,
|
||||
CODEX_MANAGED_BY_BUN: undefined,
|
||||
CODEX_THREAD_ID: undefined,
|
||||
ANTHROPIC_BASE_URL: undefined,
|
||||
CLIPROXY_API_KEY: 'ccs-internal-managed',
|
||||
CCS_BROWSER_USER_DATA_DIR: undefined,
|
||||
CCS_BROWSER_PROFILE_DIR: undefined,
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails with a clean CLI error when ccsxp receives a malformed --target flag', () => {
|
||||
|
||||
@@ -68,9 +68,9 @@ describe('resolveTargetType', () => {
|
||||
expect(resolveTargetType([], { target: 'invalid-target' as never })).toBe('claude');
|
||||
});
|
||||
|
||||
it('should ignore runtime-only codex target when it appears in persisted profile config', () => {
|
||||
it('should use codex from persisted profile config', () => {
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(resolveTargetType([], { target: 'codex' })).toBe('claude');
|
||||
expect(resolveTargetType([], { target: 'codex' })).toBe('codex');
|
||||
});
|
||||
|
||||
it('should prioritize --target flag over profile config', () => {
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
} from '../../../ui/src/lib/cliproxy-version-risk';
|
||||
|
||||
describe('cliproxy-version-risk helpers', () => {
|
||||
it('compares versions while ignoring release suffixes', () => {
|
||||
it('compares fork release suffixes after core versions', () => {
|
||||
expect(compareCliproxyVersions('6.6.88', '6.6.81-0')).toBe(1);
|
||||
expect(compareCliproxyVersions('6.6.81-0', '6.6.81')).toBe(0);
|
||||
expect(compareCliproxyVersions('7.1.31-1', '7.1.31-0')).toBe(1);
|
||||
expect(compareCliproxyVersions('7.1.31-0', '7.1.31-1')).toBe(-1);
|
||||
expect(compareCliproxyVersions('6.6.80', '6.6.81')).toBe(-1);
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ describe('browser status', () => {
|
||||
let originalBrowserUserDataDir: string | undefined;
|
||||
let originalBrowserProfileDir: string | undefined;
|
||||
let originalBrowserDevtoolsPort: string | undefined;
|
||||
let originalBrowserEvalMode: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-status-'));
|
||||
@@ -29,11 +30,13 @@ describe('browser status', () => {
|
||||
originalBrowserUserDataDir = process.env.CCS_BROWSER_USER_DATA_DIR;
|
||||
originalBrowserProfileDir = process.env.CCS_BROWSER_PROFILE_DIR;
|
||||
originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT;
|
||||
originalBrowserEvalMode = process.env.CCS_BROWSER_EVAL_MODE;
|
||||
|
||||
process.env.CCS_HOME = tempHome;
|
||||
delete process.env.CCS_BROWSER_USER_DATA_DIR;
|
||||
delete process.env.CCS_BROWSER_PROFILE_DIR;
|
||||
delete process.env.CCS_BROWSER_DEVTOOLS_PORT;
|
||||
delete process.env.CCS_BROWSER_EVAL_MODE;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -60,6 +63,11 @@ describe('browser status', () => {
|
||||
} else {
|
||||
delete process.env.CCS_BROWSER_DEVTOOLS_PORT;
|
||||
}
|
||||
if (originalBrowserEvalMode !== undefined) {
|
||||
process.env.CCS_BROWSER_EVAL_MODE = originalBrowserEvalMode;
|
||||
} else {
|
||||
delete process.env.CCS_BROWSER_EVAL_MODE;
|
||||
}
|
||||
|
||||
rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
@@ -290,6 +298,29 @@ describe('browser status', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('honors CCS_BROWSER_EVAL_MODE over configured eval_mode', () => {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: 'manual',
|
||||
user_data_dir: '/config-browser',
|
||||
devtools_port: 9333,
|
||||
eval_mode: 'readonly',
|
||||
},
|
||||
codex: {
|
||||
enabled: false,
|
||||
policy: 'manual',
|
||||
},
|
||||
};
|
||||
});
|
||||
process.env.CCS_BROWSER_EVAL_MODE = 'disabled';
|
||||
|
||||
const effective = getEffectiveClaudeBrowserAttachConfig(getBrowserConfig());
|
||||
|
||||
expect(effective.evalMode).toBe('disabled');
|
||||
});
|
||||
|
||||
it('returns the same managed attach warning when the configured DevTools port is unreachable', async () => {
|
||||
const managedDir = join(tempHome, '.ccs', 'browser', 'chrome-user-data');
|
||||
mkdirSync(managedDir, { recursive: true });
|
||||
|
||||
@@ -145,8 +145,16 @@ function registerChildProcessMock(): void {
|
||||
}));
|
||||
}
|
||||
|
||||
const tempCcsHomes = new Set<string>();
|
||||
|
||||
function createTempCcsHome(prefix: string): string {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
tempCcsHomes.add(tempHome);
|
||||
return tempHome;
|
||||
}
|
||||
|
||||
function writeConfigWithAutoUpdatePreference(enabled: boolean): void {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-auto-update-pref-'));
|
||||
const tempHome = createTempCcsHome('ccs-auto-update-pref-');
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
@@ -158,7 +166,7 @@ preferences:
|
||||
}
|
||||
|
||||
function writeConfigWithWebSearchSettings(yamlBody: string): void {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-websearch-env-'));
|
||||
const tempHome = createTempCcsHome('ccs-websearch-env-');
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
@@ -225,10 +233,8 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const tempCcsHome = process.env.CCS_HOME?.startsWith(os.tmpdir())
|
||||
? process.env.CCS_HOME
|
||||
: undefined;
|
||||
if (tempCcsHome) {
|
||||
const tempCcsHome = process.env.CCS_HOME;
|
||||
if (tempCcsHome && tempCcsHomes.has(tempCcsHome)) {
|
||||
await stopOpenAICompatProxy();
|
||||
}
|
||||
|
||||
@@ -277,9 +283,10 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (tempCcsHome) {
|
||||
fs.rmSync(tempCcsHome, { recursive: true, force: true });
|
||||
for (const tempHome of tempCcsHomes) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
tempCcsHomes.clear();
|
||||
});
|
||||
|
||||
it('stripClaudeCodeEnv removes CLAUDECODE case-insensitively', () => {
|
||||
|
||||
@@ -20,6 +20,9 @@ describe('glmt deprecation helpers', () => {
|
||||
expect(isLegacyGlmtBaseUrl('https://api.z.ai/api/coding/paas/v4/chat/completions/')).toBe(
|
||||
true
|
||||
);
|
||||
expect(isLegacyGlmtBaseUrl('https://private.example.internal/v1/chat/completions')).toBe(
|
||||
false
|
||||
);
|
||||
expect(isLegacyGlmtBaseUrl('https://api.z.ai/api/anthropic')).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,12 +3,20 @@ import {
|
||||
DEFAULT_IMAGE_ANALYSIS_CONFIG,
|
||||
type ImageAnalysisConfig,
|
||||
} from '../../../../src/config/unified-config-types';
|
||||
import { findModel } from '../../../../src/cliproxy/model-catalog';
|
||||
import {
|
||||
canonicalizeImageAnalysisConfig,
|
||||
resolveImageAnalysisStatus,
|
||||
} from '../../../../src/utils/hooks/image-analysis-backend-resolver';
|
||||
|
||||
describe('image-analysis-backend-resolver', () => {
|
||||
it('uses a catalog-backed Claude image analysis default model', () => {
|
||||
const defaultModel = DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models.claude;
|
||||
|
||||
expect(defaultModel).toBe('claude-haiku-4-5-20251001');
|
||||
expect(findModel('claude', defaultModel)?.id).toBe(defaultModel);
|
||||
});
|
||||
|
||||
it('canonicalizes provider aliases in config', () => {
|
||||
const config = canonicalizeImageAnalysisConfig({
|
||||
enabled: true,
|
||||
|
||||
@@ -313,6 +313,12 @@ describe('web-server claude-extension-routes', () => {
|
||||
|
||||
it('creates a binding and applies managed settings to shared + IDE targets', async () => {
|
||||
const ideSettingsPath = path.join(tempHome, 'ide', 'vscode', 'settings.json');
|
||||
const sharedSettingsPath = path.join(tempHome, '.claude', 'settings.json');
|
||||
fs.mkdirSync(path.dirname(ideSettingsPath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(sharedSettingsPath), { recursive: true });
|
||||
fs.writeFileSync(sharedSettingsPath, JSON.stringify({ env: {} }, null, 2) + '\n', { mode: 0o600 });
|
||||
fs.writeFileSync(ideSettingsPath, JSON.stringify({}, null, 2) + '\n', { mode: 0o600 });
|
||||
|
||||
const createResponse = await fetch(`${baseUrl}/api/claude-extension/bindings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -352,7 +358,6 @@ describe('web-server claude-extension-routes', () => {
|
||||
expect(applied.sharedSettings.state).toBe('applied');
|
||||
expect(applied.ideSettings.state).toBe('applied');
|
||||
|
||||
const sharedSettingsPath = path.join(tempHome, '.claude', 'settings.json');
|
||||
const sharedSettings = JSON.parse(fs.readFileSync(sharedSettingsPath, 'utf8')) as {
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
@@ -370,6 +375,9 @@ describe('web-server claude-extension-routes', () => {
|
||||
)
|
||||
).toBe(true);
|
||||
expect(ideSettings['claudeCode.disableLoginPrompt']).toBe(true);
|
||||
|
||||
expect(fs.statSync(sharedSettingsPath).mode & 0o777).toBe(0o600);
|
||||
expect(fs.statSync(ideSettingsPath).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('resets only managed keys and preserves unrelated shared + IDE settings', async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as path from 'path';
|
||||
import * as http from 'http';
|
||||
import type { Server } from 'http';
|
||||
import cliproxyAuthRoutes from '../../../src/web-server/routes/cliproxy-auth-routes';
|
||||
import { registerAccountFromToken } from '../../../src/cliproxy/auth/token-manager';
|
||||
import {
|
||||
clearQuotaCache,
|
||||
getCachedQuota,
|
||||
@@ -593,6 +594,61 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('reauthenticates a targeted existing Codex account when its token file is rewritten', async () => {
|
||||
const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
|
||||
fs.mkdirSync(tokenDir, { recursive: true });
|
||||
const tokenPath = path.join(tokenDir, 'codex-existing@example.com.json');
|
||||
fs.writeFileSync(
|
||||
tokenPath,
|
||||
JSON.stringify({ type: 'codex', email: 'existing@example.com', version: 1 }),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const initialAccount = registerAccountFromToken('codex', tokenDir, 'work');
|
||||
expect(initialAccount?.id).toBe('existing@example.com');
|
||||
|
||||
mockFetch([
|
||||
{
|
||||
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
|
||||
response: {
|
||||
auth_url: 'https://auth.example.com/authorize?state=state-targeted-rewrite',
|
||||
state: 'state-targeted-rewrite',
|
||||
},
|
||||
},
|
||||
{
|
||||
url: /\/v0\/management\/get-auth-status\?state=state-targeted-rewrite$/,
|
||||
response: { status: 'ok' },
|
||||
},
|
||||
]);
|
||||
|
||||
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {
|
||||
accountId: 'existing@example.com',
|
||||
});
|
||||
expect(startResponse.status).toBe(200);
|
||||
|
||||
fs.writeFileSync(
|
||||
tokenPath,
|
||||
JSON.stringify({ type: 'codex', email: 'existing@example.com', version: 2 }),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const statusResponse = await getJson(
|
||||
'/api/cliproxy/auth/codex/status?state=state-targeted-rewrite'
|
||||
);
|
||||
|
||||
expect(statusResponse.status).toBe(200);
|
||||
expect(statusResponse.body).toEqual({
|
||||
status: 'ok',
|
||||
account: {
|
||||
id: 'existing@example.com',
|
||||
email: 'existing@example.com',
|
||||
nickname: 'work',
|
||||
provider: 'codex',
|
||||
isDefault: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('registers the new account before reporting polled auth success', async () => {
|
||||
mockFetch([
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
getKiroStartIDCValidationError,
|
||||
getReauthAccountTarget,
|
||||
getStartAuthFailureMessage,
|
||||
getStartAuthNicknameError,
|
||||
getStartUrlUnsupportedReason,
|
||||
@@ -17,6 +18,7 @@ describe('cliproxy-auth-routes start-url guard', () => {
|
||||
"Provider 'codebuddy' uses Device Code flow"
|
||||
);
|
||||
expect(getStartUrlUnsupportedReason('kilo')).toContain("Provider 'kilo' uses Device Code flow");
|
||||
expect(getStartUrlUnsupportedReason('qoder')).toContain("Provider 'qoder' uses Device Code flow");
|
||||
});
|
||||
|
||||
it('allows Cursor browser URL auth on start-url', () => {
|
||||
@@ -142,3 +144,27 @@ describe('cliproxy-auth-routes nickname validation', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cliproxy-auth-routes reauth account targeting', () => {
|
||||
const existingAccounts = [
|
||||
{ id: 'codex-user@example.com', nickname: 'work' },
|
||||
{ id: 'codex-personal@example.com', nickname: 'personal' },
|
||||
];
|
||||
|
||||
it('does not require a target for normal add-account auth', () => {
|
||||
expect(getReauthAccountTarget(undefined, existingAccounts)).toEqual({});
|
||||
expect(getReauthAccountTarget('', existingAccounts)).toEqual({});
|
||||
});
|
||||
|
||||
it('resolves an existing account target for reauth', () => {
|
||||
expect(getReauthAccountTarget('codex-user@example.com', existingAccounts)).toEqual({
|
||||
account: { id: 'codex-user@example.com', nickname: 'work' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unknown account targets instead of falling back to ambiguous registration', () => {
|
||||
expect(getReauthAccountTarget('missing', existingAccounts)).toEqual({
|
||||
error: "Account 'missing' not found for this provider",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -112,6 +112,21 @@ describe('cliproxy usage syncer', () => {
|
||||
const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json');
|
||||
expect(fs.existsSync(snapshotPath)).toBe(true);
|
||||
|
||||
const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')) as {
|
||||
details: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(snapshot.details[0]).not.toHaveProperty('source');
|
||||
expect(snapshot.details[0]).not.toHaveProperty('authIndex');
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
const cacheDir = path.join(ccsDir, 'cache');
|
||||
const cliproxyCacheDir = path.dirname(snapshotPath);
|
||||
expect(fs.statSync(ccsDir).mode & 0o777).toBe(0o700);
|
||||
expect(fs.statSync(cacheDir).mode & 0o777).toBe(0o700);
|
||||
expect(fs.statSync(cliproxyCacheDir).mode & 0o777).toBe(0o700);
|
||||
expect(fs.statSync(snapshotPath).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
|
||||
const cached = await runWithScopedConfigDir(ccsDir, async () => {
|
||||
return await loadCachedCliproxyData();
|
||||
});
|
||||
|
||||
@@ -110,6 +110,8 @@ describe('cliproxy usage transformer', () => {
|
||||
const flat = extractCliproxyUsageHistoryDetails(sampleResponse);
|
||||
expect(flat).toHaveLength(4);
|
||||
expect(flat[0].provider).toBe('google');
|
||||
expect(flat[0]).not.toHaveProperty('source');
|
||||
expect(flat[0]).not.toHaveProperty('authIndex');
|
||||
expect(
|
||||
flat.some(
|
||||
(entry) => entry.failed === true && entry.inputTokens === 40 && entry.outputTokens === 10
|
||||
@@ -160,8 +162,8 @@ describe('cliproxy usage transformer', () => {
|
||||
const legacyDetail = normalizeCliproxyUsageHistoryDetail({
|
||||
model: incoming.model,
|
||||
timestamp: incoming.timestamp,
|
||||
source: incoming.source,
|
||||
authIndex: incoming.authIndex,
|
||||
source: 'account-a',
|
||||
authIndex: '0',
|
||||
inputTokens: incoming.inputTokens,
|
||||
outputTokens: incoming.outputTokens,
|
||||
cacheReadTokens: incoming.cacheReadTokens,
|
||||
@@ -170,6 +172,8 @@ describe('cliproxy usage transformer', () => {
|
||||
|
||||
expect(legacyDetail?.requestCount).toBe(1);
|
||||
expect(Number.isFinite(legacyDetail?.cost)).toBe(true);
|
||||
expect(legacyDetail).not.toHaveProperty('source');
|
||||
expect(legacyDetail).not.toHaveProperty('authIndex');
|
||||
|
||||
const merged = mergeCliproxyUsageHistoryDetails([legacyDetail!], [incoming]);
|
||||
expect(merged).toHaveLength(1);
|
||||
@@ -177,6 +181,19 @@ describe('cliproxy usage transformer', () => {
|
||||
expect(merged[0].cost).toBe(incoming.cost);
|
||||
});
|
||||
|
||||
it('strips legacy account identifiers when merging persisted history', () => {
|
||||
const details = extractCliproxyUsageHistoryDetails(sampleResponse);
|
||||
const legacyDetail = {
|
||||
...details[0],
|
||||
source: 'user@example.com',
|
||||
authIndex: 'auth-file-7',
|
||||
};
|
||||
const merged = mergeCliproxyUsageHistoryDetails([legacyDetail], []);
|
||||
|
||||
expect(merged[0]).not.toHaveProperty('source');
|
||||
expect(merged[0]).not.toHaveProperty('authIndex');
|
||||
});
|
||||
|
||||
it('preserves legitimate duplicate requests when the incoming batch has more occurrences', () => {
|
||||
const details = extractCliproxyUsageHistoryDetails(sampleResponse);
|
||||
const repeated = [details[0], { ...details[0] }];
|
||||
|
||||
@@ -311,6 +311,28 @@ describe('codex native usage collector', () => {
|
||||
expect(entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('writes native usage caches with owner-only permissions', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
writeCodexRollout(tempRoot);
|
||||
const cacheDir = getCacheDir();
|
||||
const previousUmask = process.umask(0o022);
|
||||
|
||||
try {
|
||||
await scanCodexNativeUsageEntries({
|
||||
env: { CODEX_HOME: tempRoot },
|
||||
homeDir: tempRoot,
|
||||
cacheDir,
|
||||
});
|
||||
} finally {
|
||||
process.umask(previousUmask);
|
||||
}
|
||||
|
||||
const cachePath = path.join(cacheDir, 'codex-native-usage-v1.json');
|
||||
expect(fs.statSync(cacheDir).mode & 0o777).toBe(0o700);
|
||||
expect(fs.statSync(cachePath).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('keeps default and include-cliproxy cache entries separate', async () => {
|
||||
writeCodexRollout(tempRoot, { modelProvider: 'cliproxy' });
|
||||
const cacheDir = getCacheDir();
|
||||
|
||||
@@ -510,6 +510,17 @@ describe('Cursor Routes Logic', () => {
|
||||
expect(json.current).toBe('gpt-5.3-codex');
|
||||
});
|
||||
|
||||
it('POST /api/cursor/probe rejects cross-origin requests', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/cursor/probe`, {
|
||||
method: 'POST',
|
||||
headers: { Origin: 'https://attacker.example' },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
const json = (await res.json()) as { error?: string };
|
||||
expect(json.error).toContain('Cross-origin probe requests are not allowed.');
|
||||
});
|
||||
|
||||
it('POST /api/cursor/probe returns auth failure when credentials are missing', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/cursor/probe`, { method: 'POST' });
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
@@ -340,4 +340,31 @@ describe('image-analysis routes', () => {
|
||||
error: 'Profile mapping for "codexProfile" references an unknown backend.',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unsupported provider backends and preserves existing configuration', async () => {
|
||||
const putResponse = await fetch(`${baseUrl}/api/image-analysis`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
providerModels: {
|
||||
gemini: 'gemini-3-flash-preview',
|
||||
evil: 'vision-model',
|
||||
},
|
||||
fallbackBackend: 'gemini',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(putResponse.status).toBe(400);
|
||||
expect(await putResponse.json()).toEqual({
|
||||
error: 'Unsupported provider backend "evil".',
|
||||
});
|
||||
|
||||
const getResponse = await fetch(`${baseUrl}/api/image-analysis`);
|
||||
expect(getResponse.status).toBe(200);
|
||||
const payload = await getResponse.json();
|
||||
expect(payload.config.providerModels).toMatchObject({
|
||||
gemini: 'gemini-3-flash-preview',
|
||||
ghcp: 'claude-haiku-4.5',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,11 +6,20 @@ import profileRoutes from '../../../src/web-server/routes/profile-routes';
|
||||
describe('profile-routes local runtime readiness', () => {
|
||||
let server: Server;
|
||||
let baseUrl = '';
|
||||
let forcedRemoteAddress = '127.0.0.1';
|
||||
let originalDashboardAuthEnabled: string | undefined;
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
Object.defineProperty(req.socket, 'remoteAddress', {
|
||||
value: forcedRemoteAddress,
|
||||
configurable: true,
|
||||
});
|
||||
next();
|
||||
});
|
||||
app.use('/api/profiles', profileRoutes);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -36,6 +45,10 @@ describe('profile-routes local runtime readiness', () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
|
||||
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false';
|
||||
forcedRemoteAddress = '127.0.0.1';
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
|
||||
@@ -73,6 +86,12 @@ describe('profile-routes local runtime readiness', () => {
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
|
||||
if (originalDashboardAuthEnabled !== undefined) {
|
||||
process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled;
|
||||
} else {
|
||||
delete process.env.CCS_DASHBOARD_AUTH_ENABLED;
|
||||
}
|
||||
});
|
||||
|
||||
it('reports local runtimes as ready when their endpoints respond with models', async () => {
|
||||
@@ -103,6 +122,30 @@ describe('profile-routes local runtime readiness', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks remote readiness probes when dashboard auth is disabled', async () => {
|
||||
let probeCount = 0;
|
||||
forcedRemoteAddress = '10.10.0.24';
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.startsWith(baseUrl)) {
|
||||
return originalFetch(input);
|
||||
}
|
||||
probeCount += 1;
|
||||
return new Response(JSON.stringify({ models: [{ name: 'gemma4:e4b' }] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/profiles/local-runtime-readiness`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'Local runtime readiness requires localhost access when dashboard auth is disabled.',
|
||||
});
|
||||
expect(probeCount).toBe(0);
|
||||
});
|
||||
|
||||
it('reports setup guidance when local endpoints are unavailable', async () => {
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
|
||||
@@ -7,16 +7,16 @@ describe('route target parsing', () => {
|
||||
it('accepts valid target values', () => {
|
||||
expect(parseProfileTarget('claude')).toBe('claude');
|
||||
expect(parseProfileTarget('DROID')).toBe('droid');
|
||||
expect(parseProfileTarget('codex')).toBe('codex');
|
||||
expect(parseVariantTarget(' claude ')).toBe('claude');
|
||||
expect(parseVariantTarget('droid')).toBe('droid');
|
||||
expect(parseVariantTarget(' codex ')).toBe('codex');
|
||||
});
|
||||
|
||||
it('returns null for invalid target values', () => {
|
||||
expect(parseProfileTarget('glm')).toBeNull();
|
||||
expect(parseProfileTarget('codex')).toBeNull();
|
||||
expect(parseProfileTarget('')).toBeNull();
|
||||
expect(parseVariantTarget('factory')).toBeNull();
|
||||
expect(parseVariantTarget('codex')).toBeNull();
|
||||
expect(parseVariantTarget(' ')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -83,11 +83,7 @@ function writeAssistantEntriesToDir(baseClaudeDir: string, entries: AssistantFix
|
||||
},
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, `${entry.sessionId}.jsonl`),
|
||||
`${line}\n`,
|
||||
'utf-8'
|
||||
);
|
||||
fs.writeFileSync(path.join(projectDir, `${entry.sessionId}.jsonl`), `${line}\n`, 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,7 +218,10 @@ describe('usage handlers semantics', () => {
|
||||
res as never
|
||||
);
|
||||
|
||||
const payload = res.payload as { success: boolean; data: Array<{ hour: string; requests: number }> };
|
||||
const payload = res.payload as {
|
||||
success: boolean;
|
||||
data: Array<{ hour: string; requests: number }>;
|
||||
};
|
||||
const targetHour = payload.data.find((row) => row.hour === '2026-03-02 10:00');
|
||||
|
||||
expect(targetHour?.requests).toBe(3);
|
||||
@@ -415,6 +414,19 @@ describe('usage handlers semantics', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-string profile filters as validation errors', async () => {
|
||||
for (const profile of [['work', 'default'], { name: 'work' }]) {
|
||||
const res = createMockResponse();
|
||||
await handlers.handleSummary({ query: { profile } } as never, res as never);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.payload).toMatchObject({
|
||||
success: false,
|
||||
error: 'Invalid profile filter',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects reversed date ranges before computing summary totals', async () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
|
||||
Reference in new issue
Block a user