Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.local.template
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,10 @@ ENVIRONMENT="local"
ADAM_URL="<Adam URL or dev URL>"
WEBHOOK_BASE_URL="<Public TanStack App URL>"
NGROK_URL="<NGROK URL>"
BUILD123D_EXPORT_URL="" # Production: isolated exporter service origin, e.g. https://build123d-exporter.example.com
BUILD123D_EXPORT_TOKEN="<Shared exporter bearer token>"
BUILD123D_EXPORT_TIMEOUT_MS="45000"
BUILD123D_MAX_CODE_LENGTH="200000"
BUILD123D_MAX_OUTPUT_BYTES="26214400"
BUILD123D_MAX_PREVIEW_PARTS="64"
BUILD123D_ALLOW_LOCAL_PYTHON="1" # Local dev only when BUILD123D_EXPORT_URL is unset
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,15 @@ npm run dev
- Node.js ^20.19.0 or >=22.12.0, with npm 10+
- Supabase CLI
- ngrok (for local webhook development)
- For local-only build123d development: Python 3 with `build123d` installed

```bash
python3 -m pip install build123d
```

Production build123d exports must run through the isolated exporter service in
`services/build123d-exporter`. Do not run LLM-generated Python inside the main
web server in production.

## 🔧 Setting Up Environment Variables

Expand Down Expand Up @@ -123,8 +132,50 @@ npm run dev
ADAM_URL="<Adam URL or dev URL>" # Checkout and portal redirect target
WEBHOOK_BASE_URL="<Public TanStack App URL>" # Your app URL for /cadam/api callbacks
NGROK_URL="<NGROK URL>" # Optional local Supabase Storage tunnel for provider-readable signed URLs
BUILD123D_EXPORT_URL="" # Production exporter service origin
BUILD123D_EXPORT_TOKEN="<Shared exporter bearer token>"
BUILD123D_EXPORT_TIMEOUT_MS="45000"
BUILD123D_MAX_CODE_LENGTH="200000"
BUILD123D_MAX_OUTPUT_BYTES="26214400"
BUILD123D_MAX_PREVIEW_PARTS="64"
BUILD123D_ALLOW_LOCAL_PYTHON="1" # Local dev only
```

### build123d Export Service

CADAM's `/api/build123d-export` endpoint validates the signed-in user, then:

- calls `BUILD123D_EXPORT_URL/export` when that env var is set;
- otherwise allows local `python3` only for `ENVIRONMENT=local`,
`ENVIRONMENT=development`, `ENVIRONMENT=test`, or
`BUILD123D_ALLOW_LOCAL_PYTHON=1`;
- fails loudly in production if no exporter service is configured.

Run the isolated exporter locally with:

```bash
docker build --platform linux/amd64 -t cadam-build123d-exporter services/build123d-exporter
docker run --rm \
-p 8080:8080 \
--cpus=2 \
--memory=2g \
-e BUILD123D_EXPORT_TOKEN=dev-build123d-token \
cadam-build123d-exporter
```

Then set:

```bash
BUILD123D_EXPORT_URL="http://127.0.0.1:8080"
BUILD123D_EXPORT_TOKEN="dev-build123d-token"
```

For production, deploy the same container behind HTTPS with CPU, memory, timeout,
temporary filesystem, and outbound-network limits. The exporter process also
blocks Python socket creation before user code loads. It receives only generated
build123d source and export options; it should not receive CADAM database,
billing, Supabase, OpenRouter, or provider secrets.

## 🌐 Setting Up ngrok for Local Development

CADAM uses public URLs for provider callbacks and local signed storage URLs:
Expand Down
4 changes: 4 additions & 0 deletions services/build123d-exporter/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
.pytest_cache/
.venv/
26 changes: 26 additions & 0 deletions services/build123d-exporter/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
FROM python:3.11-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1

RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libgl1 \
libglib2.0-0 \
libxrender1 \
tini \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt

COPY app.py .

RUN useradd --create-home --shell /usr/sbin/nologin exporter
USER exporter

EXPOSE 8080
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]
239 changes: 239 additions & 0 deletions services/build123d-exporter/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import base64
import json
import os
import pathlib
import resource
import shutil
import subprocess
import sys
import tempfile
import textwrap
from typing import Literal

from fastapi import FastAPI, Header, HTTPException, Response
from pydantic import BaseModel, Field


ExportFormat = Literal["stl", "step", "brep", "preview"]

MAX_CODE_LENGTH = int(os.getenv("BUILD123D_MAX_CODE_LENGTH", "200000"))
DEFAULT_TIMEOUT_MS = int(os.getenv("BUILD123D_EXPORT_TIMEOUT_MS", "45000"))
DEFAULT_MAX_OUTPUT_BYTES = int(
os.getenv("BUILD123D_MAX_OUTPUT_BYTES", str(25 * 1024 * 1024))
)
DEFAULT_MAX_PREVIEW_PARTS = int(os.getenv("BUILD123D_MAX_PREVIEW_PARTS", "64"))
MEMORY_LIMIT_MB = int(os.getenv("BUILD123D_MEMORY_LIMIT_MB", "2048"))
TOKEN = os.getenv("BUILD123D_EXPORT_TOKEN", "")


class ExportRequest(BaseModel):
code: str = Field(min_length=1, max_length=MAX_CODE_LENGTH)
format: ExportFormat
timeoutMs: int = Field(default=DEFAULT_TIMEOUT_MS, ge=1000, le=120000)
maxOutputBytes: int = Field(
default=DEFAULT_MAX_OUTPUT_BYTES,
ge=1024,
le=100 * 1024 * 1024,
)
maxPreviewParts: int = Field(default=DEFAULT_MAX_PREVIEW_PARTS, ge=1, le=256)


app = FastAPI(title="CADAM build123d exporter")


def _require_token(authorization: str | None) -> None:
if not TOKEN:
return
expected = f"Bearer {TOKEN}"
if authorization != expected:
raise HTTPException(status_code=401, detail="Unauthorized")
Comment on lines +44 to +49

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Unauthenticated access when token is not configured

If BUILD123D_EXPORT_TOKEN is unset or empty, _require_token returns immediately with no auth check — any caller can execute arbitrary Python code. The service executes LLM-generated code in a subprocess, so an unauthenticated endpoint is a high-impact gap. Additionally, the != string comparison is not constant-time, making the token enumerable via timing side-channel when the service is reachable. Both issues compound: a misconfigured deployment (empty token) gives full access, and a correctly-configured one leaks timing information. Use hmac.compare_digest for the comparison, and raise a startup error rather than silently skipping auth when no token is configured.



def _extension(format: ExportFormat) -> str:
return "step" if format == "step" else format


def _mime_type(format: ExportFormat) -> str:
if format == "preview":
return "application/json"
if format == "stl":
return "model/stl"
if format == "step":
return "model/step"
return "application/octet-stream"


def _runner_source(
source_path: pathlib.Path,
output_path: pathlib.Path,
format: ExportFormat,
max_preview_parts: int,
) -> str:
return textwrap.dedent(
f"""
import base64
import importlib.util
import json
import pathlib
import socket
import sys
import traceback

try:
import build123d

def _blocked_network(*args, **kwargs):
raise RuntimeError("Network access is disabled during build123d export")

socket.create_connection = _blocked_network
socket.socket = _blocked_network

source_path = pathlib.Path({str(source_path)!r})
output_path = pathlib.Path({str(output_path)!r})
spec = importlib.util.spec_from_file_location("adam_build123d_model", source_path)
if spec is None or spec.loader is None:
raise RuntimeError("Could not load build123d source")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
gen_step = getattr(module, "gen_step", None)
if not callable(gen_step):
raise RuntimeError("build123d source must define gen_step()")
shape = gen_step()
if shape is None:
raise RuntimeError("gen_step() returned None")

export_format = {format!r}
if export_format == "preview":
def color_tuple(value):
if value is None:
return None
if hasattr(value, "to_tuple"):
return [float(v) for v in value.to_tuple()]
try:
return [float(v) for v in value]
except TypeError:
return None

def collect_leaves(node, inherited_color=None, inherited_label="part"):
color = getattr(node, "color", None) or inherited_color
label = getattr(node, "label", None) or inherited_label
children = tuple(getattr(node, "children", ()) or ())
if children:
leaves = []
for index, child in enumerate(children):
child_label = getattr(child, "label", None) or f"{{label}}_{{index + 1}}"
leaves.extend(collect_leaves(child, color, child_label))
return leaves
return [(node, color, label)]

root_stl_path = output_path.with_suffix(".root.stl")
build123d.export_stl(shape, root_stl_path)
parts = []
for index, (part, part_color, part_label) in enumerate(collect_leaves(shape)[:{max_preview_parts}]):
part_path = output_path.with_suffix(f".part-{{index}}.stl")
build123d.export_stl(part, part_path)
parts.append({{
"label": str(part_label or f"part_{{index + 1}}"),
"color": color_tuple(part_color),
"stl": base64.b64encode(part_path.read_bytes()).decode("ascii"),
}})
output_path.write_text(json.dumps({{
"rootStl": base64.b64encode(root_stl_path.read_bytes()).decode("ascii"),
"parts": parts,
}}), encoding="utf8")
elif export_format == "step":
build123d.export_step(shape, output_path)
elif export_format == "brep":
build123d.export_brep(shape, output_path)
else:
build123d.export_stl(shape, output_path)
except Exception:
traceback.print_exc()
sys.exit(1)
"""
)


def _limit_child_resources(max_output_bytes: int, timeout_ms: int):
def set_limit(kind: int, limits: tuple[int, int]) -> None:
try:
resource.setrlimit(kind, limits)
except (OSError, ValueError):
pass

def apply_limits() -> None:
memory_bytes = MEMORY_LIMIT_MB * 1024 * 1024
cpu_seconds = max(1, int(timeout_ms / 1000) + 2)
set_limit(resource.RLIMIT_AS, (memory_bytes, memory_bytes))
set_limit(resource.RLIMIT_CPU, (cpu_seconds, cpu_seconds))
set_limit(
resource.RLIMIT_FSIZE,
(max_output_bytes * 2, max_output_bytes * 2),
)

return apply_limits


@app.get("/healthz")
def healthz() -> dict[str, str]:
return {"status": "ok"}


@app.post("/export")
def export_model(
request: ExportRequest,
authorization: str | None = Header(default=None),
) -> Response:
_require_token(authorization)

temp_dir = pathlib.Path(tempfile.mkdtemp(prefix="adam-build123d-"))
source_path = temp_dir / "model.py"
output_path = temp_dir / f"model.{_extension(request.format)}"
runner_path = temp_dir / "export_model.py"

try:
source_path.write_text(request.code, encoding="utf8")
runner_path.write_text(
_runner_source(
source_path,
output_path,
request.format,
request.maxPreviewParts,
),
encoding="utf8",
)

result = subprocess.run(
[sys.executable, str(runner_path)],
cwd=temp_dir,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
timeout=request.timeoutMs / 1000,
env={"PATH": os.getenv("PATH", "")},
preexec_fn=_limit_child_resources(
request.maxOutputBytes,
request.timeoutMs,
),
)
if result.returncode != 0:
message = result.stderr[-8000:].strip() or "build123d export failed"
raise HTTPException(status_code=422, detail=message)

output = output_path.read_bytes()
if len(output) > request.maxOutputBytes:
raise HTTPException(
status_code=413,
detail="build123d export exceeded maximum output size",
)

return Response(
content=output,
media_type=_mime_type(request.format),
headers={"Cache-Control": "no-store"},
)
except subprocess.TimeoutExpired:
raise HTTPException(status_code=422, detail="build123d export timed out")
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
3 changes: 3 additions & 0 deletions services/build123d-exporter/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
build123d==0.10.0
fastapi==0.115.6
uvicorn[standard]==0.32.1
5 changes: 5 additions & 0 deletions shared/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Database } from './database.ts';
export type Model = string;
export type CreativeModel = 'quality' | 'fast' | 'ultra';
export type CadBackend = 'openscad' | 'build123d';

export type Prompt = {
text?: string;
Expand Down Expand Up @@ -64,12 +65,15 @@ export type Content = {
polygonCount?: number;
// File format preference for quad topology models
preferredFormat?: 'glb' | 'fbx';
cadBackend?: CadBackend;
};

export type ParametricArtifact = {
title: string;
version: string;
code: string;
cadBackend?: CadBackend;
codeLanguage?: 'openscad' | 'python';
parameters: Parameter[];
parts?: ParametricPart[];
legacy?: {
Expand Down Expand Up @@ -123,6 +127,7 @@ export type GenerationStatus = Database['public']['Enums']['generation-status'];

export type ConversationSettings = {
model?: Model;
cadBackend?: CadBackend;
} | null;

export type Profile = Database['public']['Tables']['profiles']['Row'];
Loading