fix(automation): TCP automation requires a session token

--http (headless and GUI-hosted) and --serve --port answered anything
that reached the loopback port, and the REST responses allowed every
web origin. Loopback is shared by every process and user on the
machine, so the port proved nothing.

- Every REST request now needs Authorization: Bearer <token>, and every
  --serve --port request line a "token" field; otherwise 401 /
  unauthorized (the socket is closed).
- The token is OCS_API_TOKEN when the launcher sets it, else 32 random
  bytes printed on stderr at startup; compared in constant time.
- No CORS headers are sent, so a web page cannot read an answer.
- stdio --serve and --mcp are unchanged (private to their parent / the
  GUI's own token).

GHSA-c27p-xfx2-9r5x

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-09-30 21:48:20 +03:00
1 parent d0bfa48037
commit 1fec34d84a
7 files changed
+195 -60

No files matched your search

+21 -11
View File
@@ -72,15 +72,22 @@ MCP advertise the whole surface; feature detection is `capabilities` (§4).
`OpenCADStudio --http 8090` runs a private, window-less session behind a
resource-oriented REST surface at `http://127.0.0.1:<port>/api/v1` (loopback
bind only, permissive CORS, JSON bodies, one request per connection). A
bind only, JSON bodies, one request per connection, no CORS headers). A
machine-readable OpenAPI 3 description of the whole surface is served at
`GET /api/v1/openapi`.
Every request carries `Authorization: Bearer <token>`: the value of
`OCS_API_TOKEN` when the launcher set it, otherwise a random token printed on
stderr at startup. A request without it is answered **401**
`{"code":"unauthorized"}`. Loopback is shared by every process and user on
the machine, so the port alone is no credential.
| Status | Meaning |
|---|---|
| `200` | Success (read or update) |
| `201` | A resource was created (document, entities, block, group, selection set, layout) |
| `400` | Validation failure — the body keeps the symbolic `code` and message |
| `401` | Missing or wrong bearer token (`code:"unauthorized"`) |
| `404` | Unknown route or unknown handle (`code:"entity_absent"`) |
| `409` | State conflict (`stale_state`, `document_not_active`) or GUI required (`gui_required`) |
| `503` | Dispatcher busy |
@@ -126,7 +133,7 @@ Differences worth knowing:
- Unknown POST paths are treated as op attempts and forwarded (any op of the
surface works — including `operation` to poll a pending pick and `cancel`
to retract it); truly unrouted paths answer `404 {"code":"unknown_route"}`.
- `OPTIONS *` answers **204** (CORS preflight).
- The same bearer token as §3.1 is required on every request.
### 3.3 stdio JSONL (`--serve`)
@@ -138,6 +145,9 @@ envelope; mutations use the envelope of §1).
### 3.4 TCP (`--serve --port N`)
The same JSONL protocol as §3.3 over `127.0.0.1:<N>`, one client at a time.
Every request line carries `"token":"<token>"` (the §3.1 token); a line
without it is answered `{"ok":false,"code":"unauthorized"}` and the
connection is closed.
### 3.5 AI-tool session (`--mcp`)
@@ -506,7 +516,7 @@ client's own log.
```sh
# Create (missing layers auto-created; the batch is atomic).
curl -s -X POST http://127.0.0.1:8090/api/v1/entities -H "Content-Type: application/json" -d '{
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/entities -H "Content-Type: application/json" -d '{
"entities":[
{"type":"Line","start":[0,0],"end":[100,0],"layer":"FRAME"},
{"type":"Text","value":"PAGE-01","position":[10,50],"height":3.0}
@@ -514,16 +524,16 @@ curl -s -X POST http://127.0.0.1:8090/api/v1/entities -H "Content-Type: applicat
# → 201 {"result":{"handles":["63","64"],"created":2,"layers_created":["FRAME"]}}
# Filter by property.
curl -s "http://127.0.0.1:8090/api/v1/entities?type=Line&where=%5B%7B%22path%22%3A%22%2Flayer%22%2C%22op%22%3A%22eq%22%2C%22value%22%3A%22FRAME%22%7D%5D"
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/entities?type=Line&where=%5B%7B%22path%22%3A%22%2Flayer%22%2C%22op%22%3A%22eq%22%2C%22value%22%3A%22FRAME%22%7D%5D"
# Mark, wrap into a reusable block, plot every sheet, save as a template.
curl -s -X PUT http://127.0.0.1:8090/api/v1/entities/64/xdata/SPEC \
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X PUT http://127.0.0.1:8090/api/v1/entities/64/xdata/SPEC \
-H "Content-Type: application/json" -d '[{"code":1000,"value":"PAGE-01"}]'
curl -s -X POST http://127.0.0.1:8090/api/v1/blocks \
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/blocks \
-H "Content-Type: application/json" -d '{"name":"MARK","base":[0,0,0],"handles":["63","64"]}'
curl -s -X POST http://127.0.0.1:8090/api/v1/plot \
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/plot \
-H "Content-Type: application/json" -d '{"path":"C:/out/pages.pdf","layout":"all","per_page":true}'
curl -s -X POST http://127.0.0.1:8090/api/v1/save \
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/save \
-H "Content-Type: application/json" -d '{"path":"C:/out/session.dwt"}'
```
@@ -534,14 +544,14 @@ OpenCADStudio.exe "C:/drawings/plan.dwg" --http 8090 # the editor stays intera
# Ask the person to pick block references; the connection parks until they
# press Enter (or Esc). No polling loop needed.
curl -s -m 1800 -X POST http://127.0.0.1:8090/api/v1/user_select \
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -m 1800 -X POST http://127.0.0.1:8090/api/v1/user_select \
-H "Content-Type: application/json" \
-d '{"request_id":"sample-1","type":"INSERT","detail":"full","prompt":"Pick the title blocks"}'
# → {"ok":true,"status":"completed","result":{"cancelled":false,"count":2,…}}
# Read the same entities back, then continue driving the live drawing.
curl -s -X POST http://127.0.0.1:8090/api/v1/get_selection -d '{}'
curl -s -X POST http://127.0.0.1:8090/api/v1/entities/transform \
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/get_selection -d '{}'
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/entities/transform \
-H "Content-Type: application/json" \
-d '{"handles":["6F"],"action":"move","vector":[5,0]}'
```
+34 -25
View File
@@ -369,13 +369,22 @@ editor, events and plotting.
resource-oriented REST surface on `http://127.0.0.1:<port>/api/v1`. Any
HTTP client works — curl, Python, C# `HttpClient`, JS `fetch`; there is no
SDK and nothing AI-specific. The server keeps one drawing session alive
across requests, binds loopback only, sends permissive CORS headers, and
serves its machine-readable description at `GET /api/v1/openapi`
(OpenAPI 3, embedded from `src/rest_openapi.json`).
across requests, binds loopback only, and serves its machine-readable
description at `GET /api/v1/openapi` (OpenAPI 3, embedded from
`src/rest_openapi.json`).
Every request carries `Authorization: Bearer <token>`; anything else is
answered `401 {"code":"unauthorized"}`. Loopback is shared by every process
and user on the machine, so the port alone proves nothing. Set the token
yourself with `OCS_API_TOKEN` before launching, or read the random one the
server prints on stderr at startup. No CORS headers are sent, so a web page
cannot drive or read the session. The same token guards `--serve --port`
(as a `"token"` field in each JSON line) and the GUI-hosted channel below.
```sh
export OCS_API_TOKEN=$(openssl rand -hex 32)
OpenCADStudio --http 8090 # start the REST server (headless)
curl http://127.0.0.1:8090/api/v1/ready
curl -H "Authorization: Bearer $OCS_API_TOKEN" http://127.0.0.1:8090/api/v1/ready
```
### GUI-hosted REST channel (`--http` + file)
@@ -503,7 +512,7 @@ HTTP.
```sh
# 1. Create entities (the missing layer "FRAME" is created automatically;
# the whole batch commits as one undoable step or not at all).
curl -s -X POST http://127.0.0.1:8090/api/v1/entities \
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/entities \
-H "Content-Type: application/json" \
-d '{"entities":[
{"type":"Line","start":[0,0],"end":[100,0],"layer":"FRAME"},
@@ -514,71 +523,71 @@ curl -s -X POST http://127.0.0.1:8090/api/v1/entities \
# → 201 {"ok":true,"status":"completed","result":{"handles":["63","64","65","66"],…}}
# 2. Verify: query it back.
curl -s "http://127.0.0.1:8090/api/v1/entities?type=Line&detail=full"
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/entities?type=Line&detail=full"
# → {"ok":true,"entities":[{"handle":"63","start":[0,0,0],"end":[100,0,0],…},…]
# 3. Move the circle 10 units right.
curl -s -X POST http://127.0.0.1:8090/api/v1/entities/transform \
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/entities/transform \
-H "Content-Type: application/json" \
-d '{"handles":["66"],"action":"move","vector":[10,0]}'
# 4. Mark the text with extended data (RegApp "SPM" registered implicitly).
curl -s -X PUT http://127.0.0.1:8090/api/v1/entities/65/xdata/SPM \
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X PUT http://127.0.0.1:8090/api/v1/entities/65/xdata/SPM \
-H "Content-Type: application/json" \
-d '[{"code":1000,"value":"PAGE-01"},{"code":1070,"value":3}]'
curl -s "http://127.0.0.1:8090/api/v1/entities/65/xdata?app=SPM"
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/entities/65/xdata?app=SPM"
# 5. Turn the two frame lines into a block definition + Insert.
curl -s -X POST http://127.0.0.1:8090/api/v1/blocks \
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/blocks \
-H "Content-Type: application/json" \
-d '{"name":"FRAME-MARK","base":[0,0,0],"handles":["63","67"]}'
# → 201 {"result":{"block":"FRAME-MARK","insert":"6B"}}
# 6. Save and prove persistence.
curl -s -X POST http://127.0.0.1:8090/api/v1/save \
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/save \
-H "Content-Type: application/json" -d '{"path":"C:/out/session.dwg"}'
# 7. Filter entities by any property (RFC 6901 pointers, SQL-ish operators).
curl -s "http://127.0.0.1:8090/api/v1/entities?type=Circle&detail=geometry&where=%5B%7B%22path%22%3A%22%2Fradius%22%2C%22op%22%3A%22gt%22%2C%22value%22%3A2%7D%5D"
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/entities?type=Circle&detail=geometry&where=%5B%7B%22path%22%3A%22%2Fradius%22%2C%22op%22%3A%22gt%22%2C%22value%22%3A2%7D%5D"
# 8. Set and read back drawing sysvars.
curl -s -X POST http://127.0.0.1:8090/api/v1/sysvars \
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/sysvars \
-H "Content-Type: application/json" -d '{"set":{"ltscale":2.5}}'
curl -s "http://127.0.0.1:8090/api/v1/sysvars?names=ltscale,mirrtext"
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/sysvars?names=ltscale,mirrtext"
# 9. Provision a sheet: create the layout, write its page setup, then
# plot every layout to its own PDF (one file per entry in result.files).
curl -s -X POST http://127.0.0.1:8090/api/v1/layouts \
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/layouts \
-H "Content-Type: application/json" -d '{"name":"PLAN"}'
curl -s -X PUT http://127.0.0.1:8090/api/v1/layouts/PLAN/page-setup \
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X PUT http://127.0.0.1:8090/api/v1/layouts/PLAN/page-setup \
-H "Content-Type: application/json" \
-d '{"paper":"ISO_A4_(210.00_x_297.00_MM)","orientation":"landscape","fit":true,"center":true}'
curl -s -X POST http://127.0.0.1:8090/api/v1/plot \
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/plot \
-H "Content-Type: application/json" \
-d '{"path":"C:/out/plan.pdf","layout":"all","per_page":true}'
# 10. Save the drawing as a template (a .dwt is DWG bytes — same writer,
# no lock held on the file) and start a fresh drawing from it.
curl -s -X POST http://127.0.0.1:8090/api/v1/save \
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/save \
-H "Content-Type: application/json" -d '{"path":"C:/out/session.dwt"}'
curl -s -X POST http://127.0.0.1:8090/api/v1/documents \
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/documents \
-H "Content-Type: application/json" -d '{"template":"C:/out/session.dwt"}'
# 11. Second document, then copy entities across documents.
curl -s -X POST http://127.0.0.1:8090/api/v1/documents -d '{}' # a fresh document
curl -s -X POST http://127.0.0.1:8090/api/v1/entities/copy-to \
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/documents -d '{}' # a fresh document
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/entities/copy-to \
-H "Content-Type: application/json" \
-d '{"handles":["63","67"],"document_id":2}'
# 12. Group the copies, save them as a named selection set, recall it.
curl -s -X POST http://127.0.0.1:8090/api/v1/groups \
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/groups \
-H "Content-Type: application/json" -d '{"name":"FRAME","handles":["63","67"]}'
curl -s -X POST http://127.0.0.1:8090/api/v1/selection-sets \
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/selection-sets \
-H "Content-Type: application/json" -d '{"name":"frame-set","handles":["63","67"]}'
curl -s "http://127.0.0.1:8090/api/v1/selection-sets/frame-set?select=true"
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/selection-sets/frame-set?select=true"
# 13. Close a document, discarding unsaved changes.
curl -s -X DELETE "http://127.0.0.1:8090/api/v1/documents/2?discard=true"
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X DELETE "http://127.0.0.1:8090/api/v1/documents/2?discard=true"
```
The same lifecycle — create → verify → transform → xdata → block →
+7 -1
View File
@@ -14,6 +14,8 @@ sets, and a close-with-discard. Uses only the Python standard library.
"""
import json
import os
import secrets
import subprocess
import sys
import tempfile
@@ -24,10 +26,13 @@ import urllib.request
from pathlib import Path
TOKEN = secrets.token_hex(32)
def call(port: int, method: str, path: str, body=None):
"""One HTTP call; returns (status, parsed-json-or-None)."""
data = None
headers = {"Content-Type": "application/json"}
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {TOKEN}"}
if body is not None:
data = json.dumps(body).encode()
request = urllib.request.Request(
@@ -67,6 +72,7 @@ def main() -> None:
out_dir = Path(tempfile.mkdtemp(prefix="ocs_rest_smoke_"))
process = subprocess.Popen(
[str(exe), "--http", str(port)],
env={**os.environ, "OCS_API_TOKEN": TOKEN},
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)