mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 16:19:27 +00:00
Merge remote-tracking branch 'origin/dev' into codex/fix-logs-filtering-to-show-dashboard-audits
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 160 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
@@ -48,6 +48,7 @@ jobs:
|
||||
with:
|
||||
app-id: ${{ secrets.CCS_REVIEWER_APP_ID }}
|
||||
private-key: ${{ secrets.CCS_REVIEWER_PRIVATE_KEY }}
|
||||
permission-issues: write
|
||||
|
||||
- name: Post /review command to the PR
|
||||
uses: actions/github-script@v7
|
||||
@@ -111,6 +112,9 @@ jobs:
|
||||
with:
|
||||
app-id: ${{ secrets.CCS_REVIEWER_APP_ID }}
|
||||
private-key: ${{ secrets.CCS_REVIEWER_PRIVATE_KEY }}
|
||||
permission-issues: write
|
||||
permission-pull-requests: write
|
||||
permission-contents: read
|
||||
|
||||
- name: Run PR-Agent
|
||||
uses: qodo-ai/pr-agent@v0.34
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
if: steps.tag.outputs.publish == 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.target.outputs.tag }}
|
||||
ref: ${{ format('refs/tags/{0}', steps.target.outputs.tag) }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
if: steps.tag.outputs.publish == 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.target.outputs.tag }}
|
||||
ref: ${{ format('refs/tags/{0}', steps.target.outputs.tag) }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up QEMU
|
||||
@@ -260,7 +260,7 @@ jobs:
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
context: docker
|
||||
file: docker/Dockerfile.integrated
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
@@ -314,7 +314,7 @@ jobs:
|
||||
- name: Checkout release tag (for test scripts)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.publish-integrated.outputs.version != '' && format('v{0}', needs.publish-integrated.outputs.version) || github.ref }}
|
||||
ref: ${{ needs.publish-integrated.outputs.version != '' && format('refs/tags/v{0}', needs.publish-integrated.outputs.version) || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Derive image reference
|
||||
@@ -332,6 +332,17 @@ jobs:
|
||||
- name: Pull image
|
||||
run: docker pull "${{ steps.image.outputs.ref }}"
|
||||
|
||||
- name: Verify anonymous pull access
|
||||
run: |
|
||||
CLEAN_DOCKER_CONFIG="$(mktemp -d)"
|
||||
trap 'rm -rf "${CLEAN_DOCKER_CONFIG}"' EXIT
|
||||
if ! DOCKER_CONFIG="${CLEAN_DOCKER_CONFIG}" docker pull "${{ steps.image.outputs.ref }}"; then
|
||||
echo "[X] ghcr.io/kaitranntt/ccs is not anonymously pullable." >&2
|
||||
echo "[i] Make the GHCR package public, then rerun the Docker publish workflow." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[OK] Anonymous pull succeeded for ${{ steps.image.outputs.ref }}"
|
||||
|
||||
- name: Assert image size budget (amd64)
|
||||
run: |
|
||||
chmod +x tests/docker/image-size.sh
|
||||
@@ -471,3 +482,24 @@ jobs:
|
||||
"${IMAGE_REF}"
|
||||
|
||||
echo "[OK] Promoted: :latest :${MINOR} :${MAJOR} → ${IMAGE_REF}"
|
||||
|
||||
- name: Verify promoted tags are anonymously pullable
|
||||
env:
|
||||
VERSION: ${{ needs.publish-integrated.outputs.version }}
|
||||
run: |
|
||||
OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')
|
||||
IMAGE="ghcr.io/${OWNER_LOWER}/ccs"
|
||||
MINOR="${VERSION%.*}"
|
||||
MAJOR="${VERSION%%.*}"
|
||||
CLEAN_DOCKER_CONFIG="$(mktemp -d)"
|
||||
trap 'rm -rf "${CLEAN_DOCKER_CONFIG}"' EXIT
|
||||
|
||||
for TAG in latest "${MINOR}" "${MAJOR}"; do
|
||||
REF="${IMAGE}:${TAG}"
|
||||
if ! DOCKER_CONFIG="${CLEAN_DOCKER_CONFIG}" docker pull "${REF}"; then
|
||||
echo "[X] ${REF} is not anonymously pullable." >&2
|
||||
echo "[i] Confirm the GHCR package is public, then rerun promotion." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[OK] Anonymous pull succeeded for ${REF}"
|
||||
done
|
||||
|
||||
@@ -102,74 +102,4 @@ jobs:
|
||||
if: success() && steps.release.outputs.released == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION=$(jq -r '.version' package.json)
|
||||
RELEASE_BODY=$(gh release view "v${VERSION}" --repo "${{ github.repository }}" --json body --jq '.body' 2>/dev/null || echo "")
|
||||
PREVIOUS_STABLE_TAG=$(git tag -l "v[0-9]*.[0-9]*.[0-9]" --sort=-v:refname | \
|
||||
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | \
|
||||
grep -vx "v${VERSION}" | head -1 || echo "")
|
||||
RANGE="${PREVIOUS_STABLE_TAG:+${PREVIOUS_STABLE_TAG}..HEAD~1}"
|
||||
if [[ -z "$RANGE" ]]; then
|
||||
RANGE="HEAD~50..HEAD~1"
|
||||
fi
|
||||
|
||||
COMMIT_TEXT=$(git log $RANGE --pretty=format:"%s%n%b" 2>/dev/null || true)
|
||||
PR_CANDIDATES=$(printf '%s\n' "$COMMIT_TEXT" | \
|
||||
grep -oE "Merge pull request #[0-9]+|\\(#[0-9]+\\)" | \
|
||||
grep -oE "[0-9]+" | sort -u || true)
|
||||
|
||||
PR_TEXT=""
|
||||
for PR_NUM in $PR_CANDIDATES; do
|
||||
DETAILS=$(gh pr view "$PR_NUM" --repo "${{ github.repository }}" --json title,body --jq '.title + "\n" + (.body // "")' 2>/dev/null || true)
|
||||
if [[ -n "$DETAILS" ]]; then
|
||||
PR_TEXT="${PR_TEXT}"$'\n'"${DETAILS}"
|
||||
fi
|
||||
done
|
||||
|
||||
RELEASE_ISSUES_FROM_BODY=$(printf '%s\n' "$RELEASE_BODY" | \
|
||||
perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \
|
||||
grep -oE '#[0-9]+' | tr -d '#' || true)
|
||||
RELEASE_ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \
|
||||
perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \
|
||||
grep -oE '#[0-9]+' | tr -d '#' || true)
|
||||
RELEASE_ISSUES_FROM_PRS=$(printf '%s\n' "$PR_TEXT" | \
|
||||
perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \
|
||||
grep -oE '#[0-9]+' | tr -d '#' || true)
|
||||
RELEASE_ISSUES=$(printf '%s\n%s\n%s\n' \
|
||||
"$RELEASE_ISSUES_FROM_BODY" \
|
||||
"$RELEASE_ISSUES_FROM_COMMITS" \
|
||||
"$RELEASE_ISSUES_FROM_PRS" | sort -u || true)
|
||||
|
||||
RESOLVED_ISSUES_FROM_BODY=$(printf '%s\n' "$RELEASE_BODY" | \
|
||||
perl -ne 'if (/(fixes|closes|resolves)(.*)/i) { print "$2\n"; }' | \
|
||||
grep -oE '#[0-9]+' | tr -d '#' || true)
|
||||
RESOLVED_ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \
|
||||
perl -ne 'if (/(fixes|closes|resolves)(.*)/i) { print "$2\n"; }' | \
|
||||
grep -oE '#[0-9]+' | tr -d '#' || true)
|
||||
RESOLVED_ISSUES_FROM_PRS=$(printf '%s\n' "$PR_TEXT" | \
|
||||
perl -ne 'if (/(fixes|closes|resolves)(.*)/i) { print "$2\n"; }' | \
|
||||
grep -oE '#[0-9]+' | tr -d '#' || true)
|
||||
RESOLVED_ISSUES=$(printf '%s\n%s\n%s\n' \
|
||||
"$RESOLVED_ISSUES_FROM_BODY" \
|
||||
"$RESOLVED_ISSUES_FROM_COMMITS" \
|
||||
"$RESOLVED_ISSUES_FROM_PRS" | sort -u || true)
|
||||
|
||||
if [[ -z "$RELEASE_ISSUES" ]]; then
|
||||
echo "No release-scoped issues found for v${VERSION}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for NUM in $RELEASE_ISSUES; do
|
||||
echo "Cleaning release state on issue #$NUM"
|
||||
gh issue edit "$NUM" \
|
||||
--remove-label "released-dev" \
|
||||
--remove-label "pending-release" \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || true
|
||||
|
||||
HAS_RELEASED=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json labels --jq '[.labels[].name | select(. == "released")] | length' 2>/dev/null || echo "0")
|
||||
if [[ "$HAS_RELEASED" -gt 0 ]] && printf '%s\n' "$RESOLVED_ISSUES" | grep -qx "$NUM"; then
|
||||
gh issue close "$NUM" \
|
||||
--comment "[bot] Closing issue because this fix/feature is now in stable release (@latest)." \
|
||||
--repo "${{ github.repository }}" || true
|
||||
fi
|
||||
done
|
||||
run: node scripts/github/stable-release-issue-cleanup.mjs
|
||||
|
||||
@@ -1,3 +1,27 @@
|
||||
## [8.1.4](https://github.com/kaitranntt/ccs/compare/v8.1.3...v8.1.4) (2026-05-29)
|
||||
|
||||
### Hotfixes
|
||||
|
||||
* avoid Docker smoke port collisions ([#1412](https://github.com/kaitranntt/ccs/issues/1412)) ([c3d8dcb](https://github.com/kaitranntt/ccs/commit/c3d8dcbdd929e29b529c40afc965fea14af15ea1))
|
||||
|
||||
## [8.1.3](https://github.com/kaitranntt/ccs/compare/v8.1.2...v8.1.3) (2026-05-29)
|
||||
|
||||
### Hotfixes
|
||||
|
||||
* add integrated Docker healthcheck ([0e3383d](https://github.com/kaitranntt/ccs/commit/0e3383d31da6c8c882760a86890c0dd915af50db)), closes [#1400](https://github.com/kaitranntt/ccs/issues/1400)
|
||||
|
||||
## [8.1.2](https://github.com/kaitranntt/ccs/compare/v8.1.1...v8.1.2) (2026-05-29)
|
||||
|
||||
### Hotfixes
|
||||
|
||||
* repair Docker image size inspection ([731d43e](https://github.com/kaitranntt/ccs/commit/731d43ec9a331fa34b77c9abc18d4608dde27252)), closes [#1400](https://github.com/kaitranntt/ccs/issues/1400)
|
||||
|
||||
## [8.1.1](https://github.com/kaitranntt/ccs/compare/v8.1.0...v8.1.1) (2026-05-29)
|
||||
|
||||
### Hotfixes
|
||||
|
||||
* repair Docker release publishing ([d49bbda](https://github.com/kaitranntt/ccs/commit/d49bbdaec3a1b391a819653f15985b3b48fdd5d2)), closes [#1400](https://github.com/kaitranntt/ccs/issues/1400)
|
||||
|
||||
## [8.1.0](https://github.com/kaitranntt/ccs/compare/v8.0.0...v8.1.0) (2026-05-23)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/kiro",
|
||||
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
|
||||
"ANTHROPIC_MODEL": "kiro",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "kiro",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "kiro",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "kiro"
|
||||
"ANTHROPIC_MODEL": "kiro-claude-sonnet-4-6",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "kiro-claude-opus-4-7",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "kiro-claude-sonnet-4-6",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "kiro-claude-haiku-4-5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/qoder",
|
||||
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
|
||||
"ANTHROPIC_MODEL": "qoder/auto",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "qoder/ultimate",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "qoder/performance",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "qoder/efficient"
|
||||
}
|
||||
}
|
||||
+8
-15
@@ -1,4 +1,4 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# =============================================================================
|
||||
# Build stage: compile TypeScript and build UI
|
||||
@@ -36,13 +36,10 @@ RUN bun run build:all
|
||||
# Validate build artifacts exist
|
||||
RUN test -d dist && test -d lib && echo "[OK] Build artifacts validated"
|
||||
|
||||
# Generate a fresh npm lockfile from package.json immediately after build.
|
||||
# This lockfile is ephemeral — never committed; bun.lock remains the dev lockfile.
|
||||
# --ignore-scripts: only lockfile generation, no install side-effects needed here.
|
||||
# postinstall (scripts/postinstall.js) is a user-facing setup script; it must NOT
|
||||
# run during the Docker build (it writes to ~/.ccs/ which does not exist here).
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install --package-lock-only --ignore-scripts
|
||||
# Build runtime deps from the committed bun.lock to keep Docker installs pinned.
|
||||
# --ignore-scripts avoids user-home side effects from postinstall during image build.
|
||||
RUN --mount=type=cache,target=/root/.bun \
|
||||
bun install --frozen-lockfile --production --ignore-scripts
|
||||
|
||||
# =============================================================================
|
||||
# Runtime stage: Node-only, no Bun
|
||||
@@ -60,13 +57,9 @@ RUN apt-get update \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the ephemeral lockfile generated in the build stage (never committed).
|
||||
# --ignore-scripts: postinstall writes ~/.ccs/ config which is not needed inside
|
||||
# the Docker image; runtime deps (bcrypt v6+, express, etc.) have no native
|
||||
# compilation steps requiring postinstall.
|
||||
COPY --from=build /app/package.json /app/package-lock.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci --omit=dev --ignore-scripts
|
||||
# Copy pinned production dependencies resolved from the committed bun.lock.
|
||||
COPY --from=build /app/package.json ./
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
|
||||
COPY docker/entrypoint.sh /usr/local/bin/ccs-entrypoint
|
||||
RUN chmod +x /usr/local/bin/ccs-entrypoint
|
||||
|
||||
@@ -45,4 +45,7 @@ RUN chmod +x /entrypoint-integrated.sh \
|
||||
|
||||
EXPOSE 3000 8085 8317
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD node -e "const http=require('http');const probe=(url)=>new Promise((resolve)=>{const req=http.get(url,(res)=>{res.resume();resolve(res.statusCode<400);});req.on('error',()=>resolve(false));req.setTimeout(4500,()=>{req.destroy();resolve(false);});});Promise.all([probe('http://127.0.0.1:3000/'),probe('http://127.0.0.1:8317/')]).then((results)=>process.exit(results.every(Boolean)?0:1));"
|
||||
|
||||
ENTRYPOINT ["/entrypoint-integrated.sh"]
|
||||
|
||||
+18
-10
@@ -113,10 +113,11 @@ environment:
|
||||
|
||||
Running `ccs config auth setup` on the outer host shell updates that machine's own `~/.ccs`, not the Docker volume mounted into `ccs-cliproxy`. For the integrated stack, configure auth inside the container or provide the auth env vars in Compose.
|
||||
|
||||
Generate a bcrypt hash:
|
||||
Generate a bcrypt hash without putting the password in shell history or process arguments:
|
||||
|
||||
```bash
|
||||
docker exec ccs-cliproxy node -e "console.log(require('bcrypt').hashSync('your-password', 10))"
|
||||
docker exec -i ccs-cliproxy node -e "const fs=require('fs'); const bcrypt=require('bcrypt'); const password=fs.readFileSync(0,'utf8').trimEnd(); console.log(bcrypt.hashSync(password, 10));"
|
||||
# then type/paste the password followed by Enter (stdin is not exposed via argv)
|
||||
```
|
||||
|
||||
> **Note:** Do not commit the password hash in `docker-compose.yml`. Use Docker secrets or a `.env` file (not tracked in git) for sensitive values like `CCS_DASHBOARD_PASSWORD_HASH`.
|
||||
@@ -160,15 +161,20 @@ docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart cliproxy
|
||||
For remote deployments via `ccs docker up --host`:
|
||||
|
||||
```bash
|
||||
# Copy tokens into the running container (no root/sudo needed)
|
||||
scp /path/to/auth/*.json my-server:/tmp/ccs-auth/
|
||||
ssh my-server 'for f in /tmp/ccs-auth/*.json; do docker cp "$f" ccs-cliproxy:/root/.ccs/cliproxy/auth/; done'
|
||||
# Create a private staging directory (0700) and print its path
|
||||
STAGE_DIR=$(ssh my-server 'umask 077 && mktemp -d "${HOME}/.ccs-auth.XXXXXX"')
|
||||
|
||||
# Copy only JSON token files into the private staging directory
|
||||
scp /path/to/auth/*.json "my-server:${STAGE_DIR}/"
|
||||
|
||||
# Restrict file permissions and import each staged token into the container
|
||||
ssh my-server "chmod 600 \"${STAGE_DIR}\"/*.json && for f in \"${STAGE_DIR}\"/*.json; do docker cp \"\$f\" ccs-cliproxy:/root/.ccs/cliproxy/auth/; done"
|
||||
|
||||
# Restart CLIProxy to load new tokens
|
||||
ssh my-server "docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart cliproxy"
|
||||
|
||||
# Clean up temp files
|
||||
ssh my-server "rm -rf /tmp/ccs-auth"
|
||||
# Clean up private staging files
|
||||
ssh my-server "rm -rf \"${STAGE_DIR}\""
|
||||
```
|
||||
|
||||
> **Tip:** `docker cp` is preferred over writing directly to Docker volume mountpoints, which require root access.
|
||||
@@ -191,10 +197,12 @@ docker exec ccs-cliproxy curl -fsS http://127.0.0.1:3000/api/health \
|
||||
# 4. Verify auth tokens loaded (check client count)
|
||||
docker exec ccs-cliproxy grep "client load complete" /var/log/ccs/cliproxy.log
|
||||
|
||||
# 5. Test dashboard API (from remote -- requires auth)
|
||||
curl -fsS -X POST http://<host>:3000/api/auth/login \
|
||||
# 5. Test dashboard API (from remote -- requires auth + HTTPS)
|
||||
read -r -s CCS_DASHBOARD_PASSWORD && echo
|
||||
curl -fsS -X POST https://<host>:3000/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"admin","password":"your-password"}'
|
||||
-d "{\"username\":\"admin\",\"password\":\"${CCS_DASHBOARD_PASSWORD}\"}"
|
||||
unset CCS_DASHBOARD_PASSWORD
|
||||
```
|
||||
|
||||
Expected healthy output:
|
||||
|
||||
+2
-2
@@ -25,8 +25,8 @@ services:
|
||||
CCS_DOCKER_LEGACY_KEY_GRACE_DAYS: "${CCS_DOCKER_LEGACY_KEY_GRACE_DAYS:-}"
|
||||
CCS_DOCKER_RESTORE_LEGACY_API_KEY: "${CCS_DOCKER_RESTORE_LEGACY_API_KEY:-}"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "8317:8317"
|
||||
- "${CCS_DASHBOARD_PORT:-3000}:3000"
|
||||
- "${CCS_CLIPROXY_PORT:-8317}:8317"
|
||||
volumes:
|
||||
# /root/.ccs matches the HOME used inside the integrated image.
|
||||
# entrypoint-integrated.sh runs as root (supervisord user=root) and
|
||||
|
||||
@@ -159,6 +159,8 @@ const DEFAULT_WAIT_TIMEOUT_MS = 2000;
|
||||
const DEFAULT_WAIT_POLL_INTERVAL_MS = 100;
|
||||
const DEFAULT_DRAG_STEPS = 5;
|
||||
const MAX_POINTER_ACTIONS = 25;
|
||||
const MAX_CLICK_COUNT = 25;
|
||||
const MAX_KEY_REPEAT = 25;
|
||||
const SESSION_START_SETTLE_WINDOW_MS = 250;
|
||||
const MAX_ARTIFACT_FILE_BYTES = 5 * 1024 * 1024;
|
||||
const MAX_LOCAL_TRANSFER_FILE_BYTES = 10 * 1024 * 1024;
|
||||
@@ -454,6 +456,7 @@ function getTools() {
|
||||
clickCount: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: MAX_CLICK_COUNT,
|
||||
description: 'Optional click count. Defaults to 1.',
|
||||
},
|
||||
},
|
||||
@@ -519,6 +522,7 @@ function getTools() {
|
||||
repeat: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: MAX_KEY_REPEAT,
|
||||
description: 'Optional repeat count. Defaults to 1.',
|
||||
},
|
||||
},
|
||||
@@ -2305,10 +2309,13 @@ function requirePositiveIntegerOrDefault(value, label, fallback) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function requirePositiveInteger(value, label) {
|
||||
function requirePositiveInteger(value, label, maximum = undefined) {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${label} must be a positive integer`);
|
||||
}
|
||||
if (maximum !== undefined && value > maximum) {
|
||||
throw new Error(`${label} must be less than or equal to ${maximum}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -3555,7 +3562,7 @@ async function handleClick(toolArgs) {
|
||||
const clickCount =
|
||||
toolArgs.clickCount === undefined
|
||||
? 1
|
||||
: requirePositiveInteger(toolArgs.clickCount, 'clickCount');
|
||||
: requirePositiveInteger(toolArgs.clickCount, 'clickCount', MAX_CLICK_COUNT);
|
||||
|
||||
const expression = `(() => {
|
||||
const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))});
|
||||
@@ -3805,7 +3812,9 @@ async function handlePressKey(toolArgs) {
|
||||
'Shift',
|
||||
]);
|
||||
const repeat =
|
||||
toolArgs.repeat === undefined ? 1 : requirePositiveInteger(toolArgs.repeat, 'repeat');
|
||||
toolArgs.repeat === undefined
|
||||
? 1
|
||||
: requirePositiveInteger(toolArgs.repeat, 'repeat', MAX_KEY_REPEAT);
|
||||
const modifierMask =
|
||||
(modifiers.includes('Alt') ? 1 : 0) |
|
||||
(modifiers.includes('Control') ? 2 : 0) |
|
||||
@@ -5097,16 +5106,18 @@ async function ensureInterceptSession(page) {
|
||||
})
|
||||
);
|
||||
}
|
||||
pushRecentRequest({
|
||||
requestId: String(paused.requestId || ''),
|
||||
pageId: page.id,
|
||||
url: String(paused.request?.url || ''),
|
||||
method: String(paused.request?.method || ''),
|
||||
resourceType: String(paused.resourceType || ''),
|
||||
matchedRuleId: matchedRule ? matchedRule.ruleId : '',
|
||||
action,
|
||||
statusCode: action === 'fulfill' ? matchedRule.statusCode : 0,
|
||||
});
|
||||
if (matchedRule) {
|
||||
pushRecentRequest({
|
||||
requestId: String(paused.requestId || ''),
|
||||
pageId: page.id,
|
||||
url: String(paused.request?.url || ''),
|
||||
method: String(paused.request?.method || ''),
|
||||
resourceType: String(paused.resourceType || ''),
|
||||
matchedRuleId: matchedRule.ruleId,
|
||||
action,
|
||||
statusCode: action === 'fulfill' ? matchedRule.statusCode : 0,
|
||||
});
|
||||
}
|
||||
})();
|
||||
activityChain = activityChain
|
||||
.catch(() => {})
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "8.1.0",
|
||||
"version": "8.1.4-dev.4",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -97,9 +97,11 @@ function evaluateDashboardSunset({ targetTag, baselineVersion, releaseWindow, st
|
||||
const versions = parseStableTags(stableTags);
|
||||
const hasBaseline = versions.some((version) => compareVersions(version, baseline) === 0);
|
||||
if (compareVersions(target, baseline) > 0 && !hasBaseline) {
|
||||
throw new Error(
|
||||
`Cannot count dashboard sunset releases: baseline tag ${baseline.raw} is missing from git tags`,
|
||||
);
|
||||
return {
|
||||
publish: false,
|
||||
elapsed: releaseWindow,
|
||||
reason: `legacy dashboard sunset baseline ${baseline.raw} is missing from git tags; skipping deprecated image publish`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!versions.some((version) => compareVersions(version, target) === 0)) {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const ISSUE_REF_PATTERN = /#([0-9]+)/g;
|
||||
const ACTION_VERB_PATTERN = /\b(fixes|closes|resolves|refs?)\b(.*)/gi;
|
||||
const RESOLVE_VERB_PATTERN = /\b(fixes|closes|resolves)\b(.*)/gi;
|
||||
const PR_REF_PATTERN = /(?:Merge pull request #|\(#)([0-9]+)/g;
|
||||
const STABLE_TAG_PATTERN = /^v[0-9]+\.[0-9]+\.[0-9]+$/;
|
||||
|
||||
export function extractIssueNumbers(text, { includeRefs = true } = {}) {
|
||||
const pattern = includeRefs ? ACTION_VERB_PATTERN : RESOLVE_VERB_PATTERN;
|
||||
const issues = new Set();
|
||||
let actionMatch;
|
||||
|
||||
pattern.lastIndex = 0;
|
||||
while ((actionMatch = pattern.exec(text || '')) !== null) {
|
||||
const tail = actionMatch[2] || '';
|
||||
let issueMatch;
|
||||
ISSUE_REF_PATTERN.lastIndex = 0;
|
||||
while ((issueMatch = ISSUE_REF_PATTERN.exec(tail)) !== null) {
|
||||
issues.add(Number(issueMatch[1]));
|
||||
}
|
||||
}
|
||||
|
||||
return [...issues].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function extractPrNumbers(text) {
|
||||
const prs = new Set();
|
||||
let match;
|
||||
|
||||
PR_REF_PATTERN.lastIndex = 0;
|
||||
while ((match = PR_REF_PATTERN.exec(text || '')) !== null) {
|
||||
prs.add(Number(match[1]));
|
||||
}
|
||||
|
||||
return [...prs].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function planIssueCleanup({ releaseIssues, resolvedIssues, issueStates }) {
|
||||
const resolved = new Set(resolvedIssues);
|
||||
return releaseIssues.map((number) => {
|
||||
const state = issueStates.get(number) || { labels: [], state: 'UNKNOWN' };
|
||||
const labels = new Set(state.labels);
|
||||
const wasReleasedDev = labels.has('released-dev');
|
||||
const shouldClose = state.state === 'OPEN' && (wasReleasedDev || resolved.has(number));
|
||||
|
||||
return {
|
||||
number,
|
||||
removeLabels: ['released-dev', 'pending-release'],
|
||||
addReleasedLabel: shouldClose,
|
||||
close: shouldClose,
|
||||
reason: wasReleasedDev ? 'promoted from dev to stable' : 'resolved by stable release',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getStableReleaseContext({ env = process.env, exec = runCommand } = {}) {
|
||||
const repo = env.GITHUB_REPOSITORY;
|
||||
if (!repo) throw new Error('GITHUB_REPOSITORY is required');
|
||||
|
||||
const version = JSON.parse(readFileSync('package.json', 'utf8')).version;
|
||||
const currentTag = `v${version}`;
|
||||
const releaseBody = exec('gh', [
|
||||
'release',
|
||||
'view',
|
||||
currentTag,
|
||||
'--repo',
|
||||
repo,
|
||||
'--json',
|
||||
'body',
|
||||
'--jq',
|
||||
'.body',
|
||||
]);
|
||||
const tags = exec('git', ['tag', '-l', 'v[0-9]*.[0-9]*.[0-9]*', '--sort=-v:refname'])
|
||||
.split('\n')
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => STABLE_TAG_PATTERN.test(tag) && tag !== currentTag);
|
||||
const previousStableTag = tags[0] || '';
|
||||
const range = previousStableTag ? `${previousStableTag}..HEAD~1` : 'HEAD~50..HEAD~1';
|
||||
const commitText = exec('git', ['log', range, '--pretty=format:%s%n%b'], { optional: true });
|
||||
|
||||
return { repo, version, currentTag, releaseBody, range, commitText };
|
||||
}
|
||||
|
||||
export function buildReleaseIssueSet({ releaseBody, commitText, prText }) {
|
||||
const releaseIssues = new Set([
|
||||
...extractIssueNumbers(releaseBody, { includeRefs: true }),
|
||||
...extractIssueNumbers(commitText, { includeRefs: true }),
|
||||
...extractIssueNumbers(prText, { includeRefs: true }),
|
||||
]);
|
||||
const resolvedIssues = new Set([
|
||||
...extractIssueNumbers(releaseBody, { includeRefs: false }),
|
||||
...extractIssueNumbers(commitText, { includeRefs: false }),
|
||||
...extractIssueNumbers(prText, { includeRefs: false }),
|
||||
]);
|
||||
|
||||
return {
|
||||
releaseIssues: [...releaseIssues].sort((a, b) => a - b),
|
||||
resolvedIssues: [...resolvedIssues].sort((a, b) => a - b),
|
||||
};
|
||||
}
|
||||
|
||||
export function runCommand(command, args, { optional = false } = {}) {
|
||||
const result = spawnSync(command, args, { encoding: 'utf8' });
|
||||
if (result.status !== 0) {
|
||||
if (optional) return '';
|
||||
throw new Error(
|
||||
`${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`
|
||||
);
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
buildReleaseIssueSet,
|
||||
extractPrNumbers,
|
||||
getStableReleaseContext,
|
||||
planIssueCleanup,
|
||||
runCommand,
|
||||
} from './stable-release-issue-cleanup-lib.mjs';
|
||||
|
||||
function gh(args, options) {
|
||||
return runCommand('gh', args, options);
|
||||
}
|
||||
|
||||
function fetchPrText(repo, commitText) {
|
||||
let prText = '';
|
||||
for (const prNumber of extractPrNumbers(commitText)) {
|
||||
const details = gh(
|
||||
[
|
||||
'pr',
|
||||
'view',
|
||||
String(prNumber),
|
||||
'--repo',
|
||||
repo,
|
||||
'--json',
|
||||
'title,body',
|
||||
'--jq',
|
||||
'.title + "\n" + (.body // "")',
|
||||
],
|
||||
{ optional: true }
|
||||
);
|
||||
if (details) prText += `\n${details}`;
|
||||
}
|
||||
return prText;
|
||||
}
|
||||
|
||||
function fetchIssueStates(repo, issueNumbers) {
|
||||
const states = new Map();
|
||||
for (const number of issueNumbers) {
|
||||
const raw = gh(
|
||||
[
|
||||
'issue',
|
||||
'view',
|
||||
String(number),
|
||||
'--repo',
|
||||
repo,
|
||||
'--json',
|
||||
'state,labels',
|
||||
'--jq',
|
||||
'{state:.state,labels:[.labels[].name]}',
|
||||
],
|
||||
{ optional: true }
|
||||
);
|
||||
if (!raw) continue;
|
||||
states.set(number, JSON.parse(raw));
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
function ensureReleasedLabel(repo) {
|
||||
gh(
|
||||
[
|
||||
'label',
|
||||
'create',
|
||||
'released',
|
||||
'--color',
|
||||
'ededed',
|
||||
'--description',
|
||||
'Fix available in stable npm channel',
|
||||
'--repo',
|
||||
repo,
|
||||
],
|
||||
{ optional: true }
|
||||
);
|
||||
}
|
||||
|
||||
function applyAction(repo, action) {
|
||||
console.log(`Cleaning release state on issue #${action.number}`);
|
||||
for (const label of action.removeLabels) {
|
||||
gh(['issue', 'edit', String(action.number), '--remove-label', label, '--repo', repo], {
|
||||
optional: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!action.close) return;
|
||||
|
||||
gh(['issue', 'edit', String(action.number), '--add-label', 'released', '--repo', repo], {
|
||||
optional: true,
|
||||
});
|
||||
gh(
|
||||
[
|
||||
'issue',
|
||||
'close',
|
||||
String(action.number),
|
||||
'--comment',
|
||||
'[bot] Closing issue because this fix/feature is now in stable release (@latest).',
|
||||
'--repo',
|
||||
repo,
|
||||
],
|
||||
{ optional: true }
|
||||
);
|
||||
console.log(`Closed issue #${action.number}: ${action.reason}`);
|
||||
}
|
||||
|
||||
export function main() {
|
||||
const context = getStableReleaseContext();
|
||||
console.log(`Checking stable release issue lifecycle for ${context.currentTag}`);
|
||||
console.log(`Checking commits in range: ${context.range}`);
|
||||
|
||||
const prText = fetchPrText(context.repo, context.commitText);
|
||||
const { releaseIssues, resolvedIssues } = buildReleaseIssueSet({
|
||||
releaseBody: context.releaseBody,
|
||||
commitText: context.commitText,
|
||||
prText,
|
||||
});
|
||||
|
||||
if (releaseIssues.length === 0) {
|
||||
console.log(`No release-scoped issues found for ${context.currentTag}`);
|
||||
return;
|
||||
}
|
||||
|
||||
ensureReleasedLabel(context.repo);
|
||||
const issueStates = fetchIssueStates(context.repo, releaseIssues);
|
||||
for (const action of planIssueCleanup({ releaseIssues, resolvedIssues, issueStates })) {
|
||||
applyAction(context.repo, action);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -809,7 +809,7 @@ export function getOfficialChannelsLegacyEnableHelp(): string {
|
||||
}
|
||||
|
||||
export function getOfficialChannelTokenHelp(): string {
|
||||
return 'Use --set-token <channel>=<token>. If no channel is provided, Discord is assumed for backward compatibility.';
|
||||
return 'Use --set-token <channel> and pass the token via that channel env var (for example TELEGRAM_BOT_TOKEN=... ccs config channels --set-token telegram).';
|
||||
}
|
||||
|
||||
export function getOfficialChannelClearTokenHelp(): string {
|
||||
|
||||
@@ -46,6 +46,7 @@ describe('provider-capabilities', () => {
|
||||
'gitlab',
|
||||
'codebuddy',
|
||||
'kilo',
|
||||
'qoder',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -66,6 +67,7 @@ describe('provider-capabilities', () => {
|
||||
'cursor',
|
||||
'codebuddy',
|
||||
'kilo',
|
||||
'qoder',
|
||||
]);
|
||||
expect(getProvidersByOAuthFlow('authorization_code')).toEqual([
|
||||
'gemini',
|
||||
@@ -85,6 +87,7 @@ describe('provider-capabilities', () => {
|
||||
'kimi',
|
||||
'codebuddy',
|
||||
'kilo',
|
||||
'qoder',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
restoreExpiredQuotaPauses,
|
||||
} from '../../accounts/account-safety';
|
||||
import { sanitizeEmail } from '../../auth/auth-utils';
|
||||
import { pauseAccount } from '../registry';
|
||||
|
||||
// Setup test isolation
|
||||
let tmpDir: string;
|
||||
@@ -1228,6 +1229,65 @@ describe('Quota Exhaustion Handlers', () => {
|
||||
expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not auto-resume when a user manually re-pauses a quota-paused account', async () => {
|
||||
writeRegistry({
|
||||
agy: {
|
||||
default: 'cooldown@gmail.com',
|
||||
accounts: {
|
||||
'cooldown@gmail.com': {
|
||||
email: 'cooldown@gmail.com',
|
||||
tokenFile: 'agy-cooldown.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
writeAuthToken('agy-cooldown.json', {
|
||||
type: 'agy',
|
||||
email: 'cooldown@gmail.com',
|
||||
access_token: 'token',
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
expect(pauseAccountForQuotaCooldown('agy', 'cooldown@gmail.com', 5, now)).toBe(true);
|
||||
|
||||
const registryPath = path.join(tmpDir, '.ccs', 'cliproxy', 'accounts.json');
|
||||
const quotaPausedPath = path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json');
|
||||
const originalPausedAt = '2026-01-01T00:00:00.000Z';
|
||||
const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as {
|
||||
providers: {
|
||||
agy: {
|
||||
accounts: Record<string, { paused?: boolean; pausedAt?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
const quotaPaused = JSON.parse(fs.readFileSync(quotaPausedPath, 'utf8')) as {
|
||||
entries: Array<{ pausedAt: string }>;
|
||||
};
|
||||
registry.providers.agy.accounts['cooldown@gmail.com'].pausedAt = originalPausedAt;
|
||||
quotaPaused.entries[0].pausedAt = originalPausedAt;
|
||||
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2));
|
||||
fs.writeFileSync(quotaPausedPath, JSON.stringify(quotaPaused, null, 2));
|
||||
|
||||
expect(pauseAccount('agy', 'cooldown@gmail.com')).toBe(true);
|
||||
|
||||
const refreshedRegistry = JSON.parse(
|
||||
fs.readFileSync(registryPath, 'utf8')
|
||||
) as typeof registry;
|
||||
expect(refreshedRegistry.providers.agy.accounts['cooldown@gmail.com'].pausedAt).not.toBe(
|
||||
originalPausedAt
|
||||
);
|
||||
|
||||
const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000);
|
||||
const { getAccount } = await import('../account-manager');
|
||||
|
||||
expect(resumed).toBe(0);
|
||||
expect(getAccount('agy', 'cooldown@gmail.com')?.paused).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth-paused', 'agy-cooldown.json'))
|
||||
).toBe(true);
|
||||
expect(fs.existsSync(quotaPausedPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not auto-resume quota-paused accounts when pausedAt metadata is missing', async () => {
|
||||
writeRegistry({
|
||||
agy: {
|
||||
|
||||
@@ -660,6 +660,10 @@ export function pauseAccount(provider: CLIProxyProvider, accountId: string): boo
|
||||
|
||||
const accountMeta = providerAccounts.accounts[accountId];
|
||||
if (accountMeta.paused) {
|
||||
// Treat an explicit pause request for an already paused account as a fresh
|
||||
// manual decision. This changes the pause metadata so quota cooldown
|
||||
// restore cannot later mistake the pause for its original auto-pause.
|
||||
accountMeta.pausedAt = new Date().toISOString();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,53 @@ function postJson(
|
||||
});
|
||||
}
|
||||
|
||||
function postJsonText(
|
||||
url: string,
|
||||
body: JsonRecord,
|
||||
timeoutMs = 2_000
|
||||
): Promise<{ statusCode: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const payload = JSON.stringify(body);
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port,
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
let responseBody = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => {
|
||||
responseBody += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ statusCode: res.statusCode ?? 0, body: responseBody });
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
req.destroy(new Error(`Timed out waiting for proxy response after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
req.on('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('CodexReasoningProxy extended-context compatibility', () => {
|
||||
const cleanupServers: http.Server[] = [];
|
||||
|
||||
@@ -147,6 +194,84 @@ describe('CodexReasoningProxy extended-context compatibility', () => {
|
||||
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBe('high');
|
||||
});
|
||||
|
||||
it('ends streaming responses when upstream stalls after headers', async () => {
|
||||
const upstream = http.createServer((req, res) => {
|
||||
req.resume();
|
||||
req.on('end', () => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
res.write(
|
||||
'event: message_start\n' +
|
||||
'data: {"type":"message_start","message":{"id":"msg_stall","type":"message","role":"assistant","content":[],"model":"test","stop_reason":null}}\n\n'
|
||||
);
|
||||
});
|
||||
});
|
||||
cleanupServers.push(upstream);
|
||||
|
||||
const upstreamPort = await listenOnRandomPort(upstream);
|
||||
const proxy = new CodexReasoningProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||
modelMap: { defaultModel: 'gpt-5.3-codex' },
|
||||
timeoutMs: 100,
|
||||
});
|
||||
|
||||
const proxyPort = await proxy.start();
|
||||
try {
|
||||
const response = await postJsonText(
|
||||
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
|
||||
{
|
||||
model: 'gpt-5.3-codex',
|
||||
messages: [],
|
||||
stream: true,
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toContain('message_start');
|
||||
expect(response.body).toContain('timeout_error');
|
||||
expect(response.body).toContain('Upstream response timed out');
|
||||
} finally {
|
||||
proxy.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 504 when non-stream upstream stalls after headers', async () => {
|
||||
const upstream = http.createServer((req, res) => {
|
||||
req.resume();
|
||||
req.on('end', () => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.flushHeaders();
|
||||
});
|
||||
});
|
||||
cleanupServers.push(upstream);
|
||||
|
||||
const upstreamPort = await listenOnRandomPort(upstream);
|
||||
const proxy = new CodexReasoningProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||
modelMap: { defaultModel: 'gpt-5.3-codex' },
|
||||
timeoutMs: 100,
|
||||
});
|
||||
|
||||
const proxyPort = await proxy.start();
|
||||
try {
|
||||
const response = await postJsonText(
|
||||
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
|
||||
{
|
||||
model: 'gpt-5.3-codex',
|
||||
messages: [],
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(504);
|
||||
expect(response.body).toContain('Upstream response timed out');
|
||||
} finally {
|
||||
proxy.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('translates codex fast model suffixes into service_tier', async () => {
|
||||
const capturedBodies: JsonRecord[] = [];
|
||||
|
||||
@@ -296,6 +421,39 @@ describe('CodexReasoningProxy extended-context compatibility', () => {
|
||||
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects oversized non-2xx upstream error bodies', async () => {
|
||||
const upstream = http.createServer((_req, res) => {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
const chunk = 'x'.repeat(1024 * 1024);
|
||||
for (let i = 0; i < 11; i += 1) {
|
||||
res.write(chunk);
|
||||
}
|
||||
res.end();
|
||||
});
|
||||
cleanupServers.push(upstream);
|
||||
|
||||
const upstreamPort = await listenOnRandomPort(upstream);
|
||||
const proxy = new CodexReasoningProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||
modelMap: { defaultModel: 'gpt-5.4' },
|
||||
defaultEffort: 'medium',
|
||||
});
|
||||
|
||||
const proxyPort = await proxy.start();
|
||||
const response = await postJson(
|
||||
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
|
||||
{
|
||||
model: 'gpt-5.4',
|
||||
messages: [],
|
||||
}
|
||||
);
|
||||
|
||||
proxy.stop();
|
||||
|
||||
expect(response.statusCode).toBe(502);
|
||||
expect(response.body.error).toContain('Upstream error response exceeded 10MB limit');
|
||||
});
|
||||
|
||||
it('keeps fast service tier when disableEffort is enabled', async () => {
|
||||
let capturedBody: JsonRecord | null = null;
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
resolveRuntimeCodexFallbackModel,
|
||||
} from './codex-plan-compatibility';
|
||||
import { getModelMaxLevel } from '../model-catalog';
|
||||
import {
|
||||
attachUpstreamResponseTimeout,
|
||||
writeForwardResponseHead,
|
||||
} from '../proxy/upstream-response-timeout';
|
||||
|
||||
export type CodexReasoningEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
export type CodexServiceTier = 'fast';
|
||||
@@ -301,7 +305,7 @@ export class CodexReasoningProxy {
|
||||
headers: http.IncomingHttpHeaders,
|
||||
responseBody: string
|
||||
): void {
|
||||
clientRes.writeHead(statusCode, headers);
|
||||
writeForwardResponseHead(clientRes, statusCode, headers);
|
||||
clientRes.end(responseBody);
|
||||
}
|
||||
|
||||
@@ -635,10 +639,38 @@ export class CodexReasoningProxy {
|
||||
),
|
||||
(upstreamRes) => {
|
||||
clearResponseTimeout();
|
||||
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
|
||||
upstreamRes.pipe(clientRes);
|
||||
upstreamRes.on('end', () => resolve());
|
||||
upstreamRes.on('error', reject);
|
||||
const statusCode = upstreamRes.statusCode || 200;
|
||||
let responseStarted = false;
|
||||
const writeResponseHead = () => {
|
||||
if (responseStarted) return;
|
||||
responseStarted = true;
|
||||
writeForwardResponseHead(clientRes, statusCode, upstreamRes.headers);
|
||||
};
|
||||
const clearUpstreamResponseTimeout = attachUpstreamResponseTimeout({
|
||||
upstreamReq,
|
||||
upstreamRes,
|
||||
clientRes,
|
||||
timeoutMs: this.config.timeoutMs,
|
||||
onTimeout: () => resolve(),
|
||||
});
|
||||
upstreamRes.on('data', (chunk: Buffer) => {
|
||||
writeResponseHead();
|
||||
const canContinue = clientRes.write(chunk);
|
||||
if (!canContinue) {
|
||||
upstreamRes.pause();
|
||||
clientRes.once('drain', () => upstreamRes.resume());
|
||||
}
|
||||
});
|
||||
upstreamRes.on('end', () => {
|
||||
clearUpstreamResponseTimeout();
|
||||
writeResponseHead();
|
||||
clientRes.end();
|
||||
resolve();
|
||||
});
|
||||
upstreamRes.on('error', (error) => {
|
||||
clearUpstreamResponseTimeout();
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -672,17 +704,60 @@ export class CodexReasoningProxy {
|
||||
(upstreamRes) => {
|
||||
clearResponseTimeout();
|
||||
const statusCode = upstreamRes.statusCode || 200;
|
||||
const clearUpstreamResponseTimeout = attachUpstreamResponseTimeout({
|
||||
upstreamReq,
|
||||
upstreamRes,
|
||||
clientRes,
|
||||
timeoutMs: this.config.timeoutMs,
|
||||
onTimeout: () => resolve(504),
|
||||
});
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
clientRes.writeHead(statusCode, upstreamRes.headers);
|
||||
upstreamRes.pipe(clientRes);
|
||||
upstreamRes.on('end', () => resolve(statusCode));
|
||||
upstreamRes.on('error', reject);
|
||||
let responseStarted = false;
|
||||
const writeResponseHead = () => {
|
||||
if (responseStarted) return;
|
||||
responseStarted = true;
|
||||
writeForwardResponseHead(clientRes, statusCode, upstreamRes.headers);
|
||||
};
|
||||
upstreamRes.on('data', (chunk: Buffer) => {
|
||||
writeResponseHead();
|
||||
const canContinue = clientRes.write(chunk);
|
||||
if (!canContinue) {
|
||||
upstreamRes.pause();
|
||||
clientRes.once('drain', () => upstreamRes.resume());
|
||||
}
|
||||
});
|
||||
upstreamRes.on('end', () => {
|
||||
clearUpstreamResponseTimeout();
|
||||
writeResponseHead();
|
||||
clientRes.end();
|
||||
resolve(statusCode);
|
||||
});
|
||||
upstreamRes.on('error', (error) => {
|
||||
clearUpstreamResponseTimeout();
|
||||
reject(error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const maxErrorResponseSize = 10 * 1024 * 1024; // 10MB
|
||||
let totalResponseBytes = 0;
|
||||
let responseTooLarge = false;
|
||||
const chunks: Buffer[] = [];
|
||||
upstreamRes.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
upstreamRes.on('data', (chunk: Buffer) => {
|
||||
totalResponseBytes += chunk.length;
|
||||
if (totalResponseBytes > maxErrorResponseSize) {
|
||||
responseTooLarge = true;
|
||||
upstreamRes.destroy(new Error('Upstream error response exceeded 10MB limit'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
upstreamRes.on('end', async () => {
|
||||
clearUpstreamResponseTimeout();
|
||||
if (responseTooLarge) {
|
||||
reject(new Error('Upstream error response exceeded 10MB limit'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const responseBody = Buffer.concat(chunks).toString('utf8');
|
||||
const unsupportedError =
|
||||
@@ -741,7 +816,10 @@ export class CodexReasoningProxy {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
upstreamRes.on('error', reject);
|
||||
upstreamRes.on('error', (error) => {
|
||||
clearUpstreamResponseTimeout();
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -306,7 +306,11 @@ describe('handlePasteCallbackMode traceability', () => {
|
||||
},
|
||||
{
|
||||
url: /\/v0\/management\/oauth-callback$/,
|
||||
response: { status: 'error', error: 'invalid_grant: expired code' },
|
||||
response: {
|
||||
status: 'error',
|
||||
error:
|
||||
'invalid_grant: bad redirect http://localhost:1455/callback?code=oauth-code-secret&state=upstream-state-secret',
|
||||
},
|
||||
status: 400,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -120,9 +120,29 @@ describe('createOAuthTraceRecorder', () => {
|
||||
expect(snap[0].error).toEqual({ code: 'E1', message: 'boom' });
|
||||
});
|
||||
|
||||
test('Error instance accepted', () => {
|
||||
test('redacts OAuth secrets from error messages before they reach sinks', () => {
|
||||
const lines: string[] = [];
|
||||
const { rec } = makeRecorder(true, lines);
|
||||
rec.record(OAuthTracePhase.Error, undefined, {
|
||||
code: 'CALLBACK_REJECTED',
|
||||
message:
|
||||
'bad redirect http://localhost:1455/callback?code=AUTHCODE_SECRET&state=STATE_SECRET',
|
||||
});
|
||||
|
||||
const blob = JSON.stringify(rec.snapshot()) + '\n' + lines.join('\n');
|
||||
expect(blob).not.toContain('AUTHCODE_SECRET');
|
||||
expect(blob).not.toContain('STATE_SECRET');
|
||||
expect(blob).toContain(REDACTED_PLACEHOLDER);
|
||||
});
|
||||
|
||||
test('Error instance accepted and redacted', () => {
|
||||
const { rec } = makeRecorder();
|
||||
rec.record(OAuthTracePhase.Error, undefined, new Error('plain'));
|
||||
expect(rec.snapshot()[0].error?.message).toBe('plain');
|
||||
rec.record(
|
||||
OAuthTracePhase.Error,
|
||||
undefined,
|
||||
new Error('bad redirect http://localhost:1455/callback?code=AUTHCODE_SECRET')
|
||||
);
|
||||
expect(rec.snapshot()[0].error?.message).toContain(REDACTED_PLACEHOLDER);
|
||||
expect(rec.snapshot()[0].error?.message).not.toContain('AUTHCODE_SECRET');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,6 +85,13 @@ describe('redactString', () => {
|
||||
expect(out).not.toContain('AT_SECRET');
|
||||
expect(out).toBe(`access_token=${REDACTED_PLACEHOLDER}&keep=1`);
|
||||
});
|
||||
|
||||
test('redacts ampersand-delimited generic token query params', () => {
|
||||
const out = redactString('access_token=AT_SECRET&token=TOKEN_SECRET');
|
||||
expect(out).not.toContain('AT_SECRET');
|
||||
expect(out).not.toContain('TOKEN_SECRET');
|
||||
expect(out).toBe(`access_token=${REDACTED_PLACEHOLDER}&token=${REDACTED_PLACEHOLDER}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactUrl', () => {
|
||||
|
||||
@@ -306,6 +306,13 @@ export const OAUTH_CONFIGS: Record<CLIProxyProvider, ProviderOAuthConfig> = {
|
||||
scopes: [],
|
||||
authFlag: '--kilo-login',
|
||||
},
|
||||
qoder: {
|
||||
provider: 'qoder',
|
||||
displayName: 'Qoder',
|
||||
authUrl: 'https://qoder.com/device/selectAccounts',
|
||||
scopes: [],
|
||||
authFlag: '--qoder-login',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -389,6 +396,8 @@ export interface OAuthOptions {
|
||||
account?: string;
|
||||
add?: boolean;
|
||||
nickname?: string;
|
||||
/** Existing account id to update during reauthentication. */
|
||||
expectedAccountId?: string;
|
||||
/** If true, caller explicitly accepts Antigravity OAuth risk for this command/session. */
|
||||
acceptAgyRisk?: boolean;
|
||||
/** Kiro auth method override (CLI + Dashboard parity). */
|
||||
|
||||
@@ -82,6 +82,7 @@ import { generateSessionId } from './project-selection-handler';
|
||||
import { createFileSink } from './oauth-trace/sink-file';
|
||||
import { createOAuthTraceRecorder, OAuthTracePhase, type OAuthTraceRecorder } from './oauth-trace';
|
||||
import { diagnoseFailure, formatErrorMessage } from './oauth-trace/diagnose-failure';
|
||||
import { redactString } from './oauth-trace/redactor';
|
||||
|
||||
interface PasteCallbackStartData {
|
||||
url?: string;
|
||||
@@ -889,8 +890,9 @@ export async function handlePasteCallbackMode(
|
||||
if (!callbackResponse.ok || callbackData.status === 'error') {
|
||||
const callbackError =
|
||||
callbackData.error || `OAuth callback failed with status ${callbackResponse.status}`;
|
||||
console.log(fail(callbackError));
|
||||
warnPossible403Ban(provider, callbackError);
|
||||
const redactedCallbackError = redactString(callbackError);
|
||||
console.log(fail(redactedCallbackError));
|
||||
warnPossible403Ban(provider, redactedCallbackError);
|
||||
trace.record(
|
||||
OAuthTracePhase.Error,
|
||||
{ status: callbackResponse.status },
|
||||
@@ -915,8 +917,9 @@ export async function handlePasteCallbackMode(
|
||||
);
|
||||
|
||||
if (tokenWaitError) {
|
||||
console.log(fail(tokenWaitError));
|
||||
warnPossible403Ban(provider, tokenWaitError);
|
||||
const redactedTokenWaitError = redactString(tokenWaitError);
|
||||
console.log(fail(redactedTokenWaitError));
|
||||
warnPossible403Ban(provider, redactedTokenWaitError);
|
||||
trace.record(
|
||||
OAuthTracePhase.Error,
|
||||
{},
|
||||
@@ -999,8 +1002,8 @@ async function handleGitLabPatLogin(
|
||||
const baseUrl = normalizeGitLabBaseUrl(options?.gitlabBaseUrl);
|
||||
const knownTokenFiles = listProviderTokenSnapshots(provider, tokenDir);
|
||||
const suppliedToken = options?.gitlabPersonalAccessToken?.trim();
|
||||
const personalAccessToken =
|
||||
suppliedToken || process.env['GITLAB_PERSONAL_ACCESS_TOKEN']?.trim() || undefined;
|
||||
const envPersonalAccessToken = process.env['GITLAB_PERSONAL_ACCESS_TOKEN']?.trim() || undefined;
|
||||
const personalAccessToken = suppliedToken || envPersonalAccessToken;
|
||||
|
||||
let token = personalAccessToken;
|
||||
if (!token) {
|
||||
@@ -1015,6 +1018,11 @@ async function handleGitLabPatLogin(
|
||||
return null;
|
||||
}
|
||||
|
||||
// PAT provided via process env should never be forwarded to downstream runtime.
|
||||
if (!suppliedToken && envPersonalAccessToken) {
|
||||
delete process.env['GITLAB_PERSONAL_ACCESS_TOKEN'];
|
||||
}
|
||||
|
||||
const response = await fetch(buildProxyUrl(target, '/v0/management/gitlab-auth-url'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -1126,8 +1134,9 @@ export async function triggerOAuth(
|
||||
// Check for existing accounts
|
||||
const existingAccounts = getProviderAccounts(provider);
|
||||
const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
|
||||
const targetAccountId = options.expectedAccountId || existingNameMatch?.id;
|
||||
const nicknameError = !fromUI
|
||||
? getCliAuthNicknameError(provider, nickname, existingAccounts, existingNameMatch?.id)
|
||||
? getCliAuthNicknameError(provider, nickname, existingAccounts, targetAccountId)
|
||||
: null;
|
||||
if (nicknameError) {
|
||||
console.log(fail(nicknameError));
|
||||
@@ -1139,7 +1148,7 @@ export async function triggerOAuth(
|
||||
const tokenDir = getProviderTokenDir(provider);
|
||||
const success = await importKiroToken(verbose);
|
||||
if (success) {
|
||||
return registerAccountFromToken(provider, tokenDir, nickname, verbose, existingNameMatch?.id);
|
||||
return registerAccountFromToken(provider, tokenDir, nickname, verbose, targetAccountId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1230,7 +1239,7 @@ export async function triggerOAuth(
|
||||
verbose,
|
||||
tokenDir,
|
||||
nickname,
|
||||
existingNameMatch?.id,
|
||||
targetAccountId,
|
||||
{
|
||||
gitlabBaseUrl: resolvedGitLabBaseUrl,
|
||||
gitlabPersonalAccessToken: options.gitlabPersonalAccessToken,
|
||||
@@ -1246,7 +1255,7 @@ export async function triggerOAuth(
|
||||
verbose,
|
||||
tokenDir,
|
||||
nickname,
|
||||
existingNameMatch?.id,
|
||||
targetAccountId,
|
||||
{
|
||||
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
|
||||
gitlabBaseUrl: provider === 'gitlab' ? resolvedGitLabBaseUrl : undefined,
|
||||
@@ -1331,7 +1340,7 @@ export async function triggerOAuth(
|
||||
verbose,
|
||||
isCLI,
|
||||
nickname,
|
||||
expectedAccountId: existingNameMatch?.id,
|
||||
expectedAccountId: targetAccountId,
|
||||
authFlowType: isDeviceCodeFlow ? 'device_code' : 'authorization_code',
|
||||
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
|
||||
manualCallback: useSelectedKiroLocalPasteCallback,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
const SENSITIVE_QUERY_KEYS = [
|
||||
'code',
|
||||
'state',
|
||||
'token',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'id_token',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { OAuthTraceEvent, OAuthTracePhase, OAuthTraceSink } from './trace-events';
|
||||
import { createMemorySink } from './sink-memory';
|
||||
import { createVerboseStdoutSink } from './sink-verbose-stdout';
|
||||
import { redactJsonShallow } from './redactor';
|
||||
import { redactJsonShallow, redactString } from './redactor';
|
||||
|
||||
export interface OAuthTraceRecorder {
|
||||
record(
|
||||
@@ -53,9 +53,9 @@ export function createOAuthTraceRecorder(options: OAuthTraceRecorderOptions): OA
|
||||
): { code?: string; message: string } | undefined {
|
||||
if (!err) return undefined;
|
||||
if (err instanceof Error) {
|
||||
return { message: err.message };
|
||||
return { message: redactString(err.message) };
|
||||
}
|
||||
return { code: err.code, message: err.message };
|
||||
return { code: err.code, message: redactString(err.message) };
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { compareCliproxyVersions, isNewerVersion } from '../version-checker';
|
||||
|
||||
describe('cliproxy version comparison', () => {
|
||||
it('treats missing fork release suffix as zero', () => {
|
||||
expect(compareCliproxyVersions('6.6.81', '6.6.81-0')).toBe(0);
|
||||
expect(isNewerVersion('6.6.81-0', '6.6.81')).toBe(false);
|
||||
});
|
||||
|
||||
it('orders patched fork release suffixes after core version equality', () => {
|
||||
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(isNewerVersion('7.1.31-1', '7.1.31-0')).toBe(true);
|
||||
});
|
||||
|
||||
it('lets core version precedence win before fork release suffixes', () => {
|
||||
expect(compareCliproxyVersions('7.1.32-0', '7.1.31-99')).toBe(1);
|
||||
expect(isNewerVersion('7.1.31-99', '7.1.32-0')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -33,28 +33,49 @@ interface FetchAllVersionsDeps {
|
||||
fetchJsonFn?: typeof fetchJson;
|
||||
}
|
||||
|
||||
interface ParsedCliproxyVersion {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
forkRelease: number;
|
||||
}
|
||||
|
||||
function parseCliproxyVersion(version: string): ParsedCliproxyVersion {
|
||||
const normalized = version.trim().replace(/^v/, '');
|
||||
const [coreVersion, forkReleaseValue = '0'] = normalized.split('-', 2);
|
||||
const [major = 0, minor = 0, patch = 0] = coreVersion
|
||||
.split('.')
|
||||
.map((part) => parseInt(part, 10) || 0);
|
||||
const forkRelease = /^\d+$/.test(forkReleaseValue) ? parseInt(forkReleaseValue, 10) || 0 : 0;
|
||||
|
||||
return { major, minor, patch, forkRelease };
|
||||
}
|
||||
|
||||
function compareVersionPart(a: number, b: number): number {
|
||||
if (a > b) return 1;
|
||||
if (a < b) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function compareCliproxyVersions(a: string, b: string): number {
|
||||
const left = parseCliproxyVersion(a);
|
||||
const right = parseCliproxyVersion(b);
|
||||
|
||||
return (
|
||||
compareVersionPart(left.major, right.major) ||
|
||||
compareVersionPart(left.minor, right.minor) ||
|
||||
compareVersionPart(left.patch, right.patch) ||
|
||||
compareVersionPart(left.forkRelease, right.forkRelease)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare semver versions (true if latest > current)
|
||||
* Handles CLIProxyAPIPlus versioning: strips -0 suffix before comparison
|
||||
* Compare CLIProxy release versions (true if latest > current).
|
||||
* Missing fork suffixes are treated as -0, while patched fork suffixes such as
|
||||
* 7.1.31-1 still sort newer than 7.1.31-0.
|
||||
*/
|
||||
export function isNewerVersion(latest: string, current: string): boolean {
|
||||
// Strip -0 suffix from CLIProxyAPIPlus versions (e.g., "6.6.40-0" -> "6.6.40")
|
||||
const cleanLatest = latest.replace(/-\d+$/, '');
|
||||
const cleanCurrent = current.replace(/-\d+$/, '');
|
||||
|
||||
const latestParts = cleanLatest.split('.').map((p) => parseInt(p, 10) || 0);
|
||||
const currentParts = cleanCurrent.split('.').map((p) => parseInt(p, 10) || 0);
|
||||
|
||||
// Pad arrays to same length
|
||||
while (latestParts.length < 3) latestParts.push(0);
|
||||
while (currentParts.length < 3) currentParts.push(0);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (latestParts[i] > currentParts[i]) return true;
|
||||
if (latestParts[i] < currentParts[i]) return false;
|
||||
}
|
||||
|
||||
return false; // Equal versions
|
||||
return compareCliproxyVersions(latest, current) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ describe('base-config-loader new providers', () => {
|
||||
['gitlab', '/api/provider/gitlab', 'gitlab-duo'],
|
||||
['codebuddy', '/api/provider/codebuddy', 'auto'],
|
||||
['kilo', '/api/provider/kilo', 'kilo/auto'],
|
||||
['qoder', '/api/provider/qoder', 'qoder/auto'],
|
||||
] as const)('loads base settings for %s', (provider, baseUrlPath, defaultModel) => {
|
||||
const config = loadBaseConfig(provider);
|
||||
|
||||
|
||||
@@ -265,6 +265,68 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
},
|
||||
],
|
||||
},
|
||||
qoder: {
|
||||
provider: 'qoder',
|
||||
displayName: 'Qoder',
|
||||
defaultModel: 'qoder/auto',
|
||||
models: [
|
||||
{
|
||||
id: 'qoder/auto',
|
||||
name: 'Qoder Auto',
|
||||
description: 'Auto selects the best Qoder model for your prompt',
|
||||
},
|
||||
{
|
||||
id: 'qoder/ultimate',
|
||||
name: 'Qoder Ultimate',
|
||||
description: 'Highest quality Qoder tier',
|
||||
},
|
||||
{
|
||||
id: 'qoder/performance',
|
||||
name: 'Qoder Performance',
|
||||
description: 'Balanced quality and speed',
|
||||
},
|
||||
{
|
||||
id: 'qoder/efficient',
|
||||
name: 'Qoder Efficient',
|
||||
description: 'Cost-efficient Qoder tier',
|
||||
},
|
||||
{
|
||||
id: 'qoder/lite',
|
||||
name: 'Qoder Lite',
|
||||
description: 'Fastest and most affordable Qoder tier',
|
||||
},
|
||||
{
|
||||
id: 'qoder/qmodel',
|
||||
name: 'Qwen 3.6 Plus (via Qoder)',
|
||||
description: 'Qwen 3.6 Plus frontier model',
|
||||
},
|
||||
{
|
||||
id: 'qoder/dmodel',
|
||||
name: 'DeepSeek V4 Pro (via Qoder)',
|
||||
description: 'DeepSeek V4 Pro frontier model',
|
||||
},
|
||||
{
|
||||
id: 'qoder/dfmodel',
|
||||
name: 'DeepSeek V4 Flash (via Qoder)',
|
||||
description: 'DeepSeek V4 Flash frontier model',
|
||||
},
|
||||
{
|
||||
id: 'qoder/gm51model',
|
||||
name: 'GLM 5.1 (via Qoder)',
|
||||
description: 'GLM 5.1 frontier model',
|
||||
},
|
||||
{
|
||||
id: 'qoder/kmodel',
|
||||
name: 'Kimi K2.6 (via Qoder)',
|
||||
description: 'Kimi K2.6 frontier model',
|
||||
},
|
||||
{
|
||||
id: 'qoder/mmodel',
|
||||
name: 'MiniMax M2.7 (via Qoder)',
|
||||
description: 'MiniMax M2.7 frontier model',
|
||||
},
|
||||
],
|
||||
},
|
||||
kimi: {
|
||||
provider: 'kimi',
|
||||
displayName: 'Kimi (Moonshot)',
|
||||
|
||||
@@ -182,6 +182,18 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
|
||||
tokenTypeValues: ['kilo'],
|
||||
aliases: [],
|
||||
},
|
||||
qoder: {
|
||||
displayName: 'Qoder',
|
||||
description: 'Qoder AI coding assistant',
|
||||
oauthFlow: 'device_code',
|
||||
callbackPort: null,
|
||||
callbackProviderName: 'qoder',
|
||||
authUrlProviderName: 'qoder',
|
||||
refreshOwnership: 'unsupported',
|
||||
authFilePrefixes: ['qoder-'],
|
||||
tokenTypeValues: ['qoder'],
|
||||
aliases: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const CLIPROXY_PROVIDER_IDS = Object.freeze(
|
||||
|
||||
@@ -294,6 +294,33 @@ describe('proxy-config-resolver', () => {
|
||||
expect(config.managementKey).toBe('remote-management-key');
|
||||
});
|
||||
|
||||
it('should clear YAML management key when CLI overrides remote host', () => {
|
||||
const { config } = resolveProxyConfig(['--proxy-host', 'cli-host.example.com'], {
|
||||
remote: {
|
||||
host: 'yaml-host.example.com',
|
||||
auth_token: 'remote-auth-token',
|
||||
management_key: 'remote-management-key',
|
||||
},
|
||||
});
|
||||
expect(config.mode).toBe('remote');
|
||||
expect(config.host).toBe('cli-host.example.com');
|
||||
expect(config.managementKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clear YAML management key when ENV overrides auth token', () => {
|
||||
process.env.CCS_PROXY_AUTH_TOKEN = 'env-auth-token';
|
||||
const { config } = resolveProxyConfig([], {
|
||||
remote: {
|
||||
host: 'yaml-host.example.com',
|
||||
auth_token: 'remote-auth-token',
|
||||
management_key: 'remote-management-key',
|
||||
},
|
||||
});
|
||||
expect(config.mode).toBe('remote');
|
||||
expect(config.authToken).toBe('env-auth-token');
|
||||
expect(config.managementKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow CLI --proxy-host to override YAML enabled:false', () => {
|
||||
const { config } = resolveProxyConfig(['--proxy-host', 'cli-host'], {
|
||||
remote: { enabled: false, host: 'yaml-host' },
|
||||
|
||||
@@ -436,6 +436,155 @@ describe('ToolSanitizationProxy Integration', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('translates Codex fast aliases before forwarding to provider routes', async () => {
|
||||
const proxy = new ToolSanitizationProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
||||
});
|
||||
const port = await proxy.start();
|
||||
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${port}/api/provider/codex/v1/messages?beta=true`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-5.5-fast',
|
||||
messages: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const sentBody = lastRequest!.body as Record<string, unknown>;
|
||||
expect(sentBody.model).toBe('gpt-5.5');
|
||||
expect(sentBody.service_tier).toBe('priority');
|
||||
} finally {
|
||||
proxy.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('translates Codex effort and fast aliases before forwarding to provider routes', async () => {
|
||||
const proxy = new ToolSanitizationProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
||||
});
|
||||
const port = await proxy.start();
|
||||
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${port}/api/provider/codex/v1/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-5.5-fast-high',
|
||||
messages: [],
|
||||
reasoning: { summary: 'auto' },
|
||||
}),
|
||||
});
|
||||
|
||||
const sentBody = lastRequest!.body as Record<string, unknown>;
|
||||
expect(sentBody.model).toBe('gpt-5.5');
|
||||
expect((sentBody.reasoning as Record<string, unknown>).summary).toBe('auto');
|
||||
expect((sentBody.reasoning as Record<string, unknown>).effort).toBe('high');
|
||||
expect(sentBody.service_tier).toBe('priority');
|
||||
} finally {
|
||||
proxy.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('folds Codex system messages into the first user message before forwarding', async () => {
|
||||
const proxy = new ToolSanitizationProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
||||
});
|
||||
const port = await proxy.start();
|
||||
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${port}/api/provider/codex/v1/messages?beta=true`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-5.4',
|
||||
system: [{ type: 'text', text: 'Always be concise.' }],
|
||||
messages: [
|
||||
{ role: 'system', content: 'Use JSON.' },
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hello' }] },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const sentBody = lastRequest!.body as Record<string, unknown>;
|
||||
const sentMessages = sentBody.messages as Array<Record<string, unknown>>;
|
||||
|
||||
expect(sentBody.system).toBeUndefined();
|
||||
expect(sentMessages).toHaveLength(1);
|
||||
expect(sentMessages[0].role).toBe('user');
|
||||
expect(sentMessages[0].content).toEqual([
|
||||
{ type: 'text', text: 'Always be concise.\n\nUse JSON.' },
|
||||
{ type: 'text', text: 'hello' },
|
||||
]);
|
||||
} finally {
|
||||
proxy.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('strips blank Codex system messages before forwarding', async () => {
|
||||
const proxy = new ToolSanitizationProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
||||
});
|
||||
const port = await proxy.start();
|
||||
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${port}/api/provider/codex/v1/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-5.4',
|
||||
system: ' ',
|
||||
messages: [
|
||||
{ role: 'system', content: [{ type: 'text', text: ' ' }] },
|
||||
{ role: 'user', content: 'hello' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const sentBody = lastRequest!.body as Record<string, unknown>;
|
||||
const sentMessages = sentBody.messages as Array<Record<string, unknown>>;
|
||||
|
||||
expect(sentBody.system).toBeUndefined();
|
||||
expect(sentMessages).toEqual([{ role: 'user', content: 'hello' }]);
|
||||
} finally {
|
||||
proxy.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not apply Codex alias or system rewrites on explicit non-Codex provider routes', async () => {
|
||||
const proxy = new ToolSanitizationProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
||||
});
|
||||
const port = await proxy.start();
|
||||
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${port}/api/provider/gemini/v1/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-5.5-fast',
|
||||
system: 'Keep this as a provider-level system field.',
|
||||
messages: [
|
||||
{ role: 'system', content: 'Keep this as a message.' },
|
||||
{ role: 'user', content: 'hello' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const sentBody = lastRequest!.body as Record<string, unknown>;
|
||||
expect(sentBody.model).toBe('gpt-5.5-fast');
|
||||
expect(sentBody.service_tier).toBeUndefined();
|
||||
expect(sentBody.system).toBe('Keep this as a provider-level system field.');
|
||||
expect(sentBody.messages).toEqual([
|
||||
{ role: 'system', content: 'Keep this as a message.' },
|
||||
{ role: 'user', content: 'hello' },
|
||||
]);
|
||||
} finally {
|
||||
proxy.stop();
|
||||
}
|
||||
});
|
||||
|
||||
for (const model of [
|
||||
'gpt-5.3-codex-xhigh',
|
||||
'gpt-5.1-codex-mini',
|
||||
@@ -825,6 +974,49 @@ describe('ToolSanitizationProxy Integration', () => {
|
||||
proxy.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 504 when raw upstream passthrough stalls after headers', async () => {
|
||||
const upstream = http.createServer((req, res) => {
|
||||
req.resume();
|
||||
req.on('end', () => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.flushHeaders();
|
||||
});
|
||||
});
|
||||
const upstreamPort = await new Promise<number>((resolve, reject) => {
|
||||
upstream.once('error', reject);
|
||||
upstream.listen(0, '127.0.0.1', () => {
|
||||
const address = upstream.address();
|
||||
if (typeof address !== 'object' || !address) {
|
||||
reject(new Error('Failed to resolve upstream port'));
|
||||
return;
|
||||
}
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
|
||||
const proxy = new ToolSanitizationProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||
timeoutMs: 100,
|
||||
});
|
||||
const port = await proxy.start();
|
||||
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/health`);
|
||||
const text = await Promise.race([
|
||||
response.text(),
|
||||
new Promise<string>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timed out waiting for proxy response')), 2_000)
|
||||
),
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(504);
|
||||
expect(text).toContain('Upstream response timed out');
|
||||
} finally {
|
||||
proxy.stop();
|
||||
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Tools Sanitization', () => {
|
||||
@@ -1086,5 +1278,65 @@ describe('ToolSanitizationProxy Integration', () => {
|
||||
proxy.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('ends stalled streaming responses after upstream headers', async () => {
|
||||
const upstream = http.createServer((req, res) => {
|
||||
req.resume();
|
||||
req.on('end', () => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
res.write(
|
||||
'event: message_start\n' +
|
||||
'data: {"type":"message_start","message":{"id":"msg_stall","type":"message","role":"assistant","content":[],"model":"test","stop_reason":null}}\n\n'
|
||||
);
|
||||
});
|
||||
});
|
||||
const upstreamPort = await new Promise<number>((resolve, reject) => {
|
||||
upstream.once('error', reject);
|
||||
upstream.listen(0, '127.0.0.1', () => {
|
||||
const address = upstream.address();
|
||||
if (typeof address !== 'object' || !address) {
|
||||
reject(new Error('Failed to resolve upstream port'));
|
||||
return;
|
||||
}
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
|
||||
const proxy = new ToolSanitizationProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||
timeoutMs: 100,
|
||||
});
|
||||
const port = await proxy.start();
|
||||
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
stream: true,
|
||||
tools: [{ name: 'valid_tool' }],
|
||||
}),
|
||||
});
|
||||
|
||||
const text = await Promise.race([
|
||||
response.text(),
|
||||
new Promise<string>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timed out waiting for proxy response')), 2_000)
|
||||
),
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(text).toContain('message_start');
|
||||
expect(text).toContain('timeout_error');
|
||||
expect(text).toContain('Upstream response timed out');
|
||||
} finally {
|
||||
proxy.stop();
|
||||
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -291,7 +291,19 @@ export function resolveProxyConfig(
|
||||
|
||||
// Merge auth token: CLI > ENV > config.yaml
|
||||
resolved.authToken = cliFlags.authToken ?? envConfig.authToken ?? yamlConfig.remote?.auth_token;
|
||||
resolved.managementKey = yamlConfig.remote?.management_key;
|
||||
|
||||
// Keep YAML management key only when remote target/auth are not overridden via CLI/ENV.
|
||||
// Prevents leaking a saved management key to a different runtime target.
|
||||
const hasRuntimeRemoteOverride =
|
||||
cliFlags.host !== undefined ||
|
||||
envConfig.host !== undefined ||
|
||||
cliFlags.port !== undefined ||
|
||||
envConfig.port !== undefined ||
|
||||
cliFlags.protocol !== undefined ||
|
||||
envConfig.protocol !== undefined ||
|
||||
cliFlags.authToken !== undefined ||
|
||||
envConfig.authToken !== undefined;
|
||||
resolved.managementKey = hasRuntimeRemoteOverride ? undefined : yamlConfig.remote?.management_key;
|
||||
|
||||
// Merge timeout: CLI > ENV > config.yaml > default (2000ms in executor)
|
||||
resolved.timeout = cliFlags.timeout ?? envConfig.timeout ?? yamlConfig.remote?.timeout;
|
||||
|
||||
@@ -22,12 +22,17 @@ import {
|
||||
extractProviderFromPathname,
|
||||
getDeniedModelIdReasonForProvider,
|
||||
normalizeModelIdForRouting,
|
||||
parseCodexModelTuningAlias,
|
||||
stripCodexEffortSuffix,
|
||||
} from '../ai-providers/model-id-normalizer';
|
||||
import { getModelMaxLevel } from '../model-catalog';
|
||||
|
||||
import { createLogger } from '../../services/logging';
|
||||
import { getCcsDir } from '../../config/config-loader-facade';
|
||||
import {
|
||||
attachUpstreamResponseTimeout,
|
||||
writeForwardResponseHead,
|
||||
} from './upstream-response-timeout';
|
||||
|
||||
export interface ToolSanitizationProxyConfig {
|
||||
/** Upstream CLIProxy URL */
|
||||
@@ -59,6 +64,7 @@ const GEMINI_UNSUPPORTED_TOOL_FIELDS = new Set([
|
||||
]);
|
||||
|
||||
const CODEX_UNSUPPORTED_TOOL_FIELDS = new Set(['cache_control']);
|
||||
const CODEX_FAST_SERVICE_TIER = 'priority';
|
||||
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
|
||||
const LEGACY_CODEX_MODEL_ID_REGEX = /^gpt-5(?:\.\d+)?-codex(?:-(?:mini|max))?$/i;
|
||||
|
||||
@@ -86,6 +92,118 @@ function isKnownCodexModelId(model: string | undefined): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isCodexRequest(providerFromPath: string | null, model: unknown): boolean {
|
||||
return (
|
||||
providerFromPath === 'codex' ||
|
||||
(providerFromPath === null && typeof model === 'string' && isKnownCodexModelId(model))
|
||||
);
|
||||
}
|
||||
|
||||
function applyCodexModelTuningAlias(body: Record<string, unknown>): Record<string, unknown> {
|
||||
if (typeof body.model !== 'string') {
|
||||
return body;
|
||||
}
|
||||
|
||||
const parsed = parseCodexModelTuningAlias(body.model);
|
||||
if (!parsed || !isKnownCodexModelId(parsed.baseModel)) {
|
||||
return body;
|
||||
}
|
||||
|
||||
const tunedBody: Record<string, unknown> = { ...body, model: parsed.baseModel };
|
||||
|
||||
if (parsed.effort) {
|
||||
const existingReasoning = isRecord(body.reasoning) ? body.reasoning : {};
|
||||
tunedBody.reasoning = {
|
||||
...existingReasoning,
|
||||
effort: parsed.effort,
|
||||
};
|
||||
}
|
||||
|
||||
if (parsed.serviceTier) {
|
||||
tunedBody.service_tier = CODEX_FAST_SERVICE_TIER;
|
||||
}
|
||||
|
||||
return tunedBody;
|
||||
}
|
||||
|
||||
function extractSystemText(content: unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return content
|
||||
.filter((block): block is { type: unknown; text?: unknown } => isRecord(block))
|
||||
.filter((block) => block.type === 'text' && typeof block.text === 'string')
|
||||
.map((block) => block.text as string)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function prependSystemTextToContent(content: unknown, systemText: string): unknown {
|
||||
if (Array.isArray(content)) {
|
||||
return [{ type: 'text', text: systemText }, ...content];
|
||||
}
|
||||
if (typeof content === 'string') {
|
||||
return `${systemText}\n\n${content}`;
|
||||
}
|
||||
return systemText;
|
||||
}
|
||||
|
||||
function foldCodexSystemMessages(body: Record<string, unknown>): Record<string, unknown> {
|
||||
const systemTexts: string[] = [];
|
||||
let removedSystem = false;
|
||||
const nextBody = { ...body };
|
||||
|
||||
if (body.system !== undefined) {
|
||||
removedSystem = true;
|
||||
delete nextBody.system;
|
||||
const systemText = extractSystemText(body.system).trim();
|
||||
if (systemText) {
|
||||
systemTexts.push(systemText);
|
||||
}
|
||||
}
|
||||
|
||||
const rawMessages = Array.isArray(body.messages) ? body.messages : [];
|
||||
const messages = rawMessages.filter((message) => {
|
||||
if (!isRecord(message) || message.role !== 'system') {
|
||||
return true;
|
||||
}
|
||||
removedSystem = true;
|
||||
const systemText = extractSystemText(message.content).trim();
|
||||
if (systemText) {
|
||||
systemTexts.push(systemText);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!removedSystem) {
|
||||
return body;
|
||||
}
|
||||
|
||||
if (systemTexts.length > 0) {
|
||||
const systemPrefix = systemTexts.join('\n\n');
|
||||
const firstUserIndex = messages.findIndex(
|
||||
(message) => isRecord(message) && message.role === 'user'
|
||||
);
|
||||
|
||||
if (firstUserIndex >= 0 && isRecord(messages[firstUserIndex])) {
|
||||
const firstUserMessage = messages[firstUserIndex];
|
||||
messages[firstUserIndex] = {
|
||||
...firstUserMessage,
|
||||
content: prependSystemTextToContent(firstUserMessage.content, systemPrefix),
|
||||
};
|
||||
} else {
|
||||
messages.unshift({ role: 'user', content: systemPrefix });
|
||||
}
|
||||
}
|
||||
|
||||
nextBody.messages = messages;
|
||||
return nextBody;
|
||||
}
|
||||
|
||||
function getUnsupportedToolFields(
|
||||
providerFromPath: string | null,
|
||||
model: string | undefined
|
||||
@@ -348,6 +466,11 @@ export class ToolSanitizationProxy {
|
||||
}
|
||||
}
|
||||
|
||||
if (isRecord(modifiedBody) && isCodexRequest(providerFromPath, modifiedBody.model)) {
|
||||
const tunedBody = applyCodexModelTuningAlias(modifiedBody);
|
||||
modifiedBody = foldCodexSystemMessages(tunedBody);
|
||||
}
|
||||
|
||||
// Sanitize tools if present
|
||||
if (isRecord(modifiedBody) && Array.isArray(modifiedBody.tools)) {
|
||||
// Step 1: Sanitize input_schema properties (remove non-standard JSON Schema properties)
|
||||
@@ -521,10 +644,38 @@ export class ToolSanitizationProxy {
|
||||
),
|
||||
(upstreamRes) => {
|
||||
clearResponseTimeout();
|
||||
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
|
||||
upstreamRes.pipe(clientRes);
|
||||
upstreamRes.on('end', () => resolve());
|
||||
upstreamRes.on('error', reject);
|
||||
const statusCode = upstreamRes.statusCode || 200;
|
||||
let responseStarted = false;
|
||||
const writeResponseHead = () => {
|
||||
if (responseStarted) return;
|
||||
responseStarted = true;
|
||||
writeForwardResponseHead(clientRes, statusCode, upstreamRes.headers);
|
||||
};
|
||||
const clearUpstreamResponseTimeout = attachUpstreamResponseTimeout({
|
||||
upstreamReq,
|
||||
upstreamRes,
|
||||
clientRes,
|
||||
timeoutMs: this.config.timeoutMs,
|
||||
onTimeout: () => resolve(),
|
||||
});
|
||||
upstreamRes.on('data', (chunk: Buffer) => {
|
||||
writeResponseHead();
|
||||
const canContinue = clientRes.write(chunk);
|
||||
if (!canContinue) {
|
||||
upstreamRes.pause();
|
||||
clientRes.once('drain', () => upstreamRes.resume());
|
||||
}
|
||||
});
|
||||
upstreamRes.on('end', () => {
|
||||
clearUpstreamResponseTimeout();
|
||||
writeResponseHead();
|
||||
clientRes.end();
|
||||
resolve();
|
||||
});
|
||||
upstreamRes.on('error', (error) => {
|
||||
clearUpstreamResponseTimeout();
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -560,10 +711,18 @@ export class ToolSanitizationProxy {
|
||||
),
|
||||
(upstreamRes) => {
|
||||
clearResponseTimeout();
|
||||
const clearUpstreamResponseTimeout = attachUpstreamResponseTimeout({
|
||||
upstreamReq,
|
||||
upstreamRes,
|
||||
clientRes,
|
||||
timeoutMs: this.config.timeoutMs,
|
||||
onTimeout: () => resolve(),
|
||||
});
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
upstreamRes.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
upstreamRes.on('end', () => {
|
||||
clearUpstreamResponseTimeout();
|
||||
try {
|
||||
const responseBody = Buffer.concat(chunks).toString('utf8');
|
||||
const contentType = upstreamRes.headers['content-type'] || '';
|
||||
@@ -598,7 +757,10 @@ export class ToolSanitizationProxy {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
upstreamRes.on('error', reject);
|
||||
upstreamRes.on('error', (error) => {
|
||||
clearUpstreamResponseTimeout();
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -636,7 +798,14 @@ export class ToolSanitizationProxy {
|
||||
),
|
||||
(upstreamRes) => {
|
||||
clearResponseTimeout();
|
||||
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
|
||||
const clearUpstreamResponseTimeout = attachUpstreamResponseTimeout({
|
||||
upstreamReq,
|
||||
upstreamRes,
|
||||
clientRes,
|
||||
timeoutMs: this.config.timeoutMs,
|
||||
onTimeout: () => resolve(),
|
||||
});
|
||||
writeForwardResponseHead(clientRes, upstreamRes.statusCode || 200, upstreamRes.headers);
|
||||
|
||||
// Track upstream SSE lifecycle events (guards against empty proxy responses)
|
||||
const lifecycle = {
|
||||
@@ -673,6 +842,7 @@ export class ToolSanitizationProxy {
|
||||
}
|
||||
});
|
||||
upstreamRes.on('end', () => {
|
||||
clearUpstreamResponseTimeout();
|
||||
try {
|
||||
if (!lifecycle.hasContent && isSuccessResponse && lifecycle.hasData) {
|
||||
this.writeLog(
|
||||
@@ -693,7 +863,10 @@ export class ToolSanitizationProxy {
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
upstreamRes.on('error', reject);
|
||||
upstreamRes.on('error', (error) => {
|
||||
clearUpstreamResponseTimeout();
|
||||
reject(error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -719,6 +892,7 @@ export class ToolSanitizationProxy {
|
||||
});
|
||||
|
||||
upstreamRes.on('end', () => {
|
||||
clearUpstreamResponseTimeout();
|
||||
try {
|
||||
// Process any remaining buffer
|
||||
if (buffer.trim()) {
|
||||
@@ -749,7 +923,10 @@ export class ToolSanitizationProxy {
|
||||
resolve();
|
||||
});
|
||||
|
||||
upstreamRes.on('error', reject);
|
||||
upstreamRes.on('error', (error) => {
|
||||
clearUpstreamResponseTimeout();
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as http from 'http';
|
||||
|
||||
export const UPSTREAM_RESPONSE_TIMEOUT_MESSAGE =
|
||||
'Upstream response timed out while streaming response body';
|
||||
|
||||
export function buildTimeoutSafeResponseHeaders(
|
||||
headers: http.IncomingHttpHeaders
|
||||
): http.OutgoingHttpHeaders {
|
||||
const safeHeaders: http.OutgoingHttpHeaders = {};
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
const normalized = name.toLowerCase();
|
||||
if (normalized === 'content-length' || normalized === 'transfer-encoding') {
|
||||
continue;
|
||||
}
|
||||
safeHeaders[name] = value;
|
||||
}
|
||||
return safeHeaders;
|
||||
}
|
||||
|
||||
export function writeForwardResponseHead(
|
||||
clientRes: http.ServerResponse,
|
||||
statusCode: number,
|
||||
headers: http.IncomingHttpHeaders
|
||||
): void {
|
||||
if (clientRes.headersSent) return;
|
||||
clientRes.writeHead(statusCode, buildTimeoutSafeResponseHeaders(headers));
|
||||
}
|
||||
|
||||
export function writeTimeoutResponse(
|
||||
clientRes: http.ServerResponse,
|
||||
headers: http.IncomingHttpHeaders,
|
||||
message = UPSTREAM_RESPONSE_TIMEOUT_MESSAGE
|
||||
): void {
|
||||
if (clientRes.destroyed || clientRes.writableEnded) return;
|
||||
|
||||
const contentType = String(headers['content-type'] ?? '').toLowerCase();
|
||||
|
||||
try {
|
||||
if (contentType.includes('text/event-stream')) {
|
||||
const payload = {
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'timeout_error',
|
||||
message,
|
||||
},
|
||||
};
|
||||
clientRes.write(`event: error\ndata: ${JSON.stringify(payload)}\n\n`);
|
||||
} else if (!clientRes.headersSent) {
|
||||
clientRes.writeHead(504, { 'Content-Type': 'application/json' });
|
||||
clientRes.write(JSON.stringify({ error: message }));
|
||||
} else {
|
||||
clientRes.write(`\n${JSON.stringify({ error: message })}`);
|
||||
}
|
||||
|
||||
clientRes.end();
|
||||
} catch {
|
||||
// Client may have disconnected while the upstream response was stalled.
|
||||
}
|
||||
}
|
||||
|
||||
export function attachUpstreamResponseTimeout(options: {
|
||||
upstreamReq: http.ClientRequest;
|
||||
upstreamRes: http.IncomingMessage;
|
||||
clientRes: http.ServerResponse;
|
||||
timeoutMs: number;
|
||||
onTimeout?: (error: Error) => void;
|
||||
}): () => void {
|
||||
const { upstreamReq, upstreamRes, clientRes, timeoutMs, onTimeout } = options;
|
||||
let settled = false;
|
||||
|
||||
const clear = () => {
|
||||
settled = true;
|
||||
upstreamRes.setTimeout(0);
|
||||
};
|
||||
|
||||
upstreamRes.setTimeout(timeoutMs, () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
const error = new Error(UPSTREAM_RESPONSE_TIMEOUT_MESSAGE);
|
||||
writeTimeoutResponse(clientRes, upstreamRes.headers, error.message);
|
||||
onTimeout?.(error);
|
||||
upstreamRes.destroy(error);
|
||||
upstreamReq.destroy(error);
|
||||
});
|
||||
|
||||
return clear;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import * as https from 'https';
|
||||
import { describe, expect, it, mock, spyOn } from 'bun:test';
|
||||
import type { ProxyTarget } from '../../proxy/proxy-target-resolver';
|
||||
|
||||
async function loadRoutingHttpModule() {
|
||||
@@ -22,6 +23,73 @@ describe('routing-strategy-http', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed self-signed HTTPS URLs before arming the request timeout', async () => {
|
||||
const { fetchCliproxyRoutingResponse } = await loadRoutingHttpModule();
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
globalThis.setTimeout = mock(((handler: TimerHandler, timeout?: number) => {
|
||||
void handler;
|
||||
void timeout;
|
||||
return 1 as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as typeof setTimeout);
|
||||
|
||||
const target: ProxyTarget = {
|
||||
host: 'bad host',
|
||||
port: 443,
|
||||
protocol: 'https',
|
||||
allowSelfSigned: true,
|
||||
isRemote: true,
|
||||
};
|
||||
|
||||
try {
|
||||
await expect(fetchCliproxyRoutingResponse(target, 'GET')).rejects.toThrow();
|
||||
expect(globalThis.setTimeout).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
it('clears the request timeout when https.request throws synchronously', async () => {
|
||||
const { fetchCliproxyRoutingResponse } = await loadRoutingHttpModule();
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const originalClearTimeout = globalThis.clearTimeout;
|
||||
const activeTimers = new Set<number>();
|
||||
|
||||
globalThis.setTimeout = mock(((handler: TimerHandler, timeout?: number) => {
|
||||
void handler;
|
||||
void timeout;
|
||||
const timerId = activeTimers.size + 1;
|
||||
activeTimers.add(timerId);
|
||||
return timerId as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as typeof setTimeout);
|
||||
globalThis.clearTimeout = mock(((timerId?: ReturnType<typeof setTimeout>) => {
|
||||
activeTimers.delete(timerId as unknown as number);
|
||||
}) as typeof clearTimeout);
|
||||
const requestSpy = spyOn(https, 'request').mockImplementation(() => {
|
||||
throw new Error('sync request failure');
|
||||
});
|
||||
|
||||
const target: ProxyTarget = {
|
||||
host: 'proxy.example.com',
|
||||
port: 443,
|
||||
protocol: 'https',
|
||||
allowSelfSigned: true,
|
||||
isRemote: true,
|
||||
};
|
||||
|
||||
try {
|
||||
await expect(fetchCliproxyRoutingResponse(target, 'GET')).rejects.toThrow(
|
||||
'sync request failure'
|
||||
);
|
||||
expect(globalThis.setTimeout).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.clearTimeout).toHaveBeenCalledTimes(1);
|
||||
expect(activeTimers.size).toBe(0);
|
||||
} finally {
|
||||
requestSpy.mockRestore();
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
globalThis.clearTimeout = originalClearTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
it('builds the remote management URL for routing strategy writes', async () => {
|
||||
const { getCliproxyRoutingManagementUrl } = await loadRoutingHttpModule();
|
||||
const target: ProxyTarget = {
|
||||
|
||||
@@ -40,6 +40,8 @@ export async function fetchCliproxyRoutingResponse(
|
||||
}
|
||||
}
|
||||
|
||||
const requestUrl = new URL(url);
|
||||
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||
let settled = false;
|
||||
@@ -51,57 +53,65 @@ export async function fetchCliproxyRoutingResponse(
|
||||
callback();
|
||||
};
|
||||
|
||||
let req: ReturnType<typeof https.request> | undefined;
|
||||
const timeoutId = setTimeout(() => {
|
||||
const error = new Error('Request timeout');
|
||||
req.destroy(error);
|
||||
req?.destroy(error);
|
||||
settle(() => reject(error));
|
||||
}, ROUTING_TIMEOUT_MS);
|
||||
|
||||
const req = https.request(
|
||||
url,
|
||||
{
|
||||
method,
|
||||
headers,
|
||||
agent,
|
||||
timeout: ROUTING_TIMEOUT_MS,
|
||||
},
|
||||
(res) => {
|
||||
let payload = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => {
|
||||
payload += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
settle(() =>
|
||||
resolve(
|
||||
new Response(payload, {
|
||||
status: res.statusCode || 500,
|
||||
statusText: res.statusMessage ?? '',
|
||||
headers:
|
||||
typeof res.headers['content-type'] === 'string'
|
||||
? { 'Content-Type': res.headers['content-type'] }
|
||||
: undefined,
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
try {
|
||||
req = https.request(
|
||||
requestUrl,
|
||||
{
|
||||
method,
|
||||
headers,
|
||||
agent,
|
||||
timeout: ROUTING_TIMEOUT_MS,
|
||||
},
|
||||
(res) => {
|
||||
let payload = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => {
|
||||
payload += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
settle(() =>
|
||||
resolve(
|
||||
new Response(payload, {
|
||||
status: res.statusCode || 500,
|
||||
statusText: res.statusMessage ?? '',
|
||||
headers:
|
||||
typeof res.headers['content-type'] === 'string'
|
||||
? { 'Content-Type': res.headers['content-type'] }
|
||||
: undefined,
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
settle(() => reject(error));
|
||||
return;
|
||||
}
|
||||
|
||||
req.on('error', (error) => {
|
||||
const request = req;
|
||||
|
||||
request.on('error', (error) => {
|
||||
settle(() => reject(error));
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
request.on('timeout', () => {
|
||||
const error = new Error('Request timeout');
|
||||
req.destroy(error);
|
||||
request.destroy(error);
|
||||
settle(() => reject(error));
|
||||
});
|
||||
|
||||
if (body) {
|
||||
req.write(JSON.stringify(body));
|
||||
request.write(JSON.stringify(body));
|
||||
}
|
||||
req.end();
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ const CHANNEL_TO_PROVIDER: Record<string, CLIProxyProvider> = {
|
||||
kimi: 'kimi',
|
||||
kiro: 'kiro',
|
||||
'github-copilot': 'ghcp',
|
||||
qoder: 'qoder',
|
||||
};
|
||||
|
||||
/** CCS provider → channel name mapping (reverse) */
|
||||
|
||||
@@ -16,7 +16,8 @@ export type CLIProxyProvider =
|
||||
| 'cursor'
|
||||
| 'gitlab'
|
||||
| 'codebuddy'
|
||||
| 'kilo';
|
||||
| 'kilo'
|
||||
| 'qoder';
|
||||
|
||||
/** CLIProxy backend selection */
|
||||
export type CLIProxyBackend = 'original' | 'plus';
|
||||
@@ -32,6 +33,7 @@ export const PLUS_ONLY_PROVIDERS: CLIProxyProvider[] = [
|
||||
'gitlab',
|
||||
'codebuddy',
|
||||
'kilo',
|
||||
'qoder',
|
||||
];
|
||||
|
||||
/** Model mapping for each provider */
|
||||
|
||||
@@ -94,7 +94,7 @@ export function ensureSharedConfigSymlink(
|
||||
target: targetPath,
|
||||
});
|
||||
} catch (err) {
|
||||
copySharedConfigFallback(targetPath, linkPath, err);
|
||||
handleSymlinkFailure(targetPath, linkPath, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,9 +106,30 @@ function isRegularConfigCopyUnmodified(linkPath: string, targetPath: string): bo
|
||||
}
|
||||
}
|
||||
|
||||
function copySharedConfigFallback(targetPath: string, linkPath: string, err: unknown): void {
|
||||
fs.copyFileSync(targetPath, linkPath);
|
||||
fs.chmodSync(linkPath, 0o600);
|
||||
function handleSymlinkFailure(targetPath: string, linkPath: string, err: unknown): void {
|
||||
const code = (err as NodeJS.ErrnoException | null)?.code;
|
||||
if (code !== 'EPERM' && code !== 'EACCES' && code !== 'ENOSYS') {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const tmpPath = path.join(
|
||||
path.dirname(linkPath),
|
||||
`.config.toml.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
);
|
||||
|
||||
try {
|
||||
fs.copyFileSync(targetPath, tmpPath, fs.constants.COPYFILE_EXCL);
|
||||
fs.chmodSync(tmpPath, 0o600);
|
||||
fs.renameSync(tmpPath, linkPath);
|
||||
} catch (copyErr) {
|
||||
try {
|
||||
fs.rmSync(tmpPath, { force: true });
|
||||
} catch {
|
||||
// best effort cleanup
|
||||
}
|
||||
throw copyErr;
|
||||
}
|
||||
|
||||
process.stderr.write(
|
||||
`[!] codex-auth: symlink unavailable; copied shared config.toml to ${linkPath}. ` +
|
||||
`Config edits won't propagate automatically.\n`
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Migrates legacy ~/.codex/auth.json into a named profile (non-destructive).
|
||||
* Implements C3 torn-write protection: read-with-retry + JWT-shape validation
|
||||
* + pgrep Codex-running detection + atomic write.
|
||||
* + current-user Codex-running detection + atomic write.
|
||||
*
|
||||
* Usage: ccsx auth import-default <name> [--with-history] [--force] [--force-while-running]
|
||||
*/
|
||||
@@ -40,51 +40,37 @@ function sleep(ms: number): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a running `codex` process via pgrep + ps validation (best-effort, never throws).
|
||||
* Returns the PID string if found, null otherwise.
|
||||
* Detect a running Codex CLI process from the current user's process table
|
||||
* (best-effort, never throws). Returns the PID string if found, null otherwise.
|
||||
*/
|
||||
function detectCodexRunning(): string | null {
|
||||
try {
|
||||
const result = childProcess.spawnSync('pgrep', ['-f', 'codex'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 2000,
|
||||
});
|
||||
if (result.status !== 0 || !result.stdout || result.stdout.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pids = parsePgrepPids(result.stdout);
|
||||
if (pids.length === 0) return null;
|
||||
|
||||
const psResult = childProcess.spawnSync(
|
||||
const result = childProcess.spawnSync(
|
||||
'ps',
|
||||
['-p', pids.join(','), '-o', 'pid=', '-o', 'command='],
|
||||
['-A', '-o', 'pid=', '-o', 'uid=', '-o', 'command='],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 2000,
|
||||
}
|
||||
);
|
||||
if (psResult.status !== 0 || !psResult.stdout) return null;
|
||||
return selectCodexPidFromPsOutput(psResult.stdout, pids);
|
||||
if (result.status !== 0 || !result.stdout || result.stdout.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return selectCodexPidFromPsOutput(result.stdout);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePgrepPids(stdout: string): string[] {
|
||||
return stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((pid) => /^\d+$/.test(pid) && pid !== String(process.pid));
|
||||
}
|
||||
|
||||
function selectCodexPidFromPsOutput(stdout: string, candidatePids: string[]): string | null {
|
||||
const candidates = new Set(candidatePids);
|
||||
function selectCodexPidFromPsOutput(stdout: string): string | null {
|
||||
const currentUid = typeof process.getuid === 'function' ? process.getuid() : null;
|
||||
for (const line of stdout.split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*(\d+)\s+(.+?)\s*$/);
|
||||
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/);
|
||||
if (!match) continue;
|
||||
const [, pid, command] = match;
|
||||
if (!pid || !command || !candidates.has(pid)) continue;
|
||||
const [, pid, uid, command] = match;
|
||||
if (!pid || !uid || !command || pid === String(process.pid)) continue;
|
||||
if (currentUid !== null && uid !== String(currentUid)) continue;
|
||||
if (isLikelyCodexProcessCommand(command)) return pid;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -1056,8 +1056,13 @@ export async function handlePauseAccount(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
if (account.paused) {
|
||||
const refreshed = pauseAccount(provider, account.id);
|
||||
const refreshedAccount = refreshed ? findAccountByQuery(provider, account.id) : account;
|
||||
console.log(warn(`Account already paused: ${formatCliAccountLabel(account)}`));
|
||||
console.log(info(`Paused at: ${account.pausedAt || 'unknown'}`));
|
||||
if (refreshed) {
|
||||
console.log(info('Manual pause refreshed; account will stay out of quota rotation'));
|
||||
}
|
||||
console.log(info(`Paused at: ${refreshedAccount?.pausedAt || account.pausedAt || 'unknown'}`));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -205,6 +205,7 @@ export const BUILTIN_PROVIDER_SHORTCUTS: readonly ShortcutEntry[] = CLIPROXY_PRO
|
||||
gitlab: 'GitLab Duo via CLIProxy OAuth',
|
||||
codebuddy: 'CodeBuddy via CLIProxy OAuth',
|
||||
kilo: 'Kilo AI via CLIProxy OAuth',
|
||||
qoder: 'Qoder AI via CLIProxy OAuth',
|
||||
}[name] || 'CLIProxy OAuth provider',
|
||||
})
|
||||
);
|
||||
|
||||
@@ -54,31 +54,13 @@ interface ChannelsCommandOptions {
|
||||
setSelectionMissing: boolean;
|
||||
clearTokenAll: boolean;
|
||||
clearTokenChannel?: OfficialChannelId;
|
||||
setToken?: { channelId: OfficialChannelId; token: string };
|
||||
setTokenChannel?: OfficialChannelId;
|
||||
setTokenMissing: boolean;
|
||||
clearTokenInvalid?: string;
|
||||
setTokenInvalid?: string;
|
||||
help: boolean;
|
||||
}
|
||||
|
||||
function parseTokenAssignment(value: string): {
|
||||
channelId: OfficialChannelId;
|
||||
token: string;
|
||||
} | null {
|
||||
const separatorIndex = value.indexOf('=');
|
||||
if (separatorIndex === -1) {
|
||||
return value.trim() ? { channelId: 'discord', token: value.trim() } : null;
|
||||
}
|
||||
|
||||
const channelId = value.slice(0, separatorIndex).trim().toLowerCase();
|
||||
const token = value.slice(separatorIndex + 1).trim();
|
||||
if (!isOfficialChannelId(channelId) || !token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { channelId, token };
|
||||
}
|
||||
|
||||
export function parseChannelsCommandArgs(args: string[]): ChannelsCommandOptions {
|
||||
const setSelection = extractOption(args, ['--set']);
|
||||
const setToken = extractOption(args, ['--set-token']);
|
||||
@@ -100,11 +82,13 @@ export function parseChannelsCommandArgs(args: string[]): ChannelsCommandOptions
|
||||
}
|
||||
}
|
||||
|
||||
let parsedSetToken: { channelId: OfficialChannelId; token: string } | undefined;
|
||||
let parsedSetTokenChannel: OfficialChannelId | undefined;
|
||||
let setTokenInvalid: string | undefined;
|
||||
if (setToken.found && !setToken.missingValue && setToken.value) {
|
||||
parsedSetToken = parseTokenAssignment(setToken.value) ?? undefined;
|
||||
if (!parsedSetToken) {
|
||||
const channelId = setToken.value.trim().toLowerCase();
|
||||
if (isOfficialChannelId(channelId)) {
|
||||
parsedSetTokenChannel = channelId;
|
||||
} else {
|
||||
setTokenInvalid = setToken.value;
|
||||
}
|
||||
}
|
||||
@@ -120,7 +104,7 @@ export function parseChannelsCommandArgs(args: string[]): ChannelsCommandOptions
|
||||
clearTokenAll,
|
||||
clearTokenChannel,
|
||||
clearTokenInvalid,
|
||||
setToken: parsedSetToken,
|
||||
setTokenChannel: parsedSetTokenChannel,
|
||||
setTokenMissing: setToken.found && setToken.missingValue,
|
||||
setTokenInvalid,
|
||||
help: hasAnyFlag(args, ['--help', '-h']),
|
||||
@@ -153,7 +137,7 @@ function showHelp(): void {
|
||||
` ${color('--unattended', 'command')} Also add --dangerously-skip-permissions`
|
||||
);
|
||||
console.log(` ${color('--no-unattended', 'command')} Disable unattended runtime flag`);
|
||||
console.log(` ${color('--set-token <spec>', 'command')} ${getOfficialChannelTokenHelp()}`);
|
||||
console.log(` ${color('--set-token <channel>', 'command')} ${getOfficialChannelTokenHelp()}`);
|
||||
console.log(
|
||||
` ${color('--clear-token [channel]', 'command')} ${getOfficialChannelClearTokenHelp()}`
|
||||
);
|
||||
@@ -173,7 +157,7 @@ function showHelp(): void {
|
||||
` $ ${color('ccs config channels --set all', 'command')} ${dim('# Enable all official channels')}`
|
||||
);
|
||||
console.log(
|
||||
` $ ${color('ccs config channels --set-token telegram=123:abc', 'command')} ${dim('# Save TELEGRAM_BOT_TOKEN')}`
|
||||
` $ ${color('TELEGRAM_BOT_TOKEN=123:abc ccs config channels --set-token telegram', 'command')} ${dim('# Save TELEGRAM_BOT_TOKEN')}`
|
||||
);
|
||||
console.log(
|
||||
` $ ${color('ccs config channels --clear-token discord', 'command')} ${dim('# Clear one token')}`
|
||||
@@ -395,7 +379,9 @@ export async function handleConfigChannelsCommand(args: string[]): Promise<void>
|
||||
}
|
||||
if (options.setTokenInvalid) {
|
||||
console.error(
|
||||
fail(`Invalid --set-token value: ${options.setTokenInvalid} (use <channel>=<token>)`)
|
||||
fail(
|
||||
`Invalid --set-token value: ${options.setTokenInvalid} (use ${getOfficialChannelChoices()})`
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
@@ -444,12 +430,19 @@ export async function handleConfigChannelsCommand(args: string[]): Promise<void>
|
||||
updateConfig({ channels: nextConfig });
|
||||
}
|
||||
|
||||
if (options.setToken) {
|
||||
if (!getOfficialChannelTokenIds().includes(options.setToken.channelId)) {
|
||||
throw new Error(`${options.setToken.channelId} does not use a bot token.`);
|
||||
if (options.setTokenChannel) {
|
||||
if (!getOfficialChannelTokenIds().includes(options.setTokenChannel)) {
|
||||
throw new Error(`${options.setTokenChannel} does not use a bot token.`);
|
||||
}
|
||||
setConfiguredOfficialChannelToken(options.setToken.channelId, options.setToken.token);
|
||||
console.log(ok(`${getOfficialChannelDisplayName(options.setToken.channelId)} token saved`));
|
||||
const envKey = getOfficialChannelEnvKey(options.setTokenChannel);
|
||||
const token = envKey ? process.env[envKey]?.trim() : '';
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
`${getOfficialChannelDisplayName(options.setTokenChannel)} token missing. Set ${envKey} in your environment and rerun.`
|
||||
);
|
||||
}
|
||||
setConfiguredOfficialChannelToken(options.setTokenChannel, token);
|
||||
console.log(ok(`${getOfficialChannelDisplayName(options.setTokenChannel)} token saved`));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export const RESERVED_PROFILE_NAMES = [
|
||||
'gitlab',
|
||||
'codebuddy',
|
||||
'kilo',
|
||||
'qoder',
|
||||
// Copilot API (GitHub Copilot proxy)
|
||||
'copilot',
|
||||
// Cursor IDE (Cursor proxy daemon)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { getCcsDir } from '../utils/config-manager';
|
||||
|
||||
const DAEMON_TOKEN_FILE = 'cursor-daemon-token';
|
||||
|
||||
export function getCursorDaemonToken(): string {
|
||||
const tokenPath = path.join(getCcsDir(), DAEMON_TOKEN_FILE);
|
||||
try {
|
||||
const token = fs.readFileSync(tokenPath, 'utf8').trim();
|
||||
if (token.length > 0) {
|
||||
return token;
|
||||
}
|
||||
} catch {
|
||||
// Token file missing or unreadable; regenerate below.
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
fs.mkdirSync(path.dirname(tokenPath), { recursive: true });
|
||||
fs.writeFileSync(tokenPath, token, { mode: 0o600 });
|
||||
return token;
|
||||
}
|
||||
@@ -55,6 +55,23 @@ interface OpenAIChatRequest {
|
||||
|
||||
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
function getAnthropicRequestToken(headers: http.IncomingHttpHeaders): string {
|
||||
const xApiKey = headers['x-api-key'];
|
||||
if (typeof xApiKey === 'string' && xApiKey.trim().length > 0) {
|
||||
return xApiKey.trim();
|
||||
}
|
||||
|
||||
const authorization = headers.authorization;
|
||||
if (typeof authorization === 'string') {
|
||||
const match = authorization.match(/^Bearer\s+(.+)$/i);
|
||||
if (match && match[1].trim().length > 0) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function writeJson(res: http.ServerResponse, statusCode: number, payload: unknown): void {
|
||||
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(payload));
|
||||
@@ -108,6 +125,23 @@ function readJsonBody(req: http.IncomingMessage): Promise<unknown> {
|
||||
});
|
||||
}
|
||||
|
||||
function hasValidDaemonToken(req: http.IncomingMessage): boolean {
|
||||
const expectedToken = process.env.CCS_CURSOR_DAEMON_TOKEN;
|
||||
if (!expectedToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const provided = req.headers['x-ccs-cursor-token'];
|
||||
if (typeof provided === 'string') {
|
||||
return provided === expectedToken;
|
||||
}
|
||||
|
||||
if (Array.isArray(provided)) {
|
||||
return provided.includes(expectedToken);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
function normalizeMessages(raw: unknown): NormalizedOpenAIMessage[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new Error('messages must be an array');
|
||||
@@ -202,6 +236,10 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
|
||||
|
||||
try {
|
||||
if (method === 'GET' && requestUrl === '/health') {
|
||||
if (!hasValidDaemonToken(req)) {
|
||||
writeJson(res, 401, { error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
writeJson(res, 200, { ok: true, service: 'cursor-daemon' });
|
||||
return;
|
||||
}
|
||||
@@ -234,6 +272,11 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasValidDaemonToken(req)) {
|
||||
writeJson(res, 401, { error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const rawBody = await readJsonBody(req);
|
||||
const anthropicBody = isAnthropicRoute ? translateAnthropicRequest(rawBody) : undefined;
|
||||
const parsedBody = anthropicBody ?? ((rawBody as OpenAIChatRequest) || {});
|
||||
@@ -283,6 +326,22 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAnthropicRoute) {
|
||||
const expectedToken = (process.env.ANTHROPIC_AUTH_TOKEN || 'cursor-managed').trim();
|
||||
const requestToken = getAnthropicRequestToken(req.headers);
|
||||
if (!expectedToken || requestToken !== expectedToken) {
|
||||
await pipeWebResponseToNode(
|
||||
createAnthropicErrorResponse(
|
||||
401,
|
||||
'authentication_error',
|
||||
'Invalid Anthropic auth token. Set ANTHROPIC_AUTH_TOKEN and send it via x-api-key or Authorization Bearer.'
|
||||
),
|
||||
res
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const daemonCredentials = {
|
||||
accessToken: authStatus.credentials.accessToken,
|
||||
machineId: authStatus.credentials.machineId,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { randomBytes } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as http from 'http';
|
||||
@@ -56,7 +57,7 @@ async function resolveDaemonEntrypoint(): Promise<string | null> {
|
||||
* Check if cursor daemon is running on the specified port.
|
||||
* Uses 127.0.0.1 instead of localhost for more reliable local connections.
|
||||
*/
|
||||
export async function isDaemonRunning(port: number): Promise<boolean> {
|
||||
export async function isDaemonRunning(port: number, daemonToken?: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.request(
|
||||
{
|
||||
@@ -65,6 +66,7 @@ export async function isDaemonRunning(port: number): Promise<boolean> {
|
||||
path: '/health',
|
||||
method: 'GET',
|
||||
timeout: 3000,
|
||||
headers: daemonToken ? { 'x-ccs-cursor-token': daemonToken } : undefined,
|
||||
},
|
||||
(res) => {
|
||||
let body = '';
|
||||
@@ -125,14 +127,26 @@ export async function getDaemonStatus(port: number): Promise<CursorDaemonStatus>
|
||||
*/
|
||||
export async function startDaemon(
|
||||
config: CursorDaemonConfig
|
||||
): Promise<{ success: boolean; pid?: number; error?: string }> {
|
||||
): Promise<{ success: boolean; pid?: number; error?: string; daemonToken?: string }> {
|
||||
// Auto-generate daemon token if not provided so /health and protected routes
|
||||
// can be probed by the caller during startup polling.
|
||||
const daemonToken =
|
||||
config.daemon_token && config.daemon_token.trim()
|
||||
? config.daemon_token
|
||||
: randomBytes(32).toString('hex');
|
||||
const effectiveConfig: CursorDaemonConfig = { ...config, daemon_token: daemonToken };
|
||||
|
||||
// Check if already running
|
||||
if (await isDaemonRunning(config.port)) {
|
||||
if (await isDaemonRunning(effectiveConfig.port, effectiveConfig.daemon_token)) {
|
||||
logger.stage('dispatch', 'cursor.daemon.already_running', 'Cursor daemon already running', {
|
||||
provider: 'cursor',
|
||||
port: config.port,
|
||||
});
|
||||
return { success: true, pid: getPidFromFile() ?? undefined };
|
||||
return {
|
||||
success: true,
|
||||
pid: getPidFromFile() ?? undefined,
|
||||
daemonToken: effectiveConfig.daemon_token,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate port before interpolation (prevents injection)
|
||||
@@ -159,7 +173,12 @@ export async function startDaemon(
|
||||
let proc: ChildProcess;
|
||||
let resolved = false;
|
||||
|
||||
const safeResolve = (result: { success: boolean; pid?: number; error?: string }) => {
|
||||
const safeResolve = (result: {
|
||||
success: boolean;
|
||||
pid?: number;
|
||||
error?: string;
|
||||
daemonToken?: string;
|
||||
}) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
if (checkTimeout) clearTimeout(checkTimeout);
|
||||
@@ -205,6 +224,10 @@ export async function startDaemon(
|
||||
proc = spawn(process.execPath, args, {
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_CURSOR_DAEMON_TOKEN: effectiveConfig.daemon_token || '',
|
||||
},
|
||||
});
|
||||
|
||||
// Unref so parent can exit
|
||||
@@ -220,8 +243,8 @@ export async function startDaemon(
|
||||
const pollHealth = async () => {
|
||||
attempts++;
|
||||
|
||||
if (await isDaemonRunning(config.port)) {
|
||||
safeResolve({ success: true, pid: proc.pid });
|
||||
if (await isDaemonRunning(effectiveConfig.port, effectiveConfig.daemon_token)) {
|
||||
safeResolve({ success: true, pid: proc.pid, daemonToken: effectiveConfig.daemon_token });
|
||||
} else if (attempts >= maxAttempts) {
|
||||
// Kill orphaned process
|
||||
if (proc.pid) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as http from 'http';
|
||||
import type { CursorModel } from './types';
|
||||
import type { CursorApiCredentials } from './cursor-protobuf-schema';
|
||||
import { isDaemonRunning } from './cursor-daemon';
|
||||
import { getCursorDaemonToken } from './cursor-daemon-auth';
|
||||
import { buildCursorModelsHeaders } from './cursor-client-policy';
|
||||
import {
|
||||
DEFAULT_CURSOR_MODEL,
|
||||
@@ -285,7 +286,7 @@ export async function fetchModelsFromDaemon(port: number): Promise<CursorModel[]
|
||||
* Checks daemon health first to avoid 5s timeout when daemon is not running.
|
||||
*/
|
||||
export async function getAvailableModels(port: number): Promise<CursorModel[]> {
|
||||
if (!(await isDaemonRunning(port))) {
|
||||
if (!(await isDaemonRunning(port, getCursorDaemonToken()))) {
|
||||
return DEFAULT_CURSOR_MODELS;
|
||||
}
|
||||
return fetchModelsFromDaemon(port);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getImageAnalysisHookEnv, resolveImageAnalysisRuntimeStatus } from '../u
|
||||
import { stripClaudeCodeEnv } from '../utils/shell-executor';
|
||||
import { checkAuthStatus } from './cursor-auth';
|
||||
import { isDaemonRunning, startDaemon } from './cursor-daemon';
|
||||
import { getCursorDaemonToken } from './cursor-daemon-auth';
|
||||
import { getGlobalEnvConfig } from '../config/config-loader-facade';
|
||||
|
||||
interface CursorImageAnalysisResolution {
|
||||
@@ -31,6 +32,7 @@ interface CursorImageAnalysisDeps {
|
||||
|
||||
export function generateCursorEnv(
|
||||
config: CursorConfig,
|
||||
daemonToken: string,
|
||||
claudeConfigDir?: string
|
||||
): Record<string, string> {
|
||||
const opusModel = config.opus_model || config.model;
|
||||
@@ -39,7 +41,7 @@ export function generateCursorEnv(
|
||||
|
||||
return {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${config.port}`,
|
||||
ANTHROPIC_AUTH_TOKEN: 'cursor-managed',
|
||||
ANTHROPIC_AUTH_TOKEN: daemonToken,
|
||||
ANTHROPIC_MODEL: config.model,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel,
|
||||
@@ -130,13 +132,16 @@ export async function executeCursorProfile(
|
||||
return 1;
|
||||
}
|
||||
|
||||
let daemonRunning = await isDaemonRunning(config.port);
|
||||
const daemonToken = getCursorDaemonToken();
|
||||
|
||||
let daemonRunning = await isDaemonRunning(config.port, daemonToken);
|
||||
if (!daemonRunning) {
|
||||
if (config.auto_start) {
|
||||
console.log(info('Starting cursor daemon...'));
|
||||
const result = await startDaemon({
|
||||
port: config.port,
|
||||
ghost_mode: config.ghost_mode,
|
||||
daemon_token: daemonToken,
|
||||
});
|
||||
if (!result.success) {
|
||||
console.error(fail(`Failed to start cursor daemon: ${result.error}`));
|
||||
@@ -154,7 +159,7 @@ export async function executeCursorProfile(
|
||||
}
|
||||
}
|
||||
|
||||
const cursorEnv = generateCursorEnv(config, claudeConfigDir);
|
||||
const cursorEnv = generateCursorEnv(config, daemonToken, claudeConfigDir);
|
||||
const globalEnvConfig = getGlobalEnvConfig();
|
||||
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CursorConfig } from '../config/unified-config-types';
|
||||
import { checkAuthStatus } from './cursor-auth';
|
||||
import { isDaemonRunning, startDaemon } from './cursor-daemon';
|
||||
import { getCursorDaemonToken } from './cursor-daemon-auth';
|
||||
import { getModelsForDaemon, resolveCursorRequestModel } from './cursor-models';
|
||||
|
||||
export interface CursorProbeResult {
|
||||
@@ -136,7 +137,8 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
|
||||
};
|
||||
}
|
||||
|
||||
let daemonRunning = await isDaemonRunning(config.port);
|
||||
const daemonToken = getCursorDaemonToken();
|
||||
let daemonRunning = await isDaemonRunning(config.port, daemonToken);
|
||||
if (!daemonRunning && config.auto_start) {
|
||||
const startResult = await startDaemon({
|
||||
port: config.port,
|
||||
@@ -144,7 +146,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
|
||||
});
|
||||
|
||||
if (!startResult.success) {
|
||||
daemonRunning = await isDaemonRunning(config.port);
|
||||
daemonRunning = await isDaemonRunning(config.port, daemonToken);
|
||||
} else {
|
||||
daemonRunning = true;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
export interface CursorDaemonConfig {
|
||||
port: number;
|
||||
ghost_mode?: boolean;
|
||||
daemon_token?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,6 +29,7 @@ import { resolveTargetType, stripTargetFlag } from '../targets/target-resolver';
|
||||
import { DroidReasoningFlagError } from '../targets/droid-reasoning-runtime';
|
||||
import { DroidCommandRouterError, routeDroidCommandArgs } from '../targets/droid-command-router';
|
||||
import { resolveCliproxyBridgeMetadata } from '../api/services/cliproxy-profile-bridge';
|
||||
import { getClaudeSubcommandName } from '../utils/claude-subcommand-detector';
|
||||
import { resolveCodexRuntimeConfigOverrides } from './environment-builder';
|
||||
import {
|
||||
detectProfile,
|
||||
@@ -83,6 +84,31 @@ function usesImplicitDefaultProfile(cleanArgs: string[]): boolean {
|
||||
return cleanArgs.length === 0 || cleanArgs[0]?.startsWith('-') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a first token that has no matching profile is actually a bare
|
||||
* Claude subcommand that should be forwarded through the default profile.
|
||||
*
|
||||
* Claude Code exposes subcommands like `claude agents`, `claude mcp`,
|
||||
* `claude plugin`, `claude setup-token`. Invoked through CCS as `ccs agents`,
|
||||
* `ccs mcp`, ... the first token is treated as a profile name, so profile
|
||||
* resolution throws "profile not found". Instead, forward such tokens to
|
||||
* `claude <subcommand>` under the default profile (matching `ccs default
|
||||
* agents`), where the launcher already strips interactive-session args.
|
||||
*
|
||||
* Gated to the claude target — codex/droid run their own subcommand routing —
|
||||
* and only reached on the profile-not-found path, so a real configured profile
|
||||
* of the same name always wins.
|
||||
*/
|
||||
export function isBareClaudeSubcommandPassthrough(profile: string, args: string[]): boolean {
|
||||
if (profile === 'default') return false;
|
||||
if (getClaudeSubcommandName([profile]) === null) return false;
|
||||
try {
|
||||
return resolveTargetType(args) === 'claude';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildNativeCodexDefaultProfile(): ProfileDetectionResult {
|
||||
return {
|
||||
type: 'default',
|
||||
@@ -123,8 +149,23 @@ export async function resolveProfileAndTarget(
|
||||
|
||||
// Detect profile (strip --target flags before profile detection)
|
||||
const cleanArgs = stripTargetFlag(args);
|
||||
const { profile, remainingArgs } = detectProfile(cleanArgs);
|
||||
let profileInfo: ProfileDetectionResult = detector.detectProfileType(profile);
|
||||
const detected = detectProfile(cleanArgs);
|
||||
let profile = detected.profile;
|
||||
let remainingArgs = detected.remainingArgs;
|
||||
let profileInfo: ProfileDetectionResult;
|
||||
try {
|
||||
profileInfo = detector.detectProfileType(profile);
|
||||
} catch (profileError) {
|
||||
// Bare Claude subcommand passthrough: forward `ccs agents`, `ccs mcp`, ...
|
||||
// through the default profile instead of failing as an unknown profile.
|
||||
if (isBareClaudeSubcommandPassthrough(profile, args)) {
|
||||
remainingArgs = [profile, ...remainingArgs];
|
||||
profile = 'default';
|
||||
profileInfo = detector.detectProfileType(profile);
|
||||
} else {
|
||||
throw profileError;
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedTarget: ReturnType<typeof resolveTargetType>;
|
||||
try {
|
||||
|
||||
@@ -7,6 +7,7 @@ export const DOCKER_LEGACY_API_KEY = 'ccs-internal-managed';
|
||||
export const DEFAULT_DOCKER_LEGACY_KEY_GRACE_DAYS = 14;
|
||||
export const DOCKER_LEGACY_KEY_GRACE_ENV = 'CCS_DOCKER_LEGACY_KEY_GRACE_DAYS';
|
||||
export const DOCKER_RESTORE_LEGACY_KEY_ENV = 'CCS_DOCKER_RESTORE_LEGACY_API_KEY';
|
||||
export const DOCKER_ENABLE_LEGACY_KEY_AUTH_ENV = 'CCS_DOCKER_ENABLE_LEGACY_KEY_AUTH';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const STATE_VERSION = 1;
|
||||
@@ -67,6 +68,10 @@ export function shouldRestoreDockerLegacyApiKey(env = process.env): boolean {
|
||||
return env[DOCKER_RESTORE_LEGACY_KEY_ENV] === '1';
|
||||
}
|
||||
|
||||
export function shouldEnableDockerLegacyKeyAuth(env = process.env): boolean {
|
||||
return env[DOCKER_ENABLE_LEGACY_KEY_AUTH_ENV] === '1';
|
||||
}
|
||||
|
||||
export function readDockerBootstrapState(): DockerBootstrapStateReadResult {
|
||||
const statePath = getDockerBootstrapStatePath();
|
||||
if (!fs.existsSync(statePath)) {
|
||||
@@ -135,6 +140,10 @@ export function isDockerLegacyKeyGraceActive(
|
||||
}
|
||||
|
||||
export function getActiveDockerLegacyApiKeys(now = new Date()): string[] {
|
||||
if (!shouldEnableDockerLegacyKeyAuth()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { state } = readDockerBootstrapState();
|
||||
if (!isDockerLegacyKeyGraceActive(state, now)) {
|
||||
return [];
|
||||
|
||||
+16
-1
@@ -35,12 +35,14 @@ export class SSEParser {
|
||||
private eventCount: number;
|
||||
private maxBufferSize: number;
|
||||
private throwOnMalformedJson: boolean;
|
||||
private pendingCR: boolean;
|
||||
|
||||
constructor(options: SSEParserOptions = {}) {
|
||||
this.buffer = '';
|
||||
this.eventCount = 0;
|
||||
this.maxBufferSize = options.maxBufferSize || 1024 * 1024; // 1MB default
|
||||
this.throwOnMalformedJson = options.throwOnMalformedJson === true;
|
||||
this.pendingCR = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,7 +51,19 @@ export class SSEParser {
|
||||
* @returns Array of parsed events
|
||||
*/
|
||||
parse(chunk: Buffer | string): SSEEvent[] {
|
||||
this.buffer += chunk.toString().replace(/\r\n?/g, '\n');
|
||||
let normalizedChunk = chunk.toString();
|
||||
|
||||
if (this.pendingCR) {
|
||||
if (normalizedChunk.startsWith('\n')) {
|
||||
normalizedChunk = normalizedChunk.slice(1);
|
||||
}
|
||||
this.pendingCR = false;
|
||||
}
|
||||
|
||||
const endsWithCR = normalizedChunk.endsWith('\r');
|
||||
normalizedChunk = normalizedChunk.replace(/\r\n?/g, '\n');
|
||||
this.pendingCR = endsWithCR;
|
||||
this.buffer += normalizedChunk;
|
||||
|
||||
// C-01 Fix: Prevent unbounded buffer growth (DoS protection)
|
||||
if (this.buffer.length > this.maxBufferSize) {
|
||||
@@ -127,5 +141,6 @@ export class SSEParser {
|
||||
reset(): void {
|
||||
this.buffer = '';
|
||||
this.eventCount = 0;
|
||||
this.pendingCR = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,7 +354,19 @@ class SharedManager {
|
||||
}
|
||||
|
||||
const entryPath = path.join(sharedPluginsPath, entry.name);
|
||||
const stats = fs.statSync(entryPath);
|
||||
let stats: fs.Stats;
|
||||
try {
|
||||
stats = fs.statSync(entryPath);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
console.log(
|
||||
warn(
|
||||
`Skipping plugins/${entry.name}: unable to inspect shared plugin entry${code ? ` (${code})` : ''}`
|
||||
)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
items.set(entry.name, {
|
||||
name: entry.name,
|
||||
type: stats.isDirectory() ? 'directory' : 'file',
|
||||
|
||||
@@ -285,7 +285,7 @@ function getOpenAICompatProxyStateForProfile(profileName: string): OpenAICompatP
|
||||
};
|
||||
}
|
||||
|
||||
return { pid: null, session: null, source: 'profile' };
|
||||
return { pid: getOpenAICompatProxyPid(profileName), session: null, source: 'profile' };
|
||||
}
|
||||
|
||||
export async function listOpenAICompatProxyStatuses(): Promise<OpenAICompatProxyStatus[]> {
|
||||
|
||||
@@ -42,15 +42,93 @@ function isDirectOpenAIReasoningChatModel(
|
||||
);
|
||||
}
|
||||
|
||||
function isMiniMaxOpenAICompatProfile(profile: OpenAICompatProfileConfig): boolean {
|
||||
try {
|
||||
return new URL(profile.baseUrl).hostname.toLowerCase().includes('minimax');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function prependTextToContent(
|
||||
content: ProxyOpenAIRequest['messages'][number]['content'],
|
||||
text: string
|
||||
): ProxyOpenAIRequest['messages'][number]['content'] {
|
||||
if (Array.isArray(content)) {
|
||||
return [{ type: 'text', text }, ...content];
|
||||
}
|
||||
if (typeof content === 'string') {
|
||||
return `${text}\n\n${content}`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function extractTextContent(content: ProxyOpenAIRequest['messages'][number]['content']): string {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return '';
|
||||
}
|
||||
return content
|
||||
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function shapeMiniMaxChatPayload(payload: ProxyOpenAIRequest): ProxyOpenAIRequest {
|
||||
const systemMessages: string[] = [];
|
||||
let removedSystemMessage = false;
|
||||
const messages = payload.messages.filter((message) => {
|
||||
if (message.role !== 'system') {
|
||||
return true;
|
||||
}
|
||||
removedSystemMessage = true;
|
||||
const systemText = extractTextContent(message.content).trim();
|
||||
if (systemText.length > 0) {
|
||||
systemMessages.push(systemText);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!removedSystemMessage) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (systemMessages.length === 0) {
|
||||
return { ...payload, messages };
|
||||
}
|
||||
|
||||
const systemPrefix = systemMessages.join('\n\n');
|
||||
const firstUserIndex = messages.findIndex((message) => message.role === 'user');
|
||||
|
||||
if (firstUserIndex >= 0) {
|
||||
messages[firstUserIndex] = {
|
||||
...messages[firstUserIndex],
|
||||
content: prependTextToContent(messages[firstUserIndex].content, systemPrefix),
|
||||
};
|
||||
} else {
|
||||
messages.unshift({ role: 'user', content: systemPrefix });
|
||||
}
|
||||
|
||||
return { ...payload, messages };
|
||||
}
|
||||
|
||||
function shapeUpstreamChatPayload(
|
||||
payload: ProxyOpenAIRequest,
|
||||
profile: OpenAICompatProfileConfig
|
||||
): ProxyOpenAIRequest {
|
||||
if (!isDirectOpenAIReasoningChatModel(profile, payload.model)) {
|
||||
return payload;
|
||||
let shaped = payload;
|
||||
|
||||
if (isMiniMaxOpenAICompatProfile(profile)) {
|
||||
shaped = shapeMiniMaxChatPayload(shaped);
|
||||
}
|
||||
|
||||
const shaped = { ...payload };
|
||||
if (!isDirectOpenAIReasoningChatModel(profile, shaped.model)) {
|
||||
return shaped;
|
||||
}
|
||||
|
||||
shaped = { ...shaped };
|
||||
|
||||
if (shaped.max_tokens !== undefined) {
|
||||
shaped.max_completion_tokens = shaped.max_tokens;
|
||||
|
||||
@@ -40,7 +40,7 @@ type AnthropicContentBlock =
|
||||
| { type: string; [key: string]: unknown };
|
||||
|
||||
interface AnthropicMessage {
|
||||
role?: 'user' | 'assistant' | string;
|
||||
role?: 'system' | 'user' | 'assistant' | string;
|
||||
content?: string | AnthropicContentBlock[];
|
||||
}
|
||||
|
||||
@@ -435,8 +435,8 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
|
||||
messagesValue.forEach((message, messageIndex) => {
|
||||
const parsedMessage = assertObject(message, `messages[${messageIndex}]`) as AnthropicMessage;
|
||||
const role = parsedMessage.role;
|
||||
if (role !== 'user' && role !== 'assistant') {
|
||||
throw new Error(`messages[${messageIndex}].role must be "user" or "assistant"`);
|
||||
if (role !== 'system' && role !== 'user' && role !== 'assistant') {
|
||||
throw new Error(`messages[${messageIndex}].role must be "system", "user", or "assistant"`);
|
||||
}
|
||||
|
||||
if (pendingToolUseIds && pendingToolUseIds.size > 0 && role !== 'user') {
|
||||
@@ -446,6 +446,14 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
|
||||
}
|
||||
|
||||
const content = parsedMessage.content;
|
||||
if (role === 'system') {
|
||||
translatedMessages.push({
|
||||
role: 'system',
|
||||
content: flattenTextContent(content, `messages[${messageIndex}].content`),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof content === 'string') {
|
||||
if (pendingToolUseIds && pendingToolUseIds.size > 0) {
|
||||
throw new Error(
|
||||
|
||||
@@ -13,6 +13,7 @@ import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
|
||||
import { getProxyTarget } from '../cliproxy/proxy/proxy-target-resolver';
|
||||
import { generateCopilotEnv } from '../copilot/copilot-executor';
|
||||
import { generateCursorEnv } from '../cursor';
|
||||
import { getCursorDaemonToken } from '../cursor/cursor-daemon-auth';
|
||||
import InstanceManager from '../management/instance-manager';
|
||||
import SharedManager from '../management/shared-manager';
|
||||
import { expandPath } from '../utils/helpers';
|
||||
@@ -245,7 +246,11 @@ async function resolveExtensionEnv(
|
||||
if (!result.cursorConfig) {
|
||||
throw new Error(`Profile "${requestedProfile}" is missing cursor configuration.`);
|
||||
}
|
||||
return generateCursorEnv(result.cursorConfig, continuity.claudeConfigDir);
|
||||
return generateCursorEnv(
|
||||
result.cursorConfig,
|
||||
getCursorDaemonToken(),
|
||||
continuity.claudeConfigDir
|
||||
);
|
||||
})()
|
||||
: (() => {
|
||||
if (!result.provider) {
|
||||
|
||||
@@ -57,6 +57,7 @@ const PROVIDER_OWNER_HINTS: Record<string, string[]> = {
|
||||
kimi: ['kimi', 'moonshot'],
|
||||
kiro: ['kiro', 'aws'],
|
||||
ghcp: ['github', 'copilot'],
|
||||
qoder: ['qoder'],
|
||||
};
|
||||
|
||||
function normalize(value: string | null | undefined): string {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export const CODEX_TRANSLATOR_URL_MARKER = '/api/provider/codex';
|
||||
|
||||
const MAX_CODEX_TRANSLATOR_SCAN_DEPTH = 100;
|
||||
const MAX_CODEX_TRANSLATOR_SCAN_NODES = 10000;
|
||||
|
||||
function formatSettingsPathSegment(basePath: string, segment: string | number): string {
|
||||
if (typeof segment === 'number') {
|
||||
return `${basePath}[${segment}]`;
|
||||
@@ -13,23 +16,62 @@ function formatSettingsPathSegment(basePath: string, segment: string | number):
|
||||
}
|
||||
|
||||
export function findCodexTranslatorUrlPaths(value: unknown, path = ''): string[] {
|
||||
if (typeof value === 'string') {
|
||||
return value.includes(CODEX_TRANSLATOR_URL_MARKER) ? [path || '(root)'] : [];
|
||||
const matches: string[] = [];
|
||||
const stack: Array<{ value: unknown; path: string; depth: number }> = [{ value, path, depth: 0 }];
|
||||
const seen = new WeakSet<object>();
|
||||
let visitedNodes = 0;
|
||||
|
||||
while (stack.length > 0 && visitedNodes < MAX_CODEX_TRANSLATOR_SCAN_NODES) {
|
||||
const item = stack.pop();
|
||||
if (!item) {
|
||||
break;
|
||||
}
|
||||
|
||||
visitedNodes += 1;
|
||||
|
||||
if (typeof item.value === 'string') {
|
||||
if (item.value.includes(CODEX_TRANSLATOR_URL_MARKER)) {
|
||||
matches.push(item.path || '(root)');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof item.value !== 'object' || item.value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.has(item.value)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(item.value);
|
||||
|
||||
if (item.depth >= MAX_CODEX_TRANSLATOR_SCAN_DEPTH) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(item.value)) {
|
||||
for (let index = item.value.length - 1; index >= 0; index -= 1) {
|
||||
stack.push({
|
||||
value: item.value[index],
|
||||
path: formatSettingsPathSegment(item.path, index),
|
||||
depth: item.depth + 1,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const entries = Object.entries(item.value);
|
||||
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||
const [key, child] = entries[index];
|
||||
stack.push({
|
||||
value: child,
|
||||
path: formatSettingsPathSegment(item.path, key),
|
||||
depth: item.depth + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item, index) =>
|
||||
findCodexTranslatorUrlPaths(item, formatSettingsPathSegment(path, index))
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return Object.entries(value).flatMap(([key, item]) =>
|
||||
findCodexTranslatorUrlPaths(item, formatSettingsPathSegment(path, key))
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function formatSettingsPathList(paths: string[]): string {
|
||||
|
||||
@@ -222,7 +222,7 @@ function prepareExplicitCodexHome(
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
fs.mkdirSync(codexHome, { mode: 0o700, recursive: true });
|
||||
} catch (err) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
if (error.code !== 'EEXIST') {
|
||||
|
||||
@@ -25,6 +25,26 @@ describe('withRetry', () => {
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('uses the default retryability check when retryableCheck is null at runtime', async () => {
|
||||
let attempt = 0;
|
||||
const fn = mock(() => {
|
||||
attempt++;
|
||||
if (attempt < 2) {
|
||||
return Promise.reject(new RetryableError('transient failure'));
|
||||
}
|
||||
return Promise.resolve('ok');
|
||||
});
|
||||
|
||||
const result = await withRetry(fn, {
|
||||
maxRetries: 3,
|
||||
baseDelayMs: 1,
|
||||
retryableCheck: null,
|
||||
} as unknown as RetryOptions);
|
||||
|
||||
expect(result).toBe('ok');
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws after max retries exhausted', async () => {
|
||||
const fn = mock(() => Promise.reject(new RetryableError('always fails')));
|
||||
await expect(withRetry(fn, { maxRetries: 2, baseDelayMs: 1 })).rejects.toThrow('always fails');
|
||||
|
||||
@@ -230,8 +230,11 @@ export function getEffectiveClaudeBrowserAttachConfig(
|
||||
const override = getBrowserAttachOverride(env);
|
||||
const configUserDataDir =
|
||||
resolveBrowserUserDataDir(config.claude.user_data_dir) ?? getRecommendedBrowserUserDataDir();
|
||||
const configHasExplicitPort = config.claude.devtools_port !== undefined;
|
||||
const configPort = normalizeDevtoolsPort(config.claude.devtools_port);
|
||||
const configEvalMode = config.claude.eval_mode ?? 'readonly';
|
||||
const envEvalMode = parseBrowserEvalMode(env.CCS_BROWSER_EVAL_MODE);
|
||||
const effectiveEvalMode = envEvalMode ?? configEvalMode;
|
||||
|
||||
if (override.userDataDir) {
|
||||
return {
|
||||
@@ -241,7 +244,7 @@ export function getEffectiveClaudeBrowserAttachConfig(
|
||||
userDataDir: override.userDataDir,
|
||||
devtoolsPort: override.devtoolsPort ?? configPort,
|
||||
hasExplicitDevtoolsPort: override.devtoolsPort !== undefined,
|
||||
evalMode: configEvalMode,
|
||||
evalMode: effectiveEvalMode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -251,8 +254,8 @@ export function getEffectiveClaudeBrowserAttachConfig(
|
||||
overrideActive: false,
|
||||
userDataDir: configUserDataDir,
|
||||
devtoolsPort: configPort,
|
||||
hasExplicitDevtoolsPort: true,
|
||||
evalMode: configEvalMode,
|
||||
hasExplicitDevtoolsPort: configHasExplicitPort,
|
||||
evalMode: effectiveEvalMode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -304,6 +307,15 @@ function parseDevtoolsPort(value?: string): number | undefined {
|
||||
return normalizeDevtoolsPort(Number.parseInt(value.trim(), 10));
|
||||
}
|
||||
|
||||
function parseBrowserEvalMode(value?: string): BrowserEvalMode | undefined {
|
||||
const trimmed = value?.trim();
|
||||
if (trimmed === 'disabled' || trimmed === 'readonly' || trimmed === 'readwrite') {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeDevtoolsPort(value: number | undefined): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 9222;
|
||||
|
||||
@@ -25,11 +25,7 @@ export function isLegacyGlmtBaseUrl(baseUrl: string | null | undefined): boolean
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
normalized === LEGACY_GLMT_BASE_URL ||
|
||||
normalized.includes('/api/coding/paas/v4') ||
|
||||
normalized.endsWith('/chat/completions')
|
||||
);
|
||||
return normalized === LEGACY_GLMT_BASE_URL || normalized.includes('/api/coding/paas/v4');
|
||||
}
|
||||
|
||||
export function normalizeDeprecatedGlmtEnv(env: Record<string, string>): GlmtNormalizationResult {
|
||||
|
||||
@@ -99,7 +99,7 @@ export async function withRetry<T>(fn: () => Promise<T>, options: RetryOptions):
|
||||
throw new Error('withRetry: baseDelayMs must be >= 0');
|
||||
}
|
||||
|
||||
const isRetryable = retryableCheck;
|
||||
const isRetryable = retryableCheck ?? defaultRetryableCheck;
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
|
||||
@@ -19,8 +19,6 @@ import {
|
||||
isDashboardWebSocketUpgradeAllowed,
|
||||
} from './middleware/auth-middleware';
|
||||
import { requestLoggingMiddleware } from './middleware/request-logging-middleware';
|
||||
import { ensureManagedModelPrefixes } from '../cliproxy/ai-providers/managed-model-prefixes';
|
||||
import { getProxyTarget } from '../cliproxy/proxy/proxy-target-resolver';
|
||||
import { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync';
|
||||
import { shutdownUsageAggregator } from './usage/aggregator';
|
||||
import { createLogger } from '../services/logging';
|
||||
@@ -172,14 +170,6 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
|
||||
// Start auto-sync watcher (if enabled in config)
|
||||
startAutoSyncWatcher();
|
||||
|
||||
if (!getProxyTarget().isRemote) {
|
||||
void ensureManagedModelPrefixes().catch((error) => {
|
||||
logger.warn('cliproxy.prefix_sync_failed', 'Managed model prefix repair failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Combined cleanup function
|
||||
const cleanup = () => {
|
||||
wsCleanup();
|
||||
|
||||
@@ -246,10 +246,36 @@ export function requireLocalAccessWhenAuthDisabled(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isLoopbackRemoteAddress(req.socket.remoteAddress)) {
|
||||
return true;
|
||||
if (!isLoopbackRemoteAddress(req.socket.remoteAddress)) {
|
||||
res.status(403).json({ error });
|
||||
return false;
|
||||
}
|
||||
|
||||
res.status(403).json({ error });
|
||||
return false;
|
||||
const host = parseHostHeader(getSingleHeader(req.headers.host));
|
||||
if (!host || !isLoopbackHostname(host.hostname)) {
|
||||
res.status(403).json({ error });
|
||||
return false;
|
||||
}
|
||||
|
||||
const originHeader = getSingleHeader(req.headers.origin);
|
||||
if (originHeader) {
|
||||
let origin: URL;
|
||||
try {
|
||||
origin = new URL(originHeader);
|
||||
} catch {
|
||||
res.status(403).json({ error });
|
||||
return false;
|
||||
}
|
||||
|
||||
const isSameHost = origin.host.toLowerCase() === host.host.toLowerCase();
|
||||
const isLoopbackAlias =
|
||||
isHttpOrigin(origin) && isLoopbackHostname(origin.hostname) && origin.port === host.port;
|
||||
|
||||
if (!isSameHost && !isLoopbackAlias) {
|
||||
res.status(403).json({ error });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -243,6 +243,12 @@ const PRICING_REGISTRY: Record<string, ModelPricing> = {
|
||||
cacheCreationPerMillion: 6.25,
|
||||
cacheReadPerMillion: 0.5,
|
||||
},
|
||||
'claude-opus-4-7-thinking': {
|
||||
inputPerMillion: 5.0,
|
||||
outputPerMillion: 25.0,
|
||||
cacheCreationPerMillion: 6.25,
|
||||
cacheReadPerMillion: 0.5,
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenAI Models - Source: better-ccusage
|
||||
|
||||
@@ -348,6 +348,24 @@ export function getStartAuthNicknameError(
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getReauthAccountTarget(
|
||||
accountId: string | undefined,
|
||||
existingAccounts: Array<{ id: string; nickname?: string }>
|
||||
): { account?: { id: string; nickname?: string }; error?: string } {
|
||||
if (!accountId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const account = existingAccounts.find((candidate) => candidate.id === accountId);
|
||||
if (!account) {
|
||||
return {
|
||||
error: `Account '${accountId}' not found for this provider`,
|
||||
};
|
||||
}
|
||||
|
||||
return { account };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles
|
||||
* Also fetches CLIProxyAPI stats to update lastUsedAt for active providers
|
||||
@@ -623,6 +641,8 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
const requestBody =
|
||||
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {};
|
||||
const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined;
|
||||
const accountId =
|
||||
typeof requestBody.accountId === 'string' ? requestBody.accountId.trim() : undefined;
|
||||
const noIncognitoBody =
|
||||
typeof requestBody.noIncognito === 'boolean' ? requestBody.noIncognito : undefined;
|
||||
const kiroMethodRaw = requestBody.kiroMethod;
|
||||
@@ -659,6 +679,16 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
return;
|
||||
}
|
||||
|
||||
const localProvider = provider as CLIProxyProvider;
|
||||
const existingAccounts = getProviderAccounts(localProvider);
|
||||
const reauthTarget = getReauthAccountTarget(accountId, existingAccounts);
|
||||
if (reauthTarget.error) {
|
||||
res.status(404).json({ error: reauthTarget.error });
|
||||
return;
|
||||
}
|
||||
const targetAccountId = reauthTarget.account?.id;
|
||||
const effectiveNickname = nickname || reauthTarget.account?.nickname;
|
||||
|
||||
if (provider === 'kiro' && invalidKiroMethod) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid kiroMethod. Supported: aws, aws-authcode, google, github, idc',
|
||||
@@ -708,7 +738,6 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
}
|
||||
|
||||
try {
|
||||
const localProvider = provider as CLIProxyProvider;
|
||||
const knownTokenFiles = listProviderTokenSnapshots(localProvider);
|
||||
const response = await fetch(buildProxyUrl(target, '/v0/management/gitlab-auth-url'), {
|
||||
method: 'POST',
|
||||
@@ -738,7 +767,8 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
|
||||
const tokenSnapshot = findNewTokenSnapshot(
|
||||
listProviderTokenSnapshots(localProvider),
|
||||
knownTokenFiles
|
||||
knownTokenFiles,
|
||||
targetAccountId
|
||||
);
|
||||
if (!tokenSnapshot) {
|
||||
res.status(409).json({
|
||||
@@ -750,9 +780,9 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
const account = registerAccountFromToken(
|
||||
localProvider,
|
||||
getProviderTokenDir(localProvider),
|
||||
nickname,
|
||||
effectiveNickname,
|
||||
false,
|
||||
tokenSnapshot.file
|
||||
targetAccountId || tokenSnapshot.file
|
||||
);
|
||||
if (!account) {
|
||||
res.status(409).json({
|
||||
@@ -784,11 +814,11 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
}
|
||||
}
|
||||
|
||||
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
|
||||
const nicknameError = getStartAuthNicknameError(
|
||||
provider as CLIProxyProvider,
|
||||
nickname,
|
||||
existingAccounts
|
||||
localProvider,
|
||||
effectiveNickname,
|
||||
existingAccounts,
|
||||
targetAccountId
|
||||
);
|
||||
if (nicknameError) {
|
||||
res.status(400).json(nicknameError);
|
||||
@@ -808,7 +838,8 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
const account = await triggerOAuth(provider as CLIProxyProvider, {
|
||||
add: true, // Always add mode from UI
|
||||
headless: false, // Force interactive mode
|
||||
nickname: nickname || undefined,
|
||||
nickname: effectiveNickname || undefined,
|
||||
expectedAccountId: targetAccountId,
|
||||
acceptAgyRisk: provider === 'agy',
|
||||
kiroMethod: provider === 'kiro' ? kiroMethod : undefined,
|
||||
kiroIDCStartUrl: provider === 'kiro' ? kiroIDCStartUrl : undefined,
|
||||
@@ -971,6 +1002,8 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
|
||||
const requestBody =
|
||||
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {};
|
||||
const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined;
|
||||
const accountId =
|
||||
typeof requestBody.accountId === 'string' ? requestBody.accountId.trim() : undefined;
|
||||
const kiroMethodRaw = requestBody.kiroMethod;
|
||||
const gitlabAuthModeRaw = requestBody.gitlabAuthMode;
|
||||
const gitlabBaseUrl =
|
||||
@@ -1037,11 +1070,20 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
|
||||
return;
|
||||
}
|
||||
|
||||
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
|
||||
const localProvider = provider as CLIProxyProvider;
|
||||
const existingAccounts = getProviderAccounts(localProvider);
|
||||
const reauthTarget = getReauthAccountTarget(accountId, existingAccounts);
|
||||
if (reauthTarget.error) {
|
||||
res.status(404).json({ error: reauthTarget.error });
|
||||
return;
|
||||
}
|
||||
const targetAccountId = reauthTarget.account?.id;
|
||||
const effectiveNickname = nickname || reauthTarget.account?.nickname;
|
||||
const nicknameError = getStartAuthNicknameError(
|
||||
provider as CLIProxyProvider,
|
||||
nickname,
|
||||
existingAccounts
|
||||
localProvider,
|
||||
effectiveNickname,
|
||||
existingAccounts,
|
||||
targetAccountId
|
||||
);
|
||||
if (nicknameError) {
|
||||
res.status(400).json(nicknameError);
|
||||
@@ -1136,8 +1178,9 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
|
||||
|
||||
if (oauthState) {
|
||||
rememberManualAuthState(oauthState, {
|
||||
nickname: nickname || undefined,
|
||||
knownTokenFiles: listProviderTokenSnapshots(provider as CLIProxyProvider),
|
||||
nickname: effectiveNickname || undefined,
|
||||
expectedAccountId: targetAccountId,
|
||||
knownTokenFiles: listProviderTokenSnapshots(localProvider),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1237,7 +1280,7 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
|
||||
getProviderTokenDir(localProvider),
|
||||
pendingAuth.nickname,
|
||||
false,
|
||||
tokenSnapshot.file
|
||||
pendingAuth.expectedAccountId || tokenSnapshot.file
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
@@ -1386,7 +1429,7 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
|
||||
getProviderTokenDir(localProvider),
|
||||
pendingAuth.nickname,
|
||||
false,
|
||||
tokenSnapshot.file
|
||||
pendingAuth.expectedAccountId || tokenSnapshot.file
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
|
||||
@@ -116,9 +116,22 @@ export function createCliproxyLocalProxyRouter(deps: CliproxyLocalProxyDeps = {}
|
||||
}
|
||||
|
||||
res.writeHead(proxyStatus, proxyRes.headers);
|
||||
// Manual streaming instead of pipe() for Bun runtime compatibility
|
||||
proxyRes.on('data', (chunk: Buffer) => res.write(chunk));
|
||||
proxyRes.on('end', () => res.end());
|
||||
// Manual streaming instead of pipe() for Bun runtime compatibility.
|
||||
// Explicitly honor downstream backpressure to avoid unbounded buffering.
|
||||
const onDrain = () => proxyRes.resume();
|
||||
|
||||
proxyRes.on('data', (chunk: Buffer) => {
|
||||
const canContinue = res.write(chunk);
|
||||
if (!canContinue) {
|
||||
proxyRes.pause();
|
||||
res.once('drain', onDrain);
|
||||
}
|
||||
});
|
||||
|
||||
proxyRes.on('end', () => {
|
||||
res.off('drain', onDrain);
|
||||
res.end();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -134,13 +147,21 @@ export function createCliproxyLocalProxyRouter(deps: CliproxyLocalProxyDeps = {}
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up proxy connection only when the client aborts the request.
|
||||
// Avoid res.on('close') here because Bun may emit it during local error
|
||||
// responses before the JSON body is flushed, which can truncate 502 payloads.
|
||||
req.on('aborted', () => {
|
||||
if (!res.writableEnded) {
|
||||
const cleanupProxyRequest = () => {
|
||||
if (!proxyReq.destroyed) {
|
||||
proxyReq.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
// Request-abort cleanup covers disconnects before the response starts.
|
||||
req.on('aborted', cleanupProxyRequest);
|
||||
|
||||
// Response close cleanup covers disconnects while streaming the proxied response.
|
||||
// Guard on writableEnded/finished so successful proxy completions are untouched.
|
||||
res.on('close', () => {
|
||||
if (!res.writableEnded || !res.finished) {
|
||||
cleanupProxyRequest();
|
||||
}
|
||||
});
|
||||
|
||||
if (bodyBuffer) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
|
||||
import cursorSettingsRoutes from './cursor-settings-routes';
|
||||
import { getCursorConfig } from '../../config/config-loader-facade';
|
||||
import { isDashboardWebSocketOriginAllowed } from '../middleware/auth-middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -192,7 +193,12 @@ router.get('/models', async (_req: Request, res: Response): Promise<void> => {
|
||||
/**
|
||||
* POST /api/cursor/probe - Run a live authenticated runtime probe
|
||||
*/
|
||||
router.post('/probe', async (_req: Request, res: Response): Promise<void> => {
|
||||
router.post('/probe', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!isDashboardWebSocketOriginAllowed(req)) {
|
||||
res.status(403).json({ error: 'Cross-origin probe requests are not allowed.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cursorConfig = getCursorConfig();
|
||||
const result = await probeCursorRuntime(cursorConfig);
|
||||
|
||||
@@ -404,6 +404,9 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!normalizedBackend || normalizedModel.length === 0) {
|
||||
return acc;
|
||||
}
|
||||
if (!knownBackends.has(normalizedBackend)) {
|
||||
throw new Error(`Unsupported provider backend "${backendId}".`);
|
||||
}
|
||||
acc[normalizedBackend] = normalizedModel;
|
||||
return acc;
|
||||
},
|
||||
@@ -470,6 +473,10 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
res.json(await buildDashboardPayload());
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Unsupported provider backend')) {
|
||||
res.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
||||
import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names';
|
||||
import {
|
||||
createApiProfile,
|
||||
@@ -30,6 +31,9 @@ import { isAnthropicDirectProfile, updateSettingsFile, parseTarget } from './rou
|
||||
|
||||
const router = Router();
|
||||
|
||||
const LOCAL_RUNTIME_READINESS_LOCAL_ACCESS_ERROR =
|
||||
'Local runtime readiness requires localhost access when dashboard auth is disabled.';
|
||||
|
||||
function isDenylistError(message: string | undefined): boolean {
|
||||
return typeof message === 'string' && message.toLowerCase().includes('denylist');
|
||||
}
|
||||
@@ -87,7 +91,11 @@ router.get('/cliproxy-bridge/providers', (_req: Request, res: Response): void =>
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/local-runtime-readiness', async (_req: Request, res: Response): Promise<void> => {
|
||||
router.get('/local-runtime-readiness', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireLocalAccessWhenAuthDisabled(req, res, LOCAL_RUNTIME_READINESS_LOCAL_ACCESS_ERROR)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
res.json({ runtimes: await getLocalRuntimeReadiness() });
|
||||
} catch (error) {
|
||||
|
||||
@@ -22,7 +22,10 @@ import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils';
|
||||
import { removeCcsImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-hook-utils';
|
||||
import { resolveCliproxyBridgeMetadata } from '../../api/services';
|
||||
|
||||
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
||||
import {
|
||||
isLoopbackRemoteAddress,
|
||||
requireLocalAccessWhenAuthDisabled,
|
||||
} from '../middleware/auth-middleware';
|
||||
import type { Settings } from '../../types/config';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
|
||||
@@ -43,6 +46,7 @@ import { resolveImageAnalysisRuntimeStatus } from '../../utils/hooks';
|
||||
import {
|
||||
getCcsDir,
|
||||
getImageAnalysisConfig,
|
||||
isDashboardAuthEnabled,
|
||||
loadConfigSafe,
|
||||
loadOrCreateUnifiedConfig,
|
||||
loadSettings,
|
||||
@@ -155,6 +159,14 @@ function requireSensitiveLocalAccess(req: Request, res: Response): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function canResolveSensitiveRuntimeStatus(req: Request): boolean {
|
||||
if (isDashboardAuthEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isLoopbackRemoteAddress(req.socket.remoteAddress);
|
||||
}
|
||||
|
||||
function classifyConfigSaveFailure(error: unknown): { statusCode: number; message: string } {
|
||||
const message = error instanceof Error ? error.message.toLowerCase() : '';
|
||||
|
||||
@@ -498,17 +510,17 @@ router.get('/:profile', async (req: Request, res: Response): Promise<void> => {
|
||||
const stat = fs.statSync(settingsPath);
|
||||
const masked = maskApiKeys(settings);
|
||||
|
||||
const imageAnalysisStatus = canResolveSensitiveRuntimeStatus(req)
|
||||
? await resolveImageAnalysisStatusForProfile(profile, settings, settingsPath)
|
||||
: null;
|
||||
|
||||
res.json({
|
||||
profile,
|
||||
settings: masked,
|
||||
mtime: stat.mtime.getTime(),
|
||||
path: settingsPath,
|
||||
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
|
||||
imageAnalysisStatus: await resolveImageAnalysisStatusForProfile(
|
||||
profile,
|
||||
settings,
|
||||
settingsPath
|
||||
),
|
||||
imageAnalysisStatus,
|
||||
});
|
||||
} catch (error) {
|
||||
respondInternalError(res, error, 'Internal server error.');
|
||||
|
||||
@@ -231,8 +231,12 @@ function writeJsonDocument(
|
||||
const tempPath = `${filePath}.tmp.${uniqueFileNonce()}`;
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
||||
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2) + '\n', {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.renameSync(tempPath, filePath);
|
||||
fs.chmodSync(filePath, 0o600);
|
||||
} catch (error) {
|
||||
if (fs.existsSync(tempPath)) {
|
||||
fs.rmSync(tempPath, { force: true });
|
||||
|
||||
@@ -9,8 +9,13 @@ export interface ProfileScopedUsageData {
|
||||
|
||||
const PROFILE_NAME_REGEX = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
export function normalizeProfileQuery(profile?: string): string | undefined {
|
||||
const value = profile?.trim();
|
||||
export function normalizeProfileQuery(profile?: unknown): string | undefined {
|
||||
if (profile === undefined) return undefined;
|
||||
if (typeof profile !== 'string') {
|
||||
throw new Error('Invalid profile filter');
|
||||
}
|
||||
|
||||
const value = profile.trim();
|
||||
if (!value || value === 'all') return undefined;
|
||||
if (!PROFILE_NAME_REGEX.test(value)) {
|
||||
throw new Error('Invalid profile filter');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user