Initial segregate: proxy source, deploy manifests, ImagePolicy Flux CRs.
build-and-push / build (push) Failing after 2m45s
build-and-push / build (push) Failing after 2m45s
Move workload out of infra with cursor-devbox-style GitRepository + run_number-sha image automation.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
name: build-and-push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'Dockerfile'
|
||||
- 'entrypoint.sh'
|
||||
- 'src/**'
|
||||
- 'scripts/**'
|
||||
- 'cursor-bootstrap/**'
|
||||
- '.gitea/workflows/build-and-push.yaml'
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: dcr.trinitysan.by
|
||||
username: ${{ secrets.DCR_USERNAME }}
|
||||
password: ${{ secrets.DCR_PASSWORD }}
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
dcr.trinitysan.by/infra/cursor-agent-api-proxy:latest
|
||||
dcr.trinitysan.by/infra/cursor-agent-api-proxy:${{ github.sha }}
|
||||
dcr.trinitysan.by/infra/cursor-agent-api-proxy:${{ github.run_number }}-${{ github.sha }}
|
||||
cache-from: type=registry,ref=dcr.trinitysan.by/infra/cursor-agent-api-proxy:build-cache
|
||||
cache-to: type=registry,ref=dcr.trinitysan.by/infra/cursor-agent-api-proxy:build-cache,mode=max
|
||||
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
*.log
|
||||
node_modules/
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# cursor-agent-api-proxy — OpenAI-compatible API in front of cursor-agent CLI (ACP).
|
||||
#
|
||||
# Every request uses cursor-agent ACP (`agent acp`):
|
||||
# - session/new with in-process MCP server for client tools[]
|
||||
# - session/prompt with text + image ContentBlocks
|
||||
# - session/request_permission denies built-in tools, allows MCP client tools
|
||||
# - Tool invocations park until HTTP client returns role:"tool"
|
||||
#
|
||||
# Requires CURSOR_API_KEY (Cursor subscription) at runtime.
|
||||
|
||||
FROM ubuntu:noble
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Node 20 (LTS) — proxy and stdio MCP bridge both run on Node.
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
||||
apt-get install -y --no-install-recommends nodejs && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# cursor-agent CLI (subscription-backed).
|
||||
RUN curl -fsS https://cursor.com/install | bash && \
|
||||
CURSOR_VERSION_DIR="$(echo /root/.local/share/cursor-agent/versions/*)" && \
|
||||
cp -r "${CURSOR_VERSION_DIR}" /opt/cursor-agent && \
|
||||
ln -sf /opt/cursor-agent/cursor-agent /usr/local/bin/cursor-agent && \
|
||||
ln -sf /opt/cursor-agent/cursor-agent /usr/local/bin/agent.real
|
||||
|
||||
# Application code.
|
||||
COPY src/ /app/src/
|
||||
COPY scripts/ /app/scripts/
|
||||
COPY cursor-bootstrap/ /opt/cursor-bootstrap/
|
||||
COPY entrypoint.sh /app/entrypoint.sh
|
||||
|
||||
RUN mkdir -p /workspace /home/nobody/.cursor && \
|
||||
chmod +x /app/scripts/*.sh /app/entrypoint.sh && \
|
||||
chown -R nobody:nogroup /opt/cursor-agent /opt/cursor-bootstrap /app /workspace /home/nobody
|
||||
|
||||
WORKDIR /workspace
|
||||
ENV PORT=4646
|
||||
ENV HOME=/home/nobody
|
||||
ENV NODE_ENV=production
|
||||
USER nobody
|
||||
|
||||
EXPOSE 4646
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
@@ -0,0 +1,46 @@
|
||||
# cursor-agent-api-proxy
|
||||
|
||||
OpenAI-compatible HTTP API wrapping `cursor-agent` via **ACP** (`agent acp`). Deployed on **node4-k3s**.
|
||||
|
||||
- **Image**: `dcr.trinitysan.by/infra/cursor-agent-api-proxy`
|
||||
- **Deploy**: `deploy/` (PVC, Deployment, Service, ServiceExport)
|
||||
- **Flux image automation**: `flux/` (ImageRepository / ImagePolicy / ImageUpdateAutomation)
|
||||
|
||||
Infra on node4 keeps thin Flux glue (Namespace, SecretAdapters, `GitRepository`, Kustomizations) in `infrastructure/node4-k3s/131-cursor-agent-api.yaml`. Public HTTPS + Authentik live on node2 (`245-authentik-cursor-agent-api`).
|
||||
|
||||
## Access
|
||||
|
||||
| Endpoint | Detail |
|
||||
|----------|--------|
|
||||
| Public | `https://cursor-api.trinitysan.by` (Authentik outpost on node2) |
|
||||
| In-cluster / MCS | `cursor-agent-api.cursor-agent-api.svc.clusterset.local:4646` |
|
||||
| Health | `GET /health` |
|
||||
|
||||
## CI
|
||||
|
||||
Gitea Actions (`.gitea/workflows/build-and-push.yaml`) on `master` and `workflow_dispatch` pushes:
|
||||
|
||||
| Tag | Purpose |
|
||||
|-----|---------|
|
||||
| `:latest` | Convenience |
|
||||
| `:<git-sha>` | Immutable full SHA |
|
||||
| `:<run_number>-<git-sha>` | Sortable; elected by Flux ImagePolicy |
|
||||
|
||||
Org secrets: `DCR_USERNAME`, `DCR_PASSWORD` (infra org).
|
||||
|
||||
## Local build / E2E
|
||||
|
||||
```bash
|
||||
docker build -t cursor-agent-api-proxy:latest .
|
||||
# Requires CURSOR_API_KEY in env or .env
|
||||
./scripts/e2e-docker.sh
|
||||
```
|
||||
|
||||
## Flux update flow
|
||||
|
||||
1. CI pushes `run_number-sha` to DCR
|
||||
2. ImageRepository scans; ImagePolicy picks highest `run_number`
|
||||
3. ImageUpdateAutomation commits the new tag into `deploy/010-cursor-agent-api.yaml`
|
||||
4. Kustomization applies; Deployment rolls
|
||||
|
||||
Requires a Gitea PAT with **read+write** on this repo (`GITEA_CURSOR_AGENT_API_GITOPS_TOKEN` on node4, see infra `060-external-secrets.yaml.template`).
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"version": 1,
|
||||
"editor": { "vimMode": false },
|
||||
"permissions": {
|
||||
"allow": ["Mcp(*)"],
|
||||
"deny": ["Shell(*)", "Read(*)", "Write(*)", "Grep(*)", "Glob(*)", "WebFetch(*)"]
|
||||
},
|
||||
"model": {
|
||||
"modelId": "default",
|
||||
"displayModelId": "auto",
|
||||
"displayName": "Auto",
|
||||
"displayNameShort": "Auto",
|
||||
"aliases": ["auto"],
|
||||
"maxMode": false
|
||||
},
|
||||
"hasChangedDefaultModel": true,
|
||||
"approvalMode": "allowlist",
|
||||
"sandbox": {
|
||||
"mode": "disabled",
|
||||
"networkAccess": "user_config_with_defaults"
|
||||
},
|
||||
"attribution": {
|
||||
"attributeCommitsToAgent": false,
|
||||
"attributePRsToAgent": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
# Cursor Agent API workload (node4).
|
||||
# Namespace + SecretAdapters live in infra glue (131-cursor-agent-api.yaml).
|
||||
# Image tag updated by Flux ImageUpdateAutomation (run_number-sha tags).
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: cursor-agent-state
|
||||
namespace: cursor-agent-api
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: local-storage
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: cursor-agent-api
|
||||
namespace: cursor-agent-api
|
||||
labels:
|
||||
app: cursor-agent-api
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: cursor-agent-api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: cursor-agent-api
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: dcr-registry-auth-ro
|
||||
initContainers:
|
||||
- name: bootstrap-cursor
|
||||
image: dcr.trinitysan.by/infra/cursor-agent-api-proxy:latest # {"$imagepolicy": "flux-system:cursor-agent-api-proxy"}
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
- name: CURSOR_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: cursor-auth
|
||||
key: CURSOR_API_KEY
|
||||
command:
|
||||
- /app/scripts/bootstrap-cursor.sh
|
||||
volumeMounts:
|
||||
- name: cursor-state
|
||||
mountPath: /home/nobody/.cursor
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
readOnlyRootFilesystem: false
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
runAsGroup: 65534
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
runAsGroup: 65534
|
||||
fsGroup: 65534
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: proxy
|
||||
image: dcr.trinitysan.by/infra/cursor-agent-api-proxy:latest # {"$imagepolicy": "flux-system:cursor-agent-api-proxy"}
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 4646
|
||||
env:
|
||||
- name: CURSOR_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: cursor-auth
|
||||
key: CURSOR_API_KEY
|
||||
- name: PORT
|
||||
value: "4646"
|
||||
- name: PROXY_SESSION_TTL_MS
|
||||
value: "120000"
|
||||
- name: PROXY_MAX_CONCURRENT_SESSIONS
|
||||
value: "4"
|
||||
- name: ACP_REQUEST_TIMEOUT_MS
|
||||
value: "300000"
|
||||
- name: PROXY_INTERNAL_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: cursor-proxy-internal-token
|
||||
key: PROXY_INTERNAL_TOKEN
|
||||
volumeMounts:
|
||||
- name: cursor-state
|
||||
mountPath: /home/nobody/.cursor
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: 2Gi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
volumes:
|
||||
- name: cursor-state
|
||||
persistentVolumeClaim:
|
||||
claimName: cursor-agent-state
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: cursor-agent-api
|
||||
namespace: cursor-agent-api
|
||||
annotations:
|
||||
service.kubernetes.io/topology-mode: Auto
|
||||
spec:
|
||||
selector:
|
||||
app: cursor-agent-api
|
||||
ports:
|
||||
- name: http
|
||||
port: 4646
|
||||
targetPort: http
|
||||
|
||||
---
|
||||
apiVersion: multicluster.x-k8s.io/v1alpha1
|
||||
kind: ServiceExport
|
||||
metadata:
|
||||
name: cursor-agent-api
|
||||
namespace: cursor-agent-api
|
||||
@@ -0,0 +1,26 @@
|
||||
# cursor-agent bootstrap — Desired State
|
||||
|
||||
## Purpose
|
||||
|
||||
Prepare the **cursor-agent-api-proxy** container image with a working Cursor CLI login and persistent `~/.cursor` state before the proxy serves traffic.
|
||||
|
||||
## Mechanism
|
||||
|
||||
1. **Image** — Ubuntu + Node 20 + cursor-agent CLI (`agent.real`) + the OpenAI-compatible ACP proxy (`src/server.mjs`).
|
||||
2. **Bootstrap** — `bootstrap-cursor.sh` runs in an init container (and entrypoint) with `CURSOR_API_KEY` from SecretAdapter `cursor-auth` to seed `/home/nobody/.cursor` on the PVC.
|
||||
3. **Runtime** — Each `/v1/chat/completions` request spawns `agent acp` with in-process MCP for client tools and native image ContentBlocks.
|
||||
|
||||
The legacy GLM/stream-json wrapper path is **not** used in this image.
|
||||
|
||||
## Build-time
|
||||
|
||||
- Install cursor-agent CLI and proxy sources in the Docker image (no secrets at build time).
|
||||
|
||||
## Runtime
|
||||
|
||||
- Init container + PVC: `bootstrap-cursor.sh` with `CURSOR_API_KEY` from `cursor-auth`.
|
||||
- Proxy env: `PROXY_INTERNAL_TOKEN`, `PROXY_MAX_CONCURRENT_SESSIONS`, `PROXY_SESSION_TTL_MS`, `ACP_REQUEST_TIMEOUT_MS`.
|
||||
|
||||
## Related
|
||||
|
||||
- [131-cursor-agent-api-goal.md](../../infrastructure/node4-k3s/131-cursor-agent-api-goal.md)
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
# Container entrypoint: run cursor-agent bootstrap, then start the proxy.
|
||||
set -euo pipefail
|
||||
|
||||
# Seed ~/.cursor/cli-config.json (default global permissions) and verify
|
||||
# the cursor-agent binary responds.
|
||||
/app/scripts/bootstrap-cursor.sh
|
||||
|
||||
# The HTTP server. Sessions spawn cursor-agent ACP subprocesses per request.
|
||||
exec node /app/src/server.mjs
|
||||
@@ -0,0 +1,59 @@
|
||||
# Flux image automation for cursor-agent-api-proxy (applied into flux-system on node4).
|
||||
# GitRepository cursor-agent-api-proxy is defined in infra glue (131-cursor-agent-api.yaml).
|
||||
# CI tags: latest, <sha>, <run_number>-<sha> — policy elects the last form.
|
||||
|
||||
---
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: cursor-agent-api-proxy
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: dcr.trinitysan.by/infra/cursor-agent-api-proxy
|
||||
interval: 1m0s
|
||||
secretRef:
|
||||
name: dcr-registry-auth-ro
|
||||
|
||||
---
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: cursor-agent-api-proxy
|
||||
namespace: flux-system
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: cursor-agent-api-proxy
|
||||
filterTags:
|
||||
pattern: '^(?P<n>[0-9]+)-[0-9a-f]{40}$'
|
||||
extract: '$n'
|
||||
policy:
|
||||
numerical:
|
||||
order: desc
|
||||
|
||||
---
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageUpdateAutomation
|
||||
metadata:
|
||||
name: cursor-agent-api-proxy
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 5m0s
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: cursor-agent-api-proxy
|
||||
git:
|
||||
checkout:
|
||||
ref:
|
||||
branch: master
|
||||
commit:
|
||||
author:
|
||||
email: fluxcdbot@users.noreply.gitea.trinitysan.by
|
||||
name: fluxcdbot
|
||||
messageTemplate: |
|
||||
chore(deploy): update cursor-agent-api-proxy image
|
||||
{{range .Changed.Changes}}{{print .OldValue}} -> {{println .NewValue}}{{end}}
|
||||
push:
|
||||
branch: master
|
||||
update:
|
||||
path: ./deploy
|
||||
strategy: Setters
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# Bootstrap: seed ~/.cursor/cli-config.json and verify the cursor-agent
|
||||
# binary works before the HTTP server starts serving traffic.
|
||||
set -euo pipefail
|
||||
|
||||
CURSOR_HOME="${HOME}/.cursor"
|
||||
BOOTSTRAP="/opt/cursor-bootstrap"
|
||||
export NO_OPEN_BROWSER=1
|
||||
|
||||
mkdir -p "${CURSOR_HOME}"
|
||||
|
||||
if [[ -f "${BOOTSTRAP}/cli-config.json" ]] && [[ ! -f "${CURSOR_HOME}/cli-config.json" ]]; then
|
||||
cp -f "${BOOTSTRAP}/cli-config.json" "${CURSOR_HOME}/cli-config.json"
|
||||
fi
|
||||
|
||||
# cursor-agent auth via env (no interactive login).
|
||||
if [[ -z "${CURSOR_API_KEY:-}" ]]; then
|
||||
echo "CURSOR_API_KEY is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
export CURSOR_API_KEY
|
||||
|
||||
# cursor-agent itself must respond.
|
||||
if ! /usr/local/bin/agent.real --version >/dev/null 2>&1; then
|
||||
echo "cursor-agent binary is not executable" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "cursor-agent bootstrap: auth ok, agent responds"
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# Local E2E: build image, run container with .env secrets, smoke-test text + image.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
IMAGE="${E2E_IMAGE:-cursor-agent-api-proxy:latest}"
|
||||
PORT="${E2E_PORT:-18766}"
|
||||
CONTAINER="${E2E_CONTAINER:-cursor-agent-api-e2e}"
|
||||
TIMEOUT="${E2E_TIMEOUT:-180}"
|
||||
|
||||
ENV_FILE="${REPO_ROOT}/.env"
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "Error: .env not found at $ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "==> Building $IMAGE"
|
||||
docker build -t "$IMAGE" "$ROOT"
|
||||
|
||||
cleanup
|
||||
echo "==> Starting container (host network, PORT=$PORT)"
|
||||
docker run -d --name "$CONTAINER" --network host --env-file "$ENV_FILE" -e "PORT=$PORT" "$IMAGE" >/dev/null
|
||||
|
||||
echo "==> Waiting for /health"
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
echo "Health check failed" >&2
|
||||
docker logs "$CONTAINER" 2>&1 | tail -30
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "==> Plain text chat"
|
||||
AUTH_ARGS=()
|
||||
if [ -n "${PROXY_INTERNAL_TOKEN:-}" ]; then
|
||||
AUTH_ARGS=(-H "Authorization: Bearer ${PROXY_INTERNAL_TOKEN}")
|
||||
fi
|
||||
TEXT_RESP="$(curl -sf --max-time "$TIMEOUT" "http://127.0.0.1:${PORT}/v1/chat/completions" \
|
||||
"${AUTH_ARGS[@]}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"auto","stream":false,"messages":[{"role":"user","content":"Reply with exactly: HELLO"}]}')"
|
||||
echo "$TEXT_RESP" | grep -q '"content":"HELLO"' || {
|
||||
echo "Plain chat failed:" >&2
|
||||
echo "$TEXT_RESP" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "OK: plain chat"
|
||||
|
||||
PNG_B64="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
echo "==> Image chat (data: PNG)"
|
||||
IMG_RESP="$(curl -sf --max-time "$TIMEOUT" "http://127.0.0.1:${PORT}/v1/chat/completions" \
|
||||
"${AUTH_ARGS[@]}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"model\":\"auto\",\"stream\":false,\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What color is this 1x1 pixel? Reply with one color word only.\"},{\"type\":\"image_url\",\"image_url\":{\"url\":\"data:image/png;base64,${PNG_B64}\"}}]}]}")"
|
||||
echo "$IMG_RESP" | grep -q '"finish_reason":"stop"' || {
|
||||
echo "Image chat failed:" >&2
|
||||
echo "$IMG_RESP" >&2
|
||||
docker logs "$CONTAINER" 2>&1 | tail -40
|
||||
exit 1
|
||||
}
|
||||
CONTENT="$(echo "$IMG_RESP" | sed -n 's/.*"content":"\([^"]*\)".*/\1/p' | head -1)"
|
||||
if [ -z "$CONTENT" ]; then
|
||||
echo "Image chat returned empty content:" >&2
|
||||
echo "$IMG_RESP" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: image chat → $CONTENT"
|
||||
|
||||
echo "==> All E2E checks passed"
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/sh
|
||||
# Bridge: cursor-agent spawns this as a stdio MCP server. We connect to a
|
||||
# per-session unix socket provided as $1 and tunnel newline-delimited
|
||||
# JSON-RPC frames between cursor-agent's stdin/stdout and the proxy-owned
|
||||
# socket server (which is the real MCP server).
|
||||
#
|
||||
# Uses Node (always available in the image) rather than nc, to avoid
|
||||
# depending on netcat variants that may not support -U for unix sockets.
|
||||
socket="$1"
|
||||
if [ -z "$socket" ]; then
|
||||
echo "usage: $0 <socket-path>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec node -e '
|
||||
const sock = process.argv[1];
|
||||
if (!sock) { console.error("mcp-stdio-bridge: missing socket arg"); process.exit(1); }
|
||||
const net = require("node:net");
|
||||
const conn = net.createConnection(sock);
|
||||
let buf = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => {
|
||||
// Forward as-is; JSON-RPC frames are newline-delimited on both sides.
|
||||
conn.write(chunk);
|
||||
});
|
||||
process.stdin.on("end", () => conn.end());
|
||||
conn.setEncoding("utf8");
|
||||
conn.on("data", (chunk) => process.stdout.write(chunk));
|
||||
conn.on("end", () => process.exit(0));
|
||||
conn.on("close", () => process.exit(0));
|
||||
conn.on("error", (e) => {
|
||||
console.error("[mcp-stdio-bridge] socket error: " + e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
process.on("SIGPIPE", () => process.exit(0));
|
||||
' "$socket"
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Minimal ACP (Agent Client Protocol) JSON-RPC client over stdio.
|
||||
* Newline-delimited JSON-RPC 2.0 per ACP transport spec.
|
||||
*/
|
||||
|
||||
export class AcpClient {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {import("node:stream").Writable} opts.stdin
|
||||
* @param {import("node:stream").Readable} opts.stdout
|
||||
* @param {(msg: string) => void} [opts.log]
|
||||
*/
|
||||
constructor({ stdin, stdout, log, requestTimeoutMs }) {
|
||||
this.stdin = stdin;
|
||||
this.stdout = stdout;
|
||||
this.log = log || (() => {});
|
||||
this.requestTimeoutMs =
|
||||
requestTimeoutMs ?? parseInt(process.env.ACP_REQUEST_TIMEOUT_MS || "300000", 10);
|
||||
this._buf = "";
|
||||
this._nextId = 1;
|
||||
/** @type {Map<number, {resolve: Function, reject: Function}>} */
|
||||
this._pending = new Map();
|
||||
/** @type {(update: object) => void} */
|
||||
this.onSessionUpdate = null;
|
||||
/**
|
||||
* Handle incoming JSON-RPC requests from the agent (permissions, cursor extensions).
|
||||
* @type {(method: string, params: object) => Promise<object>}
|
||||
*/
|
||||
this.onIncomingRequest = null;
|
||||
this._closed = false;
|
||||
this.agentCapabilities = null;
|
||||
|
||||
stdout.setEncoding("utf8");
|
||||
stdout.on("data", (chunk) => this._onData(chunk));
|
||||
}
|
||||
|
||||
_send(obj) {
|
||||
if (this._closed || this.stdin.destroyed) return;
|
||||
this.stdin.write(`${JSON.stringify(obj)}\n`);
|
||||
}
|
||||
|
||||
_onData(chunk) {
|
||||
this._buf += chunk;
|
||||
let nl;
|
||||
while ((nl = this._buf.indexOf("\n")) >= 0) {
|
||||
const line = this._buf.slice(0, nl).trim();
|
||||
this._buf = this._buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(line);
|
||||
} catch (e) {
|
||||
this.log(`[acp] parse error: ${e.message}`);
|
||||
continue;
|
||||
}
|
||||
this._dispatch(msg).catch((e) => this.log(`[acp] dispatch error: ${e.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
async _dispatch(msg) {
|
||||
if (msg.method === "session/update") {
|
||||
this.onSessionUpdate?.(msg.params || {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.method && msg.id != null && !Object.prototype.hasOwnProperty.call(msg, "result") && !msg.error) {
|
||||
const handler = this.onIncomingRequest;
|
||||
if (!handler) {
|
||||
this._send({
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id,
|
||||
error: { code: -32603, message: "no incoming request handler" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await handler(msg.method, msg.params || {});
|
||||
this._send({ jsonrpc: "2.0", id: msg.id, result: result ?? {} });
|
||||
} catch (e) {
|
||||
this._send({
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id,
|
||||
error: { code: -32000, message: e.message || "handler failed" },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.id != null && (msg.result !== undefined || msg.error !== undefined)) {
|
||||
const p = this._pending.get(msg.id);
|
||||
if (!p) return;
|
||||
this._pending.delete(msg.id);
|
||||
if (msg.error) {
|
||||
const err = new Error(msg.error.message || "ACP error");
|
||||
err.code = msg.error.code;
|
||||
err.data = msg.error.data;
|
||||
p.reject(err);
|
||||
} else {
|
||||
p.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} method
|
||||
* @param {object} [params]
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
request(method, params = {}) {
|
||||
const id = this._nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (!this._pending.has(id)) return;
|
||||
this._pending.delete(id);
|
||||
reject(new Error(`ACP request timeout after ${this.requestTimeoutMs}ms: ${method}`));
|
||||
}, this.requestTimeoutMs);
|
||||
|
||||
this._pending.set(id, {
|
||||
resolve: (value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
reject: (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
this._send({ jsonrpc: "2.0", id, method, params });
|
||||
});
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
const result = await this.request("initialize", {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
fs: { readTextFile: false, writeTextFile: false },
|
||||
terminal: false,
|
||||
},
|
||||
clientInfo: { name: "cursor-agent-api-proxy", version: "1.6.0" },
|
||||
});
|
||||
this.agentCapabilities = result.agentCapabilities || null;
|
||||
if (!result.agentCapabilities?.promptCapabilities?.image) {
|
||||
throw new Error("cursor-agent ACP does not advertise image prompt capability");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async authenticateIfNeeded() {
|
||||
try {
|
||||
return await this.request("authenticate", { methodId: "cursor_login" });
|
||||
} catch (e) {
|
||||
this.log(`[acp] authenticate skipped or failed: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this._closed = true;
|
||||
for (const [, p] of this._pending) {
|
||||
p.reject(new Error("ACP client closed"));
|
||||
}
|
||||
this._pending.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { AcpClient } from "./acp-client.mjs";
|
||||
|
||||
describe("AcpClient", () => {
|
||||
it("resolves request/response pairs", async () => {
|
||||
const stdin = new PassThrough();
|
||||
const stdout = new PassThrough();
|
||||
const client = new AcpClient({ stdin, stdout });
|
||||
|
||||
const p = client.request("ping", { ok: true });
|
||||
const written = await new Promise((r) => {
|
||||
stdin.once("data", (c) => r(JSON.parse(c.toString())));
|
||||
});
|
||||
assert.equal(written.method, "ping");
|
||||
assert.equal(written.id, 1);
|
||||
|
||||
stdout.write(
|
||||
`${JSON.stringify({ jsonrpc: "2.0", id: written.id, result: { pong: true } })}\n`
|
||||
);
|
||||
const result = await p;
|
||||
assert.deepEqual(result, { pong: true });
|
||||
client.close();
|
||||
});
|
||||
|
||||
it("dispatches session/update notifications", async () => {
|
||||
const stdin = new PassThrough();
|
||||
const stdout = new PassThrough();
|
||||
const client = new AcpClient({ stdin, stdout });
|
||||
const updates = [];
|
||||
client.onSessionUpdate = (u) => updates.push(u);
|
||||
|
||||
stdout.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: { sessionId: "s1", update: { sessionUpdate: "agent_message_chunk" } },
|
||||
})}\n`
|
||||
);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
assert.equal(updates.length, 1);
|
||||
assert.equal(updates[0].sessionId, "s1");
|
||||
client.close();
|
||||
});
|
||||
|
||||
it("rejects requests that exceed timeout", async () => {
|
||||
const stdin = new PassThrough();
|
||||
const stdout = new PassThrough();
|
||||
const client = new AcpClient({ stdin, stdout, requestTimeoutMs: 30 });
|
||||
|
||||
const p = client.request("slow", {});
|
||||
await assert.rejects(p, /timeout.*slow/i);
|
||||
client.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* In-process MCP server speaking JSON-RPC 2.0 (newline-delimited) over a
|
||||
* unix socket. cursor-agent connects to this socket via a tiny stdio bridge
|
||||
* script (scripts/mcp-stdio-bridge.sh) and treats it as a regular stdio MCP
|
||||
* server.
|
||||
*
|
||||
* The server ADVERTISES the client's tools (so the LLM sees them and can
|
||||
* decide to call them) but does NOT execute them. When `tools/call` arrives,
|
||||
* it generates an OpenAI-style `tool_call_id`, parks the response as a
|
||||
* pending Promise, and emits a `tool_call` event on the parent session.
|
||||
* The proxy resolves the Promise later when the HTTP client sends back a
|
||||
* `role: "tool"` message with the result.
|
||||
*/
|
||||
|
||||
import { createServer } from "node:net";
|
||||
|
||||
/**
|
||||
* @typedef {Object} McpTool
|
||||
* @property {string} name
|
||||
* @property {string} [description]
|
||||
* @property {object} [inputSchema] JSON Schema for parameters
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PendingToolCall
|
||||
* @property {string} toolCallId
|
||||
* @property {string} name
|
||||
* @property {object} arguments
|
||||
* @property {(result: string) => void} resolve
|
||||
* @property {(err: Error) => void} reject
|
||||
*/
|
||||
|
||||
export class ProxyMcpServer {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.socketPath
|
||||
* @param {McpTool[]} opts.tools
|
||||
* @param {(call: {toolCallId: string, name: string, arguments: object}) => void} opts.onToolCall
|
||||
* @param {(msg: string) => void} [opts.log]
|
||||
*/
|
||||
constructor({ socketPath, tools, onToolCall, log }) {
|
||||
this.socketPath = socketPath;
|
||||
this.tools = tools;
|
||||
this.onToolCall = onToolCall || (() => {});
|
||||
this.log = log || (() => {});
|
||||
/** @type {import("node:net").Server} */
|
||||
this.server = null;
|
||||
/** Connection that cursor-agent is currently using. */
|
||||
this.conn = null;
|
||||
/** @type {Map<string, PendingToolCall>} key = toolCallId */
|
||||
this.pending = new Map();
|
||||
}
|
||||
|
||||
/** Start listening on the unix socket. */
|
||||
async start() {
|
||||
this.server = createServer((conn) => this._handleConn(conn));
|
||||
await new Promise((resolve, reject) => {
|
||||
this.server.once("error", reject);
|
||||
this.server.listen(this.socketPath, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
async stop() {
|
||||
// Reject any pending tool calls so callers don't hang.
|
||||
for (const [, p] of this.pending) {
|
||||
p.reject(new Error("mcp server stopped"));
|
||||
}
|
||||
this.pending.clear();
|
||||
if (this.conn) {
|
||||
try {
|
||||
this.conn.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (this.server) {
|
||||
await new Promise((r) => this.server.close(r));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a parked tool call with a result string. Returns true if a
|
||||
* matching pending call was found.
|
||||
*/
|
||||
resolveToolCall(toolCallId, result) {
|
||||
const p = this.pending.get(toolCallId);
|
||||
if (!p) return false;
|
||||
this.pending.delete(toolCallId);
|
||||
p.resolve(String(result ?? ""));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Reject a parked tool call (e.g. on session teardown). */
|
||||
rejectToolCall(toolCallId, err) {
|
||||
const p = this.pending.get(toolCallId);
|
||||
if (!p) return false;
|
||||
this.pending.delete(toolCallId);
|
||||
p.reject(err);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
_handleConn(conn) {
|
||||
// cursor-agent spawns one stdio bridge per session; only one connection
|
||||
// is expected. If a new one arrives, drop the old.
|
||||
if (this.conn && this.conn.writable) {
|
||||
this.log("mcp: new connection replacing existing");
|
||||
try {
|
||||
this.conn.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
this.conn = conn;
|
||||
conn.setEncoding("utf8");
|
||||
let buf = "";
|
||||
conn.on("data", (chunk) => {
|
||||
buf += chunk;
|
||||
let nl;
|
||||
while ((nl = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, nl);
|
||||
buf = buf.slice(nl + 1);
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(trimmed);
|
||||
} catch (e) {
|
||||
this.log(`mcp: parse error: ${e.message}`);
|
||||
continue;
|
||||
}
|
||||
this._handleMessage(msg, conn).catch((e) => this.log(`mcp: handler error: ${e.message}`));
|
||||
}
|
||||
});
|
||||
conn.on("error", (e) => this.log(`mcp: conn error: ${e.message}`));
|
||||
conn.on("close", () => {
|
||||
if (this.conn === conn) this.conn = null;
|
||||
});
|
||||
}
|
||||
|
||||
_send(conn, obj) {
|
||||
if (conn.writableEnded) return;
|
||||
conn.write(JSON.stringify(obj) + "\n");
|
||||
}
|
||||
|
||||
async _handleMessage(msg, conn) {
|
||||
const { id, method, params } = msg;
|
||||
|
||||
if (method === "initialize") {
|
||||
this._send(conn, {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
result: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: "proxy-client-tools", version: "1.0.0" },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "notifications/initialized") {
|
||||
// No response required for notifications.
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "tools/list") {
|
||||
const tools = this.tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description || `Client-side tool ${t.name}`,
|
||||
inputSchema: t.inputSchema || { type: "object", properties: {} },
|
||||
}));
|
||||
this._send(conn, { jsonrpc: "2.0", id, result: { tools } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "tools/call") {
|
||||
this._handleToolsCall(id, params || {}, conn);
|
||||
return;
|
||||
}
|
||||
|
||||
this._send(conn, {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
error: { code: -32601, message: `unknown method: ${method}` },
|
||||
});
|
||||
}
|
||||
|
||||
_handleToolsCall(id, params, conn) {
|
||||
const { name, arguments: args } = params;
|
||||
const toolCallId =
|
||||
params.toolCallId ||
|
||||
`call_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
/** @type {PendingToolCall} */
|
||||
const pending = {
|
||||
toolCallId,
|
||||
name,
|
||||
arguments: args || {},
|
||||
resolve: (result) => {
|
||||
this._send(conn, {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
result: { content: [{ type: "text", text: String(result ?? "") }] },
|
||||
});
|
||||
},
|
||||
reject: (err) => {
|
||||
this._send(conn, {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
error: { code: -32000, message: err.message || "tool call failed" },
|
||||
});
|
||||
},
|
||||
};
|
||||
this.pending.set(toolCallId, pending);
|
||||
|
||||
// Notify session so it can stream the OpenAI tool_calls chunk to the
|
||||
// HTTP client. The session stays alive while we hold this Promise.
|
||||
this.onToolCall({ toolCallId, name, arguments: args || {} });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Convert OpenAI chat messages to ACP ContentBlock arrays.
|
||||
*/
|
||||
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { isIP } from "node:net";
|
||||
|
||||
export const MAX_IMAGES_PER_REQUEST = 5;
|
||||
export const MAX_IMAGE_BYTES = 15 * 1024 * 1024;
|
||||
const ALLOWED_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
||||
|
||||
const PRIVATE_IP_PATTERNS = [
|
||||
/^127\./,
|
||||
/^10\./,
|
||||
/^192\.168\./,
|
||||
/^169\.254\./,
|
||||
/^0\./,
|
||||
/^::1$/,
|
||||
/^fc00:/i,
|
||||
/^fd00:/i,
|
||||
/^fe80:/i,
|
||||
];
|
||||
|
||||
export function messageContentToText(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text || "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function hasImageParts(messages) {
|
||||
for (const m of messages || []) {
|
||||
if (!Array.isArray(m.content)) continue;
|
||||
if (m.content.some((p) => p.type === "image_url")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function countImages(messages) {
|
||||
let n = 0;
|
||||
for (const m of messages || []) {
|
||||
if (!Array.isArray(m.content)) continue;
|
||||
n += m.content.filter((p) => p.type === "image_url").length;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function parseDataUrl(url) {
|
||||
const m = url.match(/^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i);
|
||||
if (!m) throw new Error("invalid data:image URL (expected base64 image)");
|
||||
const mimeType = m[1].toLowerCase();
|
||||
if (!ALLOWED_MIMES.has(mimeType)) {
|
||||
throw new Error(`unsupported image MIME type: ${mimeType}`);
|
||||
}
|
||||
const data = m[2];
|
||||
const buf = Buffer.from(data, "base64");
|
||||
if (buf.length > MAX_IMAGE_BYTES) {
|
||||
throw new Error(`image exceeds ${MAX_IMAGE_BYTES} bytes`);
|
||||
}
|
||||
return { mimeType, data: buf.toString("base64") };
|
||||
}
|
||||
|
||||
/** Normalize IPv4-mapped IPv6 (::ffff:x.x.x.x) to dotted IPv4 for range checks. */
|
||||
export function normalizeIpAddress(ip) {
|
||||
const mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
||||
if (mapped) return mapped[1];
|
||||
return ip;
|
||||
}
|
||||
|
||||
export function isPrivateIp(ip) {
|
||||
const normalized = normalizeIpAddress(ip);
|
||||
if (isIP(normalized) === 4) {
|
||||
if (normalized.startsWith("172.")) {
|
||||
const second = parseInt(normalized.split(".")[1], 10);
|
||||
if (second >= 16 && second <= 31) return true;
|
||||
}
|
||||
return PRIVATE_IP_PATTERNS.some((re) => re.test(normalized));
|
||||
}
|
||||
if (isIP(normalized) === 6) {
|
||||
return PRIVATE_IP_PATTERNS.some((re) => re.test(normalized));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function assertPublicHost(hostname) {
|
||||
if (hostname === "localhost" || hostname.endsWith(".local")) {
|
||||
throw new Error("image URL host not allowed");
|
||||
}
|
||||
const records = await lookup(hostname, { all: true });
|
||||
for (const r of records) {
|
||||
if (isPrivateIp(r.address)) {
|
||||
throw new Error("image URL resolves to private IP address");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHttpsImage(url) {
|
||||
let current = url;
|
||||
for (let redirects = 0; redirects <= 3; redirects++) {
|
||||
const parsed = new URL(current);
|
||||
if (parsed.protocol !== "https:") {
|
||||
throw new Error("image URL must use https");
|
||||
}
|
||||
await assertPublicHost(parsed.hostname);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 10000);
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(current, { signal: controller.signal, redirect: "manual" });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
const loc = res.headers.get("location");
|
||||
if (!loc) throw new Error("image URL redirect missing Location header");
|
||||
current = new URL(loc, current).toString();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`failed to fetch image URL: HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const contentType = (res.headers.get("content-type") || "image/jpeg").split(";")[0].trim().toLowerCase();
|
||||
if (!ALLOWED_MIMES.has(contentType)) {
|
||||
throw new Error(`unsupported image MIME type from URL: ${contentType}`);
|
||||
}
|
||||
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
if (buf.length > MAX_IMAGE_BYTES) {
|
||||
throw new Error(`image exceeds ${MAX_IMAGE_BYTES} bytes`);
|
||||
}
|
||||
return { mimeType: contentType, data: buf.toString("base64") };
|
||||
}
|
||||
throw new Error("too many redirects fetching image URL");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {Promise<{mimeType: string, data: string}>}
|
||||
*/
|
||||
export async function parseImageUrl(url) {
|
||||
if (typeof url !== "string" || !url) {
|
||||
throw new Error("image_url.url is required");
|
||||
}
|
||||
if (url.startsWith("data:")) {
|
||||
return parseDataUrl(url);
|
||||
}
|
||||
if (url.startsWith("https://")) {
|
||||
return fetchHttpsImage(url);
|
||||
}
|
||||
throw new Error("image_url must be a data: URL or https:// URL");
|
||||
}
|
||||
|
||||
function roleLabel(role) {
|
||||
if (role === "system") return "System";
|
||||
if (role === "user") return "User";
|
||||
if (role === "assistant") return "Assistant";
|
||||
return role;
|
||||
}
|
||||
|
||||
function formatMessageText(m) {
|
||||
const text = messageContentToText(m.content);
|
||||
let body = text;
|
||||
if (Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
|
||||
const calls = m.tool_calls
|
||||
.map((c) => ` - ${c.function?.name}(${c.function?.arguments || "{}"}) [id=${c.id}]`)
|
||||
.join("\n");
|
||||
body = `${text}\n[Tool calls issued:]\n${calls}`;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<object>} messages OpenAI messages
|
||||
* @returns {Promise<Array<object>>} ACP ContentBlock[]
|
||||
*/
|
||||
export async function openAiMessagesToAcpBlocks(messages) {
|
||||
const imageCount = countImages(messages);
|
||||
if (imageCount > MAX_IMAGES_PER_REQUEST) {
|
||||
throw new Error(`too many images (max ${MAX_IMAGES_PER_REQUEST})`);
|
||||
}
|
||||
|
||||
const filtered = (messages || []).filter((m) => m.role !== "tool");
|
||||
const nonEmpty = filtered.filter((m) => {
|
||||
if (typeof m.content === "string") return m.content.length > 0;
|
||||
if (Array.isArray(m.content)) {
|
||||
return m.content.some((p) => p.type === "text" && p.text) || m.content.some((p) => p.type === "image_url");
|
||||
}
|
||||
if (m.tool_calls) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
/** @type {Array<object>} */
|
||||
const blocks = [];
|
||||
|
||||
for (const m of nonEmpty) {
|
||||
const label = roleLabel(m.role);
|
||||
const body = formatMessageText(m);
|
||||
if (body) {
|
||||
blocks.push({ type: "text", text: `[${label}]\n${body}` });
|
||||
} else if (Array.isArray(m.content) && m.content.some((p) => p.type === "image_url")) {
|
||||
blocks.push({ type: "text", text: `[${label}]` });
|
||||
}
|
||||
|
||||
if (Array.isArray(m.content)) {
|
||||
for (const part of m.content) {
|
||||
if (part.type !== "image_url") continue;
|
||||
const url = part.image_url?.url;
|
||||
const { mimeType, data } = await parseImageUrl(url);
|
||||
blocks.push({ type: "image", mimeType, data });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (blocks.length === 0) {
|
||||
throw new Error("no content blocks produced from messages");
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
messageContentToText,
|
||||
hasImageParts,
|
||||
parseImageUrl,
|
||||
openAiMessagesToAcpBlocks,
|
||||
MAX_IMAGES_PER_REQUEST,
|
||||
} from "./message-converter.mjs";
|
||||
import { pickModelValueId, resolveModelId } from "./session-manager.mjs";
|
||||
|
||||
const PNG_B64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
||||
|
||||
describe("messageContentToText", () => {
|
||||
it("joins text parts", () => {
|
||||
assert.equal(
|
||||
messageContentToText([{ type: "text", text: "a" }, { type: "text", text: "b" }]),
|
||||
"ab"
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores image_url parts", () => {
|
||||
assert.equal(
|
||||
messageContentToText([
|
||||
{ type: "text", text: "hi" },
|
||||
{ type: "image_url", image_url: { url: "data:image/png;base64,abc" } },
|
||||
]),
|
||||
"hi"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasImageParts", () => {
|
||||
it("detects image_url", () => {
|
||||
assert.equal(
|
||||
hasImageParts([{ role: "user", content: [{ type: "image_url", image_url: { url: "x" } }] }]),
|
||||
true
|
||||
);
|
||||
assert.equal(hasImageParts([{ role: "user", content: "text only" }]), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseImageUrl", () => {
|
||||
it("parses data URL", async () => {
|
||||
const r = await parseImageUrl(`data:image/png;base64,${PNG_B64}`);
|
||||
assert.equal(r.mimeType, "image/png");
|
||||
assert.equal(r.data, PNG_B64);
|
||||
});
|
||||
|
||||
it("rejects http URL", async () => {
|
||||
await assert.rejects(() => parseImageUrl("http://example.com/x.png"), /https/);
|
||||
});
|
||||
|
||||
it("rejects private IP URL", async () => {
|
||||
await assert.rejects(
|
||||
() => parseImageUrl("https://127.0.0.1/x.png"),
|
||||
/private|not allowed/i
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPrivateIp", () => {
|
||||
it("treats IPv4-mapped loopback as private", async () => {
|
||||
const { isPrivateIp } = await import("./message-converter.mjs");
|
||||
assert.equal(isPrivateIp("::ffff:127.0.0.1"), true);
|
||||
assert.equal(isPrivateIp("::ffff:10.0.0.1"), true);
|
||||
assert.equal(isPrivateIp("8.8.8.8"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openAiMessagesToAcpBlocks", () => {
|
||||
it("produces labeled text blocks", async () => {
|
||||
const blocks = await openAiMessagesToAcpBlocks([{ role: "user", content: "Hello" }]);
|
||||
assert.equal(blocks.length, 1);
|
||||
assert.equal(blocks[0].type, "text");
|
||||
assert.match(blocks[0].text, /\[User\]/);
|
||||
assert.match(blocks[0].text, /Hello/);
|
||||
});
|
||||
|
||||
it("adds image block after message text", async () => {
|
||||
const blocks = await openAiMessagesToAcpBlocks([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "What color?" },
|
||||
{ type: "image_url", image_url: { url: `data:image/png;base64,${PNG_B64}` } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.equal(blocks.length, 2);
|
||||
assert.equal(blocks[0].type, "text");
|
||||
assert.equal(blocks[1].type, "image");
|
||||
assert.equal(blocks[1].mimeType, "image/png");
|
||||
});
|
||||
|
||||
it("rejects too many images", async () => {
|
||||
const parts = Array.from({ length: MAX_IMAGES_PER_REQUEST + 1 }, () => ({
|
||||
type: "image_url",
|
||||
image_url: { url: `data:image/png;base64,${PNG_B64}` },
|
||||
}));
|
||||
await assert.rejects(
|
||||
() => openAiMessagesToAcpBlocks([{ role: "user", content: parts }]),
|
||||
/too many images/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickModelValueId", () => {
|
||||
const modelOption = {
|
||||
id: "model",
|
||||
currentValue: "default[]",
|
||||
options: [
|
||||
{ value: "default[]", name: "Auto" },
|
||||
{ value: "gpt-5.2", name: "GPT-5.2" },
|
||||
{ value: "composer-2.5[fast=true]", name: "composer-2.5" },
|
||||
],
|
||||
};
|
||||
|
||||
it("maps auto to default[]", () => {
|
||||
assert.equal(pickModelValueId(modelOption, "auto"), "default[]");
|
||||
});
|
||||
|
||||
it("maps exact model id", () => {
|
||||
assert.equal(pickModelValueId(modelOption, "gpt-5.2"), "gpt-5.2");
|
||||
});
|
||||
|
||||
it("maps by prefix bracket", () => {
|
||||
assert.equal(pickModelValueId(modelOption, "composer-2.5"), "composer-2.5[fast=true]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModelId", () => {
|
||||
const sessionWithModels = {
|
||||
models: {
|
||||
currentModelId: "default[]",
|
||||
availableModels: [
|
||||
{ modelId: "default[]", name: "Auto" },
|
||||
{ modelId: "gpt-5.2", name: "GPT-5.2" },
|
||||
{ modelId: "composer-2.5[fast=true]", name: "composer-2.5" },
|
||||
],
|
||||
},
|
||||
configOptions: [],
|
||||
};
|
||||
|
||||
it("maps auto via availableModels", () => {
|
||||
assert.equal(resolveModelId(sessionWithModels, "auto"), "default[]");
|
||||
});
|
||||
|
||||
it("maps exact modelId in availableModels", () => {
|
||||
assert.equal(resolveModelId(sessionWithModels, "gpt-5.2"), "gpt-5.2");
|
||||
});
|
||||
|
||||
it("maps by name in availableModels", () => {
|
||||
assert.equal(resolveModelId(sessionWithModels, "composer-2.5"), "composer-2.5[fast=true]");
|
||||
});
|
||||
|
||||
it("falls back to configOptions when availableModels empty", () => {
|
||||
const sessionResult = {
|
||||
models: { currentModelId: null, availableModels: [] },
|
||||
configOptions: [
|
||||
{
|
||||
id: "model",
|
||||
currentValue: "default[]",
|
||||
options: [{ value: "default[]", name: "Auto" }, { value: "gpt-5.2", name: "GPT-5.2" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
assert.equal(resolveModelId(sessionResult, "gpt-5.2"), "gpt-5.2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Model registry. The proxy exposes all Cursor-subscription models, served
|
||||
* via the cursor-agent CLI. There is no separate provider path.
|
||||
*
|
||||
* The list is served on GET /v1/models.
|
||||
*/
|
||||
|
||||
export const CURSOR_MODELS = [
|
||||
"auto",
|
||||
"composer-1.5",
|
||||
"composer-1",
|
||||
"opus-4.6-thinking",
|
||||
"opus-4.6",
|
||||
"opus-4.5-thinking",
|
||||
"opus-4.5",
|
||||
"sonnet-4.5-thinking",
|
||||
"sonnet-4.5",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.3-codex-fast",
|
||||
"gpt-5.3-codex-low",
|
||||
"gpt-5.3-codex-low-fast",
|
||||
"gpt-5.3-codex-high",
|
||||
"gpt-5.3-codex-high-fast",
|
||||
"gpt-5.3-codex-xhigh",
|
||||
"gpt-5.3-codex-xhigh-fast",
|
||||
"gpt-5.2",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.2-codex-low",
|
||||
"gpt-5.2-codex-low-fast",
|
||||
"gpt-5.1-codex-max",
|
||||
"gemini-3-pro",
|
||||
"gemini-3-flash",
|
||||
"grok",
|
||||
];
|
||||
|
||||
export function allModelIds() {
|
||||
return [...CURSOR_MODELS];
|
||||
}
|
||||
|
||||
/** Normalize a model id from the request to a known value or "auto". */
|
||||
export function normalizeModel(input) {
|
||||
if (!input) return "auto";
|
||||
const m = String(input);
|
||||
if (m.startsWith("cursor/")) return m.slice("cursor/".length) || "auto";
|
||||
if (m.startsWith("cursor-")) {
|
||||
const r = m.slice("cursor-".length);
|
||||
if (r && CURSOR_MODELS.includes(r)) return r;
|
||||
}
|
||||
if (CURSOR_MODELS.includes(m)) return m;
|
||||
return "auto";
|
||||
}
|
||||
+405
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* OpenAI-compatible proxy in front of cursor-agent CLI (ACP transport).
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /v1/models List supported models
|
||||
* POST /v1/chat/completions Chat completion (streaming or not)
|
||||
* GET /health Liveness probe
|
||||
*
|
||||
* Every request uses cursor-agent ACP (`agent acp`):
|
||||
* - session/new with in-process MCP server for client tools[]
|
||||
* - session/prompt with text + image ContentBlocks
|
||||
* - session/request_permission denies built-in tools, allows MCP client tools
|
||||
* - Tool invocations park until HTTP client returns role:"tool"
|
||||
* - In-cluster callers authenticate with PROXY_INTERNAL_TOKEN; Authentik outpost via X-Authentik-Username
|
||||
*
|
||||
* No external dependencies. Uses only Node built-ins.
|
||||
*/
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { allModelIds, normalizeModel } from "./models.mjs";
|
||||
import {
|
||||
createSession,
|
||||
findSessionForFollowUp,
|
||||
destroyAllSessions,
|
||||
} from "./session-manager.mjs";
|
||||
import {
|
||||
messageContentToText,
|
||||
hasImageParts,
|
||||
openAiMessagesToAcpBlocks,
|
||||
} from "./message-converter.mjs";
|
||||
import { textChunk, toolCallChunk, finishChunk, nonStreamResponse } from "./stream-translator.mjs";
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "4646", 10);
|
||||
const SHUTDOWN_TIMEOUT_MS = parseInt(process.env.SHUTDOWN_TIMEOUT_MS || "10000", 10);
|
||||
const MAX_BODY_BYTES = parseInt(process.env.PROXY_MAX_BODY_BYTES || String(24 * 1024 * 1024), 10);
|
||||
const INTERNAL_TOKEN = process.env.PROXY_INTERNAL_TOKEN || "";
|
||||
|
||||
/** @param {import("node:http").ServerResponse} res */
|
||||
function sendJson(res, status, obj) {
|
||||
const body = JSON.stringify(obj);
|
||||
res.statusCode = status;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.setHeader("Content-Length", Buffer.byteLength(body));
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function readJsonBody(req, maxBytes = MAX_BODY_BYTES) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
req.on("data", (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) {
|
||||
reject(new Error(`request body exceeds ${maxBytes} bytes`));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => {
|
||||
const buf = Buffer.concat(chunks);
|
||||
if (buf.length === 0) {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(buf.toString("utf8")));
|
||||
} catch (e) {
|
||||
reject(new Error(`invalid JSON body: ${e.message}`));
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function extractBearerToken(req) {
|
||||
const auth = req.headers.authorization || "";
|
||||
if (auth.startsWith("Bearer ")) {
|
||||
return auth.slice(7).trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function isPublicPath(pathname) {
|
||||
return pathname === "/health" || pathname === "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* When PROXY_INTERNAL_TOKEN is set, require it for API routes unless the
|
||||
* request was already authenticated by an Authentik proxy outpost.
|
||||
*/
|
||||
function isAuthorized(req) {
|
||||
if (!INTERNAL_TOKEN) return true;
|
||||
|
||||
const url = new URL(req.url, "http://internal");
|
||||
if (isPublicPath(url.pathname)) return true;
|
||||
|
||||
if (req.headers["x-authentik-username"]) return true;
|
||||
|
||||
const bearer = extractBearerToken(req);
|
||||
if (bearer === INTERNAL_TOKEN) return true;
|
||||
|
||||
const headerToken = req.headers["x-proxy-internal-token"];
|
||||
if (typeof headerToken === "string" && headerToken === INTERNAL_TOKEN) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function sendUnauthorized(res) {
|
||||
sendJson(res, 401, {
|
||||
error: {
|
||||
message: "unauthorized",
|
||||
type: "authentication_error",
|
||||
code: "invalid_internal_token",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleModels(req, res) {
|
||||
if (!isAuthorized(req)) {
|
||||
sendUnauthorized(res);
|
||||
return;
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
sendJson(res, 200, {
|
||||
object: "list",
|
||||
data: allModelIds().map((id) => ({ id, object: "model", owned_by: "cursor", created: now })),
|
||||
});
|
||||
}
|
||||
|
||||
function handleHealth(_req, res) {
|
||||
sendJson(res, 200, { status: "ok", timestamp: new Date().toISOString() });
|
||||
}
|
||||
|
||||
async function handleChatCompletions(req, res) {
|
||||
if (!isAuthorized(req)) {
|
||||
sendUnauthorized(res);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = randomUUID().replace(/-/g, "").slice(0, 24);
|
||||
let body;
|
||||
try {
|
||||
body = await readJsonBody(req);
|
||||
} catch (e) {
|
||||
sendJson(res, 400, {
|
||||
error: { message: e.message, type: "invalid_request_error", code: "invalid_body" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(body.messages) || body.messages.length === 0) {
|
||||
sendJson(res, 400, {
|
||||
error: {
|
||||
message: "messages is required and must be a non-empty array",
|
||||
type: "invalid_request_error",
|
||||
code: "invalid_messages",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = body.stream === true;
|
||||
const model = normalizeModel(body.model);
|
||||
const toolsCount = Array.isArray(body.tools) ? body.tools.length : 0;
|
||||
const imageCount = hasImageParts(body.messages) ? body.messages.reduce((n, m) => {
|
||||
if (!Array.isArray(m.content)) return n;
|
||||
return n + m.content.filter((p) => p.type === "image_url").length;
|
||||
}, 0) : 0;
|
||||
|
||||
const followUp = findSessionForFollowUp(body);
|
||||
if (followUp) {
|
||||
const { session, toolResults } = followUp;
|
||||
console.error(
|
||||
`[chat] id=${requestId} transport=acp model=${model} FOLLOWUP session=${session.sessionId} pending=${toolResults.length} stream=${stream}`
|
||||
);
|
||||
for (const tr of toolResults) {
|
||||
const ok = session.resolveToolCall(tr.tool_call_id, messageContentToText(tr.content));
|
||||
if (!ok) {
|
||||
console.error(
|
||||
`[chat] id=${requestId} tool_call_id=${tr.tool_call_id} not pending in session ${session.sessionId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
await streamSessionTurn({ res, session, requestId, stream, blocks: null });
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(
|
||||
`[chat] id=${requestId} transport=acp model=${body.model} -> ${model} tools=${toolsCount} images=${imageCount} stream=${stream}`
|
||||
);
|
||||
|
||||
let blocks;
|
||||
try {
|
||||
blocks = await openAiMessagesToAcpBlocks(body.messages);
|
||||
} catch (e) {
|
||||
sendJson(res, 400, {
|
||||
error: { message: e.message, type: "invalid_request_error", code: "invalid_messages" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let session;
|
||||
try {
|
||||
session = await createSession({
|
||||
tools: body.tools || [],
|
||||
model,
|
||||
log: (...args) => console.error(`[chat:${requestId}]`, ...args),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[chat] id=${requestId} createSession error: ${e.message}`);
|
||||
if (e.code === "too_many_sessions") {
|
||||
sendJson(res, 429, {
|
||||
error: {
|
||||
message: e.message,
|
||||
type: "rate_limit_error",
|
||||
code: "too_many_sessions",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!res.headersSent) {
|
||||
sendJson(res, 500, { error: { message: e.message, type: "server_error", code: null } });
|
||||
} else {
|
||||
try {
|
||||
res.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await streamSessionTurn({ res, session, requestId, stream, blocks });
|
||||
}
|
||||
|
||||
async function streamSessionTurn({ res, session, requestId, stream, blocks }) {
|
||||
if (!stream) {
|
||||
const result = await collectSessionTurn(session, blocks);
|
||||
if (!res.headersSent) {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(
|
||||
JSON.stringify(
|
||||
nonStreamResponse({
|
||||
id: requestId,
|
||||
model: session.detectedModel || "auto",
|
||||
text: result.text,
|
||||
toolCalls: result.calls,
|
||||
finishReason: result.finishReason,
|
||||
usage: result.usage,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
if (result.finishReason !== "tool_calls") {
|
||||
await session.destroy();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Request-Id", requestId);
|
||||
res.flushHeaders();
|
||||
res.write(":ok\n\n");
|
||||
|
||||
let isFirst = true;
|
||||
let finished = false;
|
||||
|
||||
const handlers = {
|
||||
onAssistantDelta: (delta) => {
|
||||
if (res.writableEnded || finished) return;
|
||||
const chunk = textChunk({
|
||||
id: requestId,
|
||||
model: session.detectedModel || "auto",
|
||||
delta,
|
||||
isFirst,
|
||||
});
|
||||
isFirst = false;
|
||||
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||
},
|
||||
onToolCall: (call) => {
|
||||
if (res.writableEnded || finished) return;
|
||||
const chunk = toolCallChunk({
|
||||
id: requestId,
|
||||
model: session.detectedModel || "auto",
|
||||
call,
|
||||
isFirst,
|
||||
});
|
||||
isFirst = false;
|
||||
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||
},
|
||||
onFinish: async (reason, _usage) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
if (!res.writableEnded) {
|
||||
const chunk = finishChunk({
|
||||
id: requestId,
|
||||
model: session.detectedModel || "auto",
|
||||
finishReason: reason,
|
||||
});
|
||||
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
}
|
||||
if (reason === "stop" || reason === "error") {
|
||||
await session.destroy();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
session.runTurn(blocks, handlers);
|
||||
}
|
||||
|
||||
function collectSessionTurn(session, blocks) {
|
||||
return new Promise((resolve) => {
|
||||
let text = "";
|
||||
const calls = [];
|
||||
let finishReason = "stop";
|
||||
let usage = null;
|
||||
session.runTurn(blocks, {
|
||||
onAssistantDelta: (d) => {
|
||||
text += d;
|
||||
},
|
||||
onToolCall: (c) => {
|
||||
calls.push(c);
|
||||
},
|
||||
onFinish: (reason, u) => {
|
||||
finishReason = reason;
|
||||
usage = u;
|
||||
resolve({ text, calls, finishReason, usage });
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server bootstrap & graceful shutdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
if (req.method === "OPTIONS") {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url, "http://internal");
|
||||
try {
|
||||
if (req.method === "GET" && url.pathname === "/v1/models") {
|
||||
await handleModels(req, res);
|
||||
} else if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
|
||||
await handleChatCompletions(req, res);
|
||||
} else if (req.method === "GET" && (url.pathname === "/health" || url.pathname === "/")) {
|
||||
handleHealth(req, res);
|
||||
} else {
|
||||
sendJson(res, 404, { error: { message: `not found: ${req.method} ${url.pathname}` } });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[server] unhandled error: ${e.stack || e.message}`);
|
||||
if (!res.headersSent) {
|
||||
sendJson(res, 500, { error: { message: e.message, type: "server_error" } });
|
||||
} else {
|
||||
try {
|
||||
res.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.error(`[server] cursor-agent-api-proxy (ACP) listening on :${PORT}`);
|
||||
});
|
||||
|
||||
let shuttingDown = false;
|
||||
async function shutdown(signal) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
console.error(`[server] ${signal} received, shutting down...`);
|
||||
server.close();
|
||||
const force = setTimeout(() => {
|
||||
console.error("[server] shutdown timeout, forcing exit");
|
||||
process.exit(1);
|
||||
}, SHUTDOWN_TIMEOUT_MS);
|
||||
try {
|
||||
await destroyAllSessions();
|
||||
} catch (e) {
|
||||
console.error(`[server] error during shutdown: ${e.message}`);
|
||||
}
|
||||
clearTimeout(force);
|
||||
process.exit(0);
|
||||
}
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
@@ -0,0 +1,464 @@
|
||||
/**
|
||||
* Cursor-agent session manager (ACP transport).
|
||||
*
|
||||
* Each HTTP chat request creates an ACP session (`agent acp`):
|
||||
* initialize → session/new (MCP stdio bridge) → session/prompt
|
||||
*
|
||||
* Client tools are advertised via in-process ProxyMcpServer. MCP tools/call
|
||||
* parks until the HTTP client returns role:"tool" with the result.
|
||||
*
|
||||
* Built-in cursor-agent tools are denied via session/request_permission.
|
||||
* Images are sent as native ACP image ContentBlocks (not via Read).
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { AcpClient } from "./acp-client.mjs";
|
||||
import { ProxyMcpServer } from "./mcp-server.mjs";
|
||||
|
||||
export const SESSION_TTL_MS = parseInt(process.env.PROXY_SESSION_TTL_MS || "120000", 10);
|
||||
export const MAX_CONCURRENT_SESSIONS = parseInt(process.env.PROXY_MAX_CONCURRENT_SESSIONS || "4", 10);
|
||||
export const TOOL_CALL_BATCH_MS = parseInt(process.env.PROXY_TOOL_CALL_BATCH_MS || "50", 10);
|
||||
const REAL_AGENT = process.env.CURSOR_AGENT_BIN || "/usr/local/bin/agent.real";
|
||||
const MCP_BRIDGE = process.env.MCP_BRIDGE_BIN || "/app/scripts/mcp-stdio-bridge.sh";
|
||||
|
||||
/** @type {Map<string, CursorSession>} */
|
||||
const SESSIONS = new Map();
|
||||
|
||||
/**
|
||||
* Resolve ACP model id from session/new response and requested OpenAI model id.
|
||||
* @param {object} sessionResult session/new result
|
||||
* @param {string} requested
|
||||
*/
|
||||
export function resolveModelId(sessionResult, requested) {
|
||||
const available = sessionResult.models?.availableModels || [];
|
||||
if (available.length > 0) {
|
||||
if (!requested || requested === "auto") {
|
||||
return sessionResult.models.currentModelId || available[0].modelId;
|
||||
}
|
||||
const exact = available.find((m) => m.modelId === requested || m.name === requested);
|
||||
if (exact) return exact.modelId;
|
||||
const byName = available.find((m) => m.name?.toLowerCase() === requested.toLowerCase());
|
||||
if (byName) return byName.modelId;
|
||||
const prefix = available.find(
|
||||
(m) => m.modelId.startsWith(`${requested}[`) || m.modelId.startsWith(requested)
|
||||
);
|
||||
if (prefix) return prefix.modelId;
|
||||
const contains = available.find((m) => m.modelId.includes(requested));
|
||||
if (contains) return contains.modelId;
|
||||
return sessionResult.models.currentModelId || available[0].modelId;
|
||||
}
|
||||
|
||||
const modelOption = (sessionResult.configOptions || []).find(
|
||||
(o) => o.id === "model" || o.category === "model"
|
||||
);
|
||||
return pickModelValueId(modelOption, requested);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick an ACP model config value for the requested OpenAI model id.
|
||||
* @param {object} modelOption session/new config option with id "model"
|
||||
* @param {string} requested e.g. "auto", "gpt-5.2"
|
||||
*/
|
||||
export function pickModelValueId(modelOption, requested) {
|
||||
const options = modelOption?.options || [];
|
||||
if (!options.length) return null;
|
||||
|
||||
if (!requested || requested === "auto") {
|
||||
const auto = options.find((o) => o.value === "default[]" || o.name === "Auto");
|
||||
return auto?.value || modelOption.currentValue || options[0].value;
|
||||
}
|
||||
|
||||
const exact = options.find((o) => o.value === requested);
|
||||
if (exact) return exact.value;
|
||||
|
||||
const byName = options.find((o) => o.name?.toLowerCase() === requested.toLowerCase());
|
||||
if (byName) return byName.value;
|
||||
|
||||
const prefix = options.find(
|
||||
(o) => o.value.startsWith(`${requested}[`) || o.value.startsWith(requested)
|
||||
);
|
||||
if (prefix) return prefix.value;
|
||||
|
||||
const contains = options.find((o) => o.value.includes(requested));
|
||||
if (contains) return contains.value;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickPermissionOption(options, allow) {
|
||||
if (!Array.isArray(options) || options.length === 0) return null;
|
||||
const kindMatch = allow
|
||||
? (o) => o.kind === "allow_once" || o.kind === "allow_always"
|
||||
: (o) => o.kind === "reject_once" || o.kind === "reject_always";
|
||||
return options.find(kindMatch) || (allow ? options[0] : options[options.length - 1]);
|
||||
}
|
||||
|
||||
export function extractToolNameFromPermission(params) {
|
||||
const tool = params?.toolCall;
|
||||
if (!tool) return "";
|
||||
|
||||
if (tool.rawInput && typeof tool.rawInput === "object" && typeof tool.rawInput.name === "string") {
|
||||
let name = tool.rawInput.name;
|
||||
if (name.startsWith("mcp_client_")) {
|
||||
name = name.slice("mcp_client_".length);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
if (typeof tool.title === "string") {
|
||||
const clientMcp = tool.title.match(/^client-([^:]+):\s*(\S+)/);
|
||||
if (clientMcp) {
|
||||
return clientMcp[2];
|
||||
}
|
||||
const m = tool.title.match(/^(\S+)/);
|
||||
if (m) return m[1].replace(/:$/, "");
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function mapStopReason(stopReason) {
|
||||
if (stopReason === "end_turn") return "stop";
|
||||
if (stopReason === "cancelled") return "error";
|
||||
return "stop";
|
||||
}
|
||||
|
||||
export class CursorSession {
|
||||
constructor({ sessionId, tools, model, log }) {
|
||||
this.sessionId = sessionId;
|
||||
this.tools = tools || [];
|
||||
this.clientToolNames = new Set(this.tools.map((t) => t.function?.name).filter(Boolean));
|
||||
this.model = model || "auto";
|
||||
this.log = log || console.error;
|
||||
this.detectedModel = model || "auto";
|
||||
this.createdAt = Date.now();
|
||||
this.lastActivityAt = Date.now();
|
||||
this.workDir = null;
|
||||
this.cursorProc = null;
|
||||
this.acp = null;
|
||||
this.mcp = null;
|
||||
this.acpSessionId = null;
|
||||
this.timeoutHandle = null;
|
||||
this._finishToolCallsTimer = null;
|
||||
this.terminated = false;
|
||||
this._handlers = null;
|
||||
this._hasToolCallInTurn = false;
|
||||
this._promptInFlight = false;
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.workDir = mkdtempSync(join(tmpdir(), `cursor-${this.sessionId}-`));
|
||||
|
||||
const mcpTools = this.tools.map((t) => ({
|
||||
name: t.function.name,
|
||||
description: t.function.description,
|
||||
inputSchema: t.function.parameters,
|
||||
}));
|
||||
|
||||
const socketPath = join(this.workDir, "mcp.sock");
|
||||
this.mcp = new ProxyMcpServer({
|
||||
socketPath,
|
||||
tools: mcpTools,
|
||||
onToolCall: ({ toolCallId, name, arguments: args }) => {
|
||||
if (!this._handlers) return;
|
||||
this._hasToolCallInTurn = true;
|
||||
this._handlers.onToolCall({
|
||||
id: toolCallId,
|
||||
name,
|
||||
arguments: JSON.stringify(args || {}),
|
||||
});
|
||||
this._scheduleFinishToolCallsTurn();
|
||||
},
|
||||
log: (...args) => this.log(`[mcp:${this.sessionId}]`, ...args),
|
||||
});
|
||||
await this.mcp.start();
|
||||
|
||||
const args = ["acp"];
|
||||
const env = { ...process.env };
|
||||
if (process.env.CURSOR_API_KEY) env.CURSOR_API_KEY = process.env.CURSOR_API_KEY;
|
||||
|
||||
this.cursorProc = spawn(REAL_AGENT, args, {
|
||||
cwd: this.workDir,
|
||||
env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
this.acp = new AcpClient({
|
||||
stdin: this.cursorProc.stdin,
|
||||
stdout: this.cursorProc.stdout,
|
||||
log: (...a) => this.log(`[acp:${this.sessionId}]`, ...a),
|
||||
});
|
||||
|
||||
this.acp.onSessionUpdate = (params) => this._onSessionUpdate(params);
|
||||
this.acp.onIncomingRequest = (method, params) => this._onIncomingRequest(method, params);
|
||||
|
||||
this.cursorProc.stderr.on("data", (d) =>
|
||||
this.log(`[cursor:${this.sessionId} stderr] ${d.toString().trim()}`)
|
||||
);
|
||||
this.cursorProc.on("close", (code) => {
|
||||
this.log(`[cursor:${this.sessionId}] process exited code=${code}`);
|
||||
if (this._handlers) this._finishTurn("error", null);
|
||||
});
|
||||
this.cursorProc.on("error", (e) => {
|
||||
this.log(`[cursor:${this.sessionId}] spawn error: ${e.message}`);
|
||||
if (this._handlers) this._finishTurn("error", null);
|
||||
});
|
||||
|
||||
await this.acp.initialize();
|
||||
await this.acp.authenticateIfNeeded();
|
||||
|
||||
const mcpServers = [
|
||||
{
|
||||
name: "client",
|
||||
command: MCP_BRIDGE,
|
||||
args: [socketPath],
|
||||
env: [],
|
||||
},
|
||||
];
|
||||
|
||||
let newSessionResult;
|
||||
try {
|
||||
newSessionResult = await this.acp.request("session/new", {
|
||||
cwd: this.workDir,
|
||||
mcpServers,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.code === -32000) {
|
||||
await this.acp.request("authenticate", { methodId: "cursor_login" });
|
||||
newSessionResult = await this.acp.request("session/new", {
|
||||
cwd: this.workDir,
|
||||
mcpServers,
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
this.acpSessionId = newSessionResult.sessionId;
|
||||
const modelId = resolveModelId(newSessionResult, this.model);
|
||||
const currentModelId = newSessionResult.models?.currentModelId;
|
||||
if (modelId && modelId !== currentModelId) {
|
||||
await this.acp.request("session/set_config_option", {
|
||||
sessionId: this.acpSessionId,
|
||||
configId: "model",
|
||||
value: modelId,
|
||||
});
|
||||
}
|
||||
this.detectedModel = modelId || currentModelId || this.model;
|
||||
|
||||
this._resetTimer();
|
||||
return this;
|
||||
}
|
||||
|
||||
async _onIncomingRequest(method, params) {
|
||||
if (method === "session/request_permission") {
|
||||
const toolName = extractToolNameFromPermission(params);
|
||||
const allow = this.clientToolNames.has(toolName);
|
||||
const option = pickPermissionOption(params.options, allow);
|
||||
if (!option) {
|
||||
return { outcome: { outcome: "cancelled" } };
|
||||
}
|
||||
const optionId = option.optionId || option.id;
|
||||
return { outcome: { outcome: "selected", optionId } };
|
||||
}
|
||||
|
||||
if (method === "cursor/ask_question") {
|
||||
const first = params?.questions?.[0]?.options?.[0]?.id;
|
||||
return { answers: first ? { [params.questions[0].id]: first } : {} };
|
||||
}
|
||||
|
||||
if (method === "cursor/create_plan") {
|
||||
return { approved: true };
|
||||
}
|
||||
|
||||
if (
|
||||
method === "cursor/update_todos" ||
|
||||
method === "cursor/task" ||
|
||||
method === "cursor/generate_image"
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
this.log(`[acp] unhandled incoming method: ${method}`);
|
||||
return {};
|
||||
}
|
||||
|
||||
_onSessionUpdate(params) {
|
||||
if (!this._handlers || params.sessionId !== this.acpSessionId) return;
|
||||
const update = params.update;
|
||||
if (!update) return;
|
||||
|
||||
if (update.sessionUpdate === "agent_message_chunk") {
|
||||
const content = update.content;
|
||||
if (content?.type === "text" && content.text) {
|
||||
this._handlers.onAssistantDelta(content.text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// agent_thought_chunk and other updates are ignored for OpenAI output.
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<object>|null} blocks ACP ContentBlocks, or null for follow-up only
|
||||
*/
|
||||
runTurn(blocks, handlers) {
|
||||
this._handlers = handlers;
|
||||
this.lastActivityAt = Date.now();
|
||||
this._resetTimer();
|
||||
|
||||
if (blocks === null) {
|
||||
// Follow-up after tool_calls: same session/prompt is still in flight.
|
||||
this._hasToolCallInTurn = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this._hasToolCallInTurn = false;
|
||||
this._promptInFlight = true;
|
||||
this.acp
|
||||
.request("session/prompt", {
|
||||
sessionId: this.acpSessionId,
|
||||
prompt: blocks,
|
||||
})
|
||||
.then((result) => {
|
||||
this._promptInFlight = false;
|
||||
if (!this._handlers) return;
|
||||
if (this.mcp.pending.size > 0 || this._hasToolCallInTurn) {
|
||||
this._scheduleFinishToolCallsTurn();
|
||||
return;
|
||||
}
|
||||
this._finishTurn(mapStopReason(result.stopReason), null);
|
||||
})
|
||||
.catch((e) => {
|
||||
this._promptInFlight = false;
|
||||
this.log(`[acp] session/prompt error: ${e.message}`);
|
||||
if (this._handlers) this._finishTurn("error", null);
|
||||
});
|
||||
}
|
||||
|
||||
resolveToolCall(toolCallId, result) {
|
||||
const ok = this.mcp.resolveToolCall(toolCallId, result);
|
||||
if (ok) {
|
||||
this.lastActivityAt = Date.now();
|
||||
this._resetTimer();
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
_scheduleFinishToolCallsTurn() {
|
||||
if (this._finishToolCallsTimer) clearTimeout(this._finishToolCallsTimer);
|
||||
this._finishToolCallsTimer = setTimeout(() => {
|
||||
this._finishToolCallsTimer = null;
|
||||
if (!this._handlers || !this._hasToolCallInTurn) return;
|
||||
if (this.mcp.pending.size === 0) return;
|
||||
this._finishTurn("tool_calls", null);
|
||||
}, TOOL_CALL_BATCH_MS);
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
if (this.terminated) return;
|
||||
this.terminated = true;
|
||||
if (this._finishToolCallsTimer) clearTimeout(this._finishToolCallsTimer);
|
||||
if (this.timeoutHandle) clearTimeout(this.timeoutHandle);
|
||||
try {
|
||||
this.acp?.close();
|
||||
if (this.cursorProc && this.cursorProc.exitCode === null) {
|
||||
this.cursorProc.kill("SIGTERM");
|
||||
await new Promise((r) => {
|
||||
const kill = setTimeout(() => {
|
||||
try {
|
||||
this.cursorProc?.kill("SIGKILL");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
r();
|
||||
}, 5000);
|
||||
this.cursorProc?.once("close", () => {
|
||||
clearTimeout(kill);
|
||||
r();
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
await this.mcp?.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
if (this.workDir) rmSync(this.workDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
SESSIONS.delete(this.sessionId);
|
||||
}
|
||||
|
||||
_resetTimer() {
|
||||
if (this.timeoutHandle) clearTimeout(this.timeoutHandle);
|
||||
this.timeoutHandle = setTimeout(async () => {
|
||||
this.log(`[cursor:${this.sessionId}] TTL expired (idle ${SESSION_TTL_MS}ms)`);
|
||||
await this.destroy();
|
||||
}, SESSION_TTL_MS);
|
||||
}
|
||||
|
||||
_finishTurn(reason, usage) {
|
||||
if (!this._handlers) return;
|
||||
const handlers = this._handlers;
|
||||
this._handlers = null;
|
||||
void Promise.resolve(handlers.onFinish(reason, usage));
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveSessionCount() {
|
||||
return SESSIONS.size;
|
||||
}
|
||||
|
||||
export async function createSession({ tools, model, log }) {
|
||||
if (SESSIONS.size >= MAX_CONCURRENT_SESSIONS) {
|
||||
const err = new Error(`too many concurrent sessions (max ${MAX_CONCURRENT_SESSIONS})`);
|
||||
err.code = "too_many_sessions";
|
||||
throw err;
|
||||
}
|
||||
|
||||
const sessionId = `s_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
||||
const session = new CursorSession({ sessionId, tools, model, log });
|
||||
SESSIONS.set(sessionId, session);
|
||||
try {
|
||||
await session.start();
|
||||
return session;
|
||||
} catch (e) {
|
||||
await session.destroy();
|
||||
SESSIONS.delete(sessionId);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function findSessionForFollowUp(body) {
|
||||
const toolResults = (body.messages || []).filter((m) => m.role === "tool");
|
||||
if (toolResults.length === 0) return null;
|
||||
for (const session of SESSIONS.values()) {
|
||||
if (!session.mcp) continue;
|
||||
for (const tr of toolResults) {
|
||||
if (session.mcp.pending.has(tr.tool_call_id)) {
|
||||
return { session, toolResults };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getSession(sessionId) {
|
||||
return SESSIONS.get(sessionId);
|
||||
}
|
||||
|
||||
export async function destroyAllSessions() {
|
||||
const all = [...SESSIONS.values()];
|
||||
SESSIONS.clear();
|
||||
await Promise.all(all.map((s) => s.destroy()));
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { extractToolNameFromPermission } from "./session-manager.mjs";
|
||||
|
||||
describe("extractToolNameFromPermission", () => {
|
||||
it("parses client MCP title format", () => {
|
||||
const name = extractToolNameFromPermission({
|
||||
toolCall: { title: "client-get_weather: get_weather" },
|
||||
});
|
||||
assert.equal(name, "get_weather");
|
||||
});
|
||||
|
||||
it("strips mcp_client_ prefix from rawInput name", () => {
|
||||
const name = extractToolNameFromPermission({
|
||||
toolCall: { rawInput: { name: "mcp_client_get_weather" } },
|
||||
});
|
||||
assert.equal(name, "get_weather");
|
||||
});
|
||||
|
||||
it("uses first token for built-in tools", () => {
|
||||
const name = extractToolNameFromPermission({
|
||||
toolCall: { title: "Shell: rm -rf /" },
|
||||
});
|
||||
assert.equal(name, "Shell");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Helpers to build OpenAI-compatible chat.completion.chunk objects.
|
||||
*
|
||||
* Used both by the plain-chat path (text deltas only) and the tool-aware
|
||||
* session path (text deltas + tool_calls deltas).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build a streaming chunk carrying an assistant text delta.
|
||||
*/
|
||||
export function textChunk({ id, model, delta, isFirst }) {
|
||||
return {
|
||||
id: `chatcmpl-${id}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { ...(isFirst ? { role: "assistant" } : {}), content: delta },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a streaming chunk carrying an OpenAI tool_calls delta. The client
|
||||
* receives this when cursor-agent decides to call an MCP tool.
|
||||
*/
|
||||
export function toolCallChunk({ id, model, call, isFirst, index = 0 }) {
|
||||
return {
|
||||
id: `chatcmpl-${id}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
...(isFirst ? { role: "assistant" } : {}),
|
||||
tool_calls: [
|
||||
{
|
||||
index,
|
||||
id: call.id,
|
||||
type: "function",
|
||||
function: { name: call.name, arguments: call.arguments },
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the terminal chunk for a streaming response. finish_reason is one of
|
||||
* "stop" | "tool_calls" | "length" | "error".
|
||||
*/
|
||||
export function finishChunk({ id, model, finishReason }) {
|
||||
return {
|
||||
id: `chatcmpl-${id}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: finishReason }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an error event (sent as a chunk before [DONE]).
|
||||
*/
|
||||
export function errorChunk({ id, model, message }) {
|
||||
return {
|
||||
error: { message, type: "server_error", code: null },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a non-streaming chat.completion response. Used when stream=false.
|
||||
*/
|
||||
export function nonStreamResponse({ id, model, text, toolCalls, finishReason, usage }) {
|
||||
const message =
|
||||
toolCalls && toolCalls.length > 0
|
||||
? {
|
||||
role: "assistant",
|
||||
content: text || null,
|
||||
tool_calls: toolCalls.map((c) => ({
|
||||
id: c.id,
|
||||
type: "function",
|
||||
function: { name: c.name, arguments: c.arguments },
|
||||
})),
|
||||
}
|
||||
: { role: "assistant", content: text || "" };
|
||||
return {
|
||||
id: `chatcmpl-${id}`,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [{ index: 0, message, finish_reason: finishReason }],
|
||||
usage: usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user