D: wire STEP engine — shared-secret auth to build123d service

- step.ts sends X-Chisel-Secret; index.ts passes STEP_SHARED_SECRET
- step-service/ (FastAPI + runner.py) runs build123d on the VM, exports STEP
- Live: chisel-step.theradicalparty.com via dedicated cloudflared tunnel
- Production /api/step verified end-to-end → valid ISO-10303-21 STEP

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
maverick 2026-07-25 00:57:08 +10:00
parent 8db62d411f
commit 00faa800e0
4 changed files with 111 additions and 2 deletions

View file

@ -8,6 +8,7 @@ import { generateStep } from "./step";
interface Env { interface Env {
ANTHROPIC_API_KEY: string; ANTHROPIC_API_KEY: string;
STEP_SERVICE_URL?: string; STEP_SERVICE_URL?: string;
STEP_SHARED_SECRET?: string;
DB: D1Database; DB: D1Database;
ASSETS: { fetch: typeof fetch }; ASSETS: { fetch: typeof fetch };
} }
@ -82,7 +83,12 @@ app.post("/api/step", async (c) => {
if (!c.env.STEP_SERVICE_URL) return c.json({ error: "STEP engine not configured yet." }, 503); if (!c.env.STEP_SERVICE_URL) return c.json({ error: "STEP engine not configured yet." }, 503);
if (!c.env.ANTHROPIC_API_KEY) return c.json({ error: "ANTHROPIC_API_KEY not set" }, 500); if (!c.env.ANTHROPIC_API_KEY) return c.json({ error: "ANTHROPIC_API_KEY not set" }, 500);
try { try {
const step = await generateStep(c.env.ANTHROPIC_API_KEY, c.env.STEP_SERVICE_URL, request); const step = await generateStep(
c.env.ANTHROPIC_API_KEY,
c.env.STEP_SERVICE_URL,
c.env.STEP_SHARED_SECRET ?? "",
request
);
return new Response(step, { return new Response(step, {
headers: { headers: {
"content-type": "application/step", "content-type": "application/step",

View file

@ -53,12 +53,13 @@ async function claudePython(apiKey: string, request: string): Promise<string> {
export async function generateStep( export async function generateStep(
apiKey: string, apiKey: string,
serviceUrl: string, serviceUrl: string,
secret: string,
request: string request: string
): Promise<ArrayBuffer> { ): Promise<ArrayBuffer> {
const code = await claudePython(apiKey, request); const code = await claudePython(apiKey, request);
const res = await fetch(`${serviceUrl.replace(/\/$/, "")}/run`, { const res = await fetch(`${serviceUrl.replace(/\/$/, "")}/run`, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json", "x-chisel-secret": secret },
body: JSON.stringify({ code }), body: JSON.stringify({ code }),
}); });
if (!res.ok) { if (!res.ok) {

32
step-service/runner.py Normal file
View file

@ -0,0 +1,32 @@
"""Isolated executor for Claude-authored build123d code.
Run as a subprocess so a bad script can't hang or crash the service:
python runner.py <code_file> <out_step_file>
The script must assign the final solid to `result`. We export it to STEP.
"""
import sys
from build123d import * # noqa: F401,F403 — the model code relies on star import
def main() -> int:
code_file, out_file = sys.argv[1], sys.argv[2]
with open(code_file) as f:
code = f.read()
ns: dict = {}
exec(compile(code, "<model>", "exec"), ns, ns) # noqa: S102 — sandboxed subprocess
result = ns.get("result")
if result is None:
print("ERROR: script did not assign `result`", file=sys.stderr)
return 2
# build123d objects expose .part/.solid in various wrappers; normalize.
obj = getattr(result, "part", result)
export_step(obj, out_file) # noqa: F405
return 0
if __name__ == "__main__":
sys.exit(main())

70
step-service/service.py Normal file
View file

@ -0,0 +1,70 @@
"""Chisel STEP engine — executes build123d code and returns a STEP file.
POST /run { "code": "<build123d python assigning `result`>" }
200 application/step (the file) or 4xx/5xx JSON error
Auth: requests must carry X-Chisel-Secret: <STEP_SHARED_SECRET>. Only Chisel's
Cloudflare Worker knows it, so the arbitrary-code /run endpoint isn't public.
Runs each script in a subprocess (runner.py) with a hard timeout so a bad or
malicious model can't hang or take down the service.
"""
import os
import subprocess
import tempfile
from fastapi import FastAPI, Header, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel
HERE = os.path.dirname(os.path.abspath(__file__))
PY = os.path.join(HERE, "venv", "bin", "python")
RUNNER = os.path.join(HERE, "runner.py")
SECRET = os.environ.get("STEP_SHARED_SECRET", "")
TIMEOUT = 40
app = FastAPI(title="Chisel STEP engine")
class RunReq(BaseModel):
code: str
@app.get("/health")
def health():
return {"ok": True}
@app.post("/run")
def run(req: RunReq, x_chisel_secret: str = Header(default="")):
if SECRET and x_chisel_secret != SECRET:
raise HTTPException(status_code=401, detail="bad secret")
with tempfile.TemporaryDirectory() as d:
code_file = os.path.join(d, "model.py")
out_file = os.path.join(d, "out.step")
with open(code_file, "w") as f:
f.write(req.code)
try:
proc = subprocess.run(
[PY, RUNNER, code_file, out_file],
capture_output=True,
text=True,
timeout=TIMEOUT,
)
except subprocess.TimeoutExpired:
raise HTTPException(status_code=504, detail="model execution timed out")
if proc.returncode != 0 or not os.path.exists(out_file):
msg = (proc.stderr or proc.stdout or "unknown error").strip()
raise HTTPException(status_code=422, detail=msg[-800:])
with open(out_file, "rb") as f:
data = f.read()
return Response(
content=data,
media_type="application/step",
headers={"content-disposition": 'attachment; filename="chisel-model.step"'},
)