PdfCraft 헤드리스 SDK 및 Python과 TypeScript 바인딩을 갖춘 REST 서비스
README 링크에서 발견 · today
PDF 처리를 위한 Rust SDK, REST 마이크로서비스, Python/TS SDK를 만들었습니다.
What they did
작성자는 storytold/pdfcraft에 대해 PR #872를 열어 기존 데스크톱 UI와 MCP 서버와 함께 프로그래매틱 개발자 플레인을 추가했습니다. 이들은 crates/sdk(pdfcraft-sdk)를 C-ABI/FFI 바인딩을 갖춘 인프로세스 Rust SDK로, crates/rest(pdfcraft-rest)를 작업 큐잉, 제한된 동시성, 베어러 인증, 파일 스필오버를 갖춘 RFC 7807 준수 HTTP 마이크로서비스로, sdks/python(pdfcraft)을 PyO3 및 HTTP 기반 Python SDK로 만들었습니다. 타입 지정 폴백을 포함한 TypeScript SDK도 함께 포함되었습니다. 이들은 'gpt-6-astra' Staff Architect 페르소나를 사용한 OpenAI Codex로 반복적인 적대적 아키텍처 리뷰를 수행하여, fail-closed 보안(REST 페이로드에서 서버 로컬 경로 거부, 경로 트래버설 방어, PKCS#12 키 제로화, 루프백 전용 바인딩), 리소스 거버넌스(제한된 스트리밍, 집계 쿼터, 페이지네이션 노드 예산), 그리고 자동화 엔진과 분리된 헥사고날 OperationExecutor 포트를 추가함으로써 설계를 5/10에서 8/10으로 끌어올렸습니다. 이들은 sdk/rest/pyo3 전반의 cargo test(71개 통과), Python SDK의 pytest(18-20개 테스트), TypeScript의 npm test(9개 통과), 그리고 -D warnings 하의 Clippy(경고 0개)로 검증했습니다.
How it went
모든 게이트 통과: Rust 테스트 71개, Python 테스트 약 20개(OCR 1개 스킵), TypeScript 테스트 9개, Clippy 경고 0개, 레이어 경계 검사 정상, main 대비 병합 충돌 0개. PR은 페이지 레이아웃, 북마크, 주석, 교정(redaction), AcroForms, 서명, 렌더링 전반에 걸쳐 완전한 APDFL 기능 동등성을 갖추고 스스로 '검토 준비 완료' 10/10 상태에 도달했습니다.
Worth knowing
점수와 '10/10' 상태는 독립적인 리뷰가 아니라 작성자가 AI 페르소나를 통해 자체 평가한 것입니다. OCR 테스트는 건너뛰었기 때문에 OCR 커버리지는 검증되지 않았습니다. TypeScript 네이티브 바인딩은 로컬에서 작동하는 대신 타입이 지정된 NATIVE_UNAVAILABLE 오류로 폴백됩니다. PR은 규모가 크고 Rust, Python, TypeScript에 걸쳐 있어 리뷰어들이 상당한 범위를 다뤄야 합니다. REST는 서버 로컬 파일시스템 경로를 의도적으로 거부하므로 일부 서버 측 워크플로가 제한됩니다.
Wiring
# pdfcraft 멀티 SDK 아키텍처 구현 계획
## (1) `crates/sdk`에 `OperationExecutor` 포트 정의 및 자동화 엔진 어댑팅
`crates/sdk`에 포트 트레이트를 정의합니다:
```rust
// crates/sdk/src/port.rs
pub trait OperationExecutor: Send + Sync {
type Error;
fn execute(&self, op: Operation) -> Result<OperationOutcome, Self::Error>;
}
```
자동화 엔진은 이 트레이트를 구현하는 어댑터로 감싸 SDK 코어가 엔진 구현에 직접 의존하지 않도록 합니다. 의존성 역전을 통해 이후 REST/PyO3/TypeScript 바인딩이 동일한 포트를 공유합니다.
## (2) `pdfcraft-sdk` 구축
- 타입이 지정된 `Result<T, SdkError>` 반환 규약
- C-ABI/FFI export (`#[no_mangle] extern "C"` 함수들, opaque 핸들 + 명시적 free)
- 에러 코드는 안정적인 정수/enum으로 매핑해 FFI 경계에서 손실 없이 전달
## (3) `pdfcraft-rest` 구축
SDK 위에 axum 스타일 핸들러를 얹습니다:
- **RFC 7807 problem details** — 모든 에러 응답을 `application/problem+json`으로 통일
- **Bearer 인증 미들웨어** — 토큰은 환경변수에서 로드
- **바운디드 잡 큐** — 용량 제한, 초과 시 429/503
- **파일 스필오버** — 메모리 임계 초과 시 디스크로 흘림
**바인딩 정책**: 기본 `127.0.0.1`, 비루프백 주소는 명시적 확인 플래그 없으면 기동 거부.
## (4) `sdks/python` PyO3 바인딩
- SDK를 직접 래핑하는 네이티브 경로
- 바인딩 부재 시를 위한 HTTP 클라이언트 경로 병행 제공
- Clippy 실행 시 `PYO3_PYTHON` 환경변수 설정 필요
## (5) `sdks/typescript`
- 로컬 클라이언트 제공
- 네이티브 바인딩이 없으면 `PdfCraftError('NATIVE_UNAVAILABLE', 501)` throw
## 검증
```
cargo test -p pdfcraft-sdk -p pdfcraft-rest -p pdfcraft-pyo3
PYTHONPATH=sdks/python pytest
npm --prefix sdks/typescript test
cargo clippy -- -D warnings
cargo xtask layers
```
`cargo xtask layers`로 레이어 경계(포트/어댑터 방향)가 유지되는지 확인합니다.Try it yourself
# Headless SDK & REST Service Implementation Plan
## 1. Rust Crate: `runchr-headless`
### Crate structure
```
crates/runchr-headless/
├── Cargo.toml
├── src/
│ ├── lib.rs
│ ├── ports/
│ │ ├── mod.rs
│ │ └── operation_executor.rs
│ ├── adapters/
│ │ ├── mod.rs
│ │ ├── engine.rs
│ │ └── rest/
│ │ ├── mod.rs
│ │ ├── auth.rs
│ │ ├── errors.rs
│ │ ├── jobs.rs
│ │ ├── paths.rs
│ │ └── routes.rs
│ └── ffi/
│ └── mod.rs
└── tests/
├── executor.rs
├── rest_auth.rs
├── rest_paths.rs
└── rest_jobs.rs
```
### `Cargo.toml`
```toml
[package]
name = "runchr-headless"
version = "0.1.0"
edition = "2021"
[dependencies]
runchr-core = { path = "../runchr-core" }
axum = "0.7"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["trace", "limit"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
thiserror = "1"
zeroize = { version = "1", features = ["derive"] }
tracing = "0.1"
pyo3 = { version = "0.21", features = ["extension-module"], optional = true }
[features]
default = []
python = ["pyo3"]
[dev-dependencies]
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
```
### `src/lib.rs`
```rust
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
pub mod adapters;
pub mod ffi;
pub mod ports;
pub use ports::operation_executor::{Operation, OperationExecutor, OperationResult};
```
### `src/ports/operation_executor.rs`
```rust
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Operation {
pub kind: String,
pub payload: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OperationResult {
pub output: serde_json::Value,
}
#[async_trait]
pub trait OperationExecutor: Send + Sync {
async fn execute(&self, op: Operation) -> Result<OperationResult, ExecutorError>;
}
#[derive(Debug, thiserror::Error)]
pub enum ExecutorError {
#[error("invalid operation: {0}")]
Invalid(String),
#[error("engine failure: {0}")]
Engine(String),
#[error("internal error")]
Internal,
}
```
### `src/adapters/engine.rs`
```rust
use std::panic::{catch_unwind, AssertUnwindSafe};
use async_trait::async_trait;
use crate::ports::operation_executor::{
ExecutorError, Operation, OperationExecutor, OperationResult,
};
pub struct CoreEngineExecutor {
engine: runchr_core::Engine,
}
impl CoreEngineExecutor {
pub fn new(engine: runchr_core::Engine) -> Self {
Self { engine }
}
}
#[async_trait]
impl OperationExecutor for CoreEngineExecutor {
async fn execute(&self, op: Operation) -> Result<OperationResult, ExecutorError> {
let engine = &self.engine;
let result = tokio::task::spawn_blocking(move || {
catch_unwind(AssertUnwindSafe(|| engine.run(&op)))
})
.await
.map_err(|_| ExecutorError::Internal)?;
match result {
Ok(Ok(output)) => Ok(OperationResult { output }),
Ok(Err(e)) => Err(ExecutorError::Engine(e.to_string())),
Err(_) => Err(ExecutorError::Internal),
}
}
}
```
### `src/adapters/rest/errors.rs` (RFC 7807)
```rust
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct ProblemDetails {
#[serde(rename = "type")]
pub type_uri: String,
pub title: String,
pub status: u16,
pub detail: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub instance: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error("unauthorized")]
Unauthorized,
#[error("invalid request: {0}")]
BadRequest(String),
#[error("path rejected: {0}")]
PathRejected(String),
#[error("not found")]
NotFound,
#[error("engine failure")]
Engine,
#[error("internal error")]
Internal,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, title, detail) = match &self {
ApiError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized", self.to_string()),
ApiError::BadRequest(_) => (StatusCode::BAD_REQUEST, "Bad Request", self.to_string()),
ApiError::PathRejected(_) => (StatusCode::BAD_REQUEST, "Path Rejected", self.to_string()),
ApiError::NotFound => (StatusCode::NOT_FOUND, "Not Found", self.to_string()),
ApiError::Engine => (StatusCode::UNPROCESSABLE_ENTITY, "Engine Failure", self.to_string()),
ApiError::Internal => (StatusCode::INTERNAL_SERVER_ERROR, "Internal Error", self.to_string()),
};
let body = ProblemDetails {
type_uri: format!("https://runchr.dev/errors/{}", status.as_u16()),
title: title.to_string(),
status: status.as_u16(),
detail,
instance: None,
};
(status, axum::Json(body)).into_response()
}
}
```
### `src/adapters/rest/auth.rs`
```rust
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use zeroize::Zeroize;
use super::errors::ApiError;
pub struct BearerToken(pub String);
impl Drop for BearerToken {
fn drop(&mut self) {
self.0.zeroize();
}
}
#[derive(Clone)]
pub struct AuthConfig {
pub token: String,
}
impl Drop for AuthConfig {
fn drop(&mut self) {
self.token.zeroize();
}
}
#[axum::async_trait]
impl<S> FromRequestParts<S> for BearerToken
where
S: Send + Sync,
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let header = parts
.headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or(ApiError::Unauthorized)?;
let token = header
.strip_prefix("Bearer ")
.ok_or(ApiError::Unauthorized)?;
Ok(BearerToken(token.to_string()))
}
}
pub fn verify(provided: &BearerToken, config: &AuthConfig) -> Result<(), ApiError> {
use subtle::ConstantTimeEq;
if provided.0.as_bytes().ct_eq(config.token.as_bytes()).into() {
Ok(())
} else {
Err(ApiError::Unauthorized)
}
}
```
### `src/adapters/rest/paths.rs`
```rust
use std::path::{Component, Path, PathBuf};
use super::errors::ApiError;
/// Reject any path that is absolute, contains `..`, or escapes the sandbox root.
pub fn validate_materialized_path(raw: &str, sandbox_root: &Path) -> Result<PathBuf, ApiError> {
let candidate = Path::new(raw);
if candidate.is_absolute() {
return Err(ApiError::PathRejected("absolute paths are not allowed".into()));
}
for component in candidate.components() {
match component {
Component::Normal(_) => {}
Component::CurDir => {}
Component::ParentDir => {
return Err(ApiError::PathRejected("parent traversal is not allowed".into()))
}
Component::RootDir | Component::Prefix(_) => {
return Err(ApiError::PathRejected("rooted paths are not allowed".into()))
}
}
}
let joined = sandbox_root.join(candidate);
let canonical_root = sandbox_root
.canonicalize()
.map_err(|_| ApiError::Internal)?;
let canonical_joined = joined
.canonicalize()
.map_err(|_| ApiError::PathRejected("path does not resolve".into()))?;
if !canonical_joined.starts_with(&canonical_root) {
return Err(ApiError::PathRejected("path escapes sandbox".into()));
}
Ok(canonical_joined)
}
```
### `src/adapters/rest/jobs.rs`
```rust
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{RwLock, Semaphore};
use uuid::Uuid;
use crate::ports::operation_executor::{Operation, OperationExecutor, OperationResult};
#[derive(Clone)]
pub struct JobStore {
inner: Arc<RwLock<HashMap<Uuid, JobState>>>,
semaphore: Arc<Semaphore>,
}
#[derive(Clone, Debug)]
pub enum JobState {
Pending,
Running,
Done(OperationResult),
Failed(String),
}
impl JobStore {
pub fn new(max_concurrency: usize) -> Self {
Self {
inner: Arc::new(RwLock::new(HashMap::new())),
semaphore: Arc::new(Semaphore::new(max_concurrency)),
}
}
pub async fn submit<E: OperationExecutor + 'static>(
&self,
executor: Arc<E>,
op: Operation,
) -> Uuid {
let id = Uuid::new_v4();
self.inner.write().await.insert(id, JobState::Pending);
let store = self.clone();
let permit = self.semaphore.clone();
tokio::spawn(async move {
let _permit = permit.acquire_owned().await;
store.inner.write().await.insert(id, JobState::Running);
let outcome = executor.execute(op).await;
let mut guard = store.inner.write().await;
match outcome {
Ok(result) => {
guard.insert(id, JobState::Done(result));
}
Err(e) => {
guard.insert(id, JobState::Failed(e.to_string()));
}
}
});
id
}
pub async fn get(&self, id: &Uuid) -> Option<JobState> {
self.inner.read().await.get(id).cloned()
}
}
```
### `src/adapters/rest/routes.rs`
```rust
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::auth::{verify, AuthConfig, BearerToken};
use super::errors::ApiError;
use super::jobs::{JobState, JobStore};
use super::paths::validate_materialized_path;
use crate::ports::operation_executor::{Operation, OperationExecutor};
#[derive(Clone)]
pub struct AppState<E: OperationExecutor + 'static> {
pub executor: Arc<E>,
pub jobs: JobStore,
pub auth: AuthConfig,
pub sandbox_root: PathBuf,
}
#[derive(Deserialize)]
pub struct ExecuteRequest {
pub kind: String,
pub payload: serde_json::Value,
#[serde(default)]
pub paths: Vec<String>,
}
#[derive(Serialize)]
pub struct JobAccepted {
pub job_id: Uuid,
}
#[derive(Serialize)]
pub struct JobStatus {
pub job_id: Uuid,
pub state: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
pub fn router<E: OperationExecutor + 'static>(state: AppState<E>) -> Router {
Router::new()
.route("/v1/execute", post(execute::<E>))
.route("/v1/jobs/:id", get(job_status::<E>))
.with_state(state)
}
async fn execute<E: OperationExecutor + 'static>(
State(state): State<AppState<E>>,
token: BearerToken,
Json(req): Json<ExecuteRequest>,
) -> Result<Json<JobAccepted>, ApiError> {
verify(&token, &state.auth)?;
for raw in &req.paths {
validate_materialized_path(raw, &state.sandbox_root)?;
}
let op = Operation {
kind: req.kind,
payload: req.payload,
};
let id = state.jobs.submit(state.executor.clone(), op).await;
Ok(Json(JobAccepted { job_id: id }))
}
async fn job_status<E: OperationExecutor + 'static>(
State(state): State<AppState<E>>,
token: BearerToken,
Path(id): Path<Uuid>,
) -> Result<Json<JobStatus>, ApiError> {
verify(&token, &state.auth)?;
let state = state.jobs.get(&id).await.ok_or(ApiError::NotFound)?;
let (label, output, error) = match state {
JobState::Pending => ("pending", None, None),
JobState::Running => ("running", None, None),
JobState::Done(r) => ("done", Some(r.output), None),
JobState::Failed(e) => ("failed", None, Some(e)),
};
Ok(Json(JobStatus {
job_id: id,
state: label.to_string(),
output,
error,
}))
}
/// Bind loopback-only by default; callers must opt in to external binding.
pub fn default_bind_addr(port: u16) -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port)
}
```
### `src/adapters/rest/mod.rs`
```rust
pub mod auth;
pub mod errors;
pub mod jobs;
pub mod paths;
pub mod routes;
pub use routes::{default_bind_addr, router, AppState};
```
### `src/ffi/mod.rs` (PyO3)
```rust
#[cfg(feature = "python")]
mod python {
use pyo3::exceptions::PyRuntimeError;
use pyo3::prelude::*;
#[pyfunction]
fn execute_native(kind: &str, payload_json: &str) -> PyResult<String> {
let payload: serde_json::Value = serde_json::from_str(payload_json)
.map_err(|e| PyRuntimeError::new_err(e.to_string()))?;
let op = crate::ports::operation_executor::Operation {
kind: kind.to_string(),
payload,
};
let rt = tokio::runtime::Runtime::new()
.map_err(|e| PyRuntimeError::new_err(e.to_string()))?;
let engine = runchr_core::Engine::default();
let executor = crate::adapters::engine::CoreEngineExecutor::new(engine);
use crate::ports::operation_executor::OperationExecutor;
let result = rt
.block_on(executor.execute(op))
.map_err(|e| PyRuntimeError::new_err(e.to_string()))?;
serde_json::to_string(&result.output)
.map_err(|e| PyRuntimeError::new_err(e.to_string()))
}
#[pymodule]
fn runchr_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(execute_native, m)?)?;
Ok(())
}
}
```
## 2. Python SDK (`sdk/python/runchr_sdk/`)
### `client.py`
```python
from __future__ import annotations
import json
import os
from typing import Any
try:
import runchr_native # type: ignore
_NATIVE = True
except ImportError:
_NATIVE = False
import urllib.request
class RunchrError(RuntimeError):
pass
class Client:
def __init__(self, base_url: str | None = None, token: str | None = None) -> None:
self._base_url = base_url or os.environ.get("RUNCHR_URL", "http://127.0.0.1:8080")
self._token = token or os.environ.get("RUNCHR_TOKEN", "")
def execute(self, kind: str, payload: dict[str, Any]) -> dict[str, Any]:
if _NATIVE:
raw = runchr_native.execute_native(kind, json.dumps(payload))
return json.loads(raw)
return self._execute_http(kind, payload)
def _execute_http(self, kind: str, payload: dict[str, Any]) -> dict[str, Any]:
body = json.dumps({"kind": kind, "payload": payload}).encode()
req = urllib.request.Request(
f"{self._base_url}/v1/execute",
data=body,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self._token}",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
accepted = json.loads(resp.read())
job_id = accepted["job_id"]
return self._poll(job_id)
def _poll(self, job_id: str) -> dict[str, Any]:
import time
for _ in range(120):
req = urllib.request.Request(
f"{self._base_url}/v1/jobs/{job_id}",
headers={"Authorization": f"Bearer {self._token}"},
)
with urllib.request.urlopen(req, timeout=30) as resp:
status = json.loads(resp.read())
if status["state"] == "done":
return status["output"]
if status["state"] == "failed":
raise RunchrError(status.get("error", "job failed"))
time.sleep(0.25)
raise RunchrError("job timed out")
```
### `tests/test_client.py`
```python
import json
from unittest import mock
from runchr_sdk import client as client_mod
from runchr_sdk.client import Client
def test_http_fallback(monkeypatch):
monkeypatch.setattr(client_mod, "_NATIVE", False)
c = Client(base_url="http://127.0.0.1:9", token="t")
with mock.patch.object(Client, "_execute_http", return_value={"ok": True}) as m:
assert c.execute("noop", {}) == {"ok": True}
m.assert_called_once()
def test_native_path(monkeypatch):
fake = mock.Mock()
fake.execute_native.return_value = json.dumps({"ok": 1})
monkeypatch.setattr(client_mod, "_NATIVE", True)
monkeypatch.setattr(client_mod, "runchr_native", fake, raising=False)
c = Client()
assert c.execute("noop", {"a": 1}) == {"ok": 1}
```
## 3. TypeScript SDK (`sdk/typescript/`)
### `src/index.ts`
```typescript
export class NativeUnavailableError extends Error {
readonly code = "NATIVE_UNAVAILABLE" as const;
constructor(message = "native bindings are not available") {
super(message);
this.name = "NativeUnavailableError";
}
}
export interface ClientOptions {
baseUrl?: string;
token?: string;
requireNative?: boolean;
}
type NativeModule = {
executeNative(kind: string, payloadJson: string): string;
};
function loadNative(): NativeModule | null {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require("runchr-native") as NativeModule;
} catch {
return null;
}
}
export class Client {
private readonly baseUrl: string;
private readonly token: string;
private readonly native: NativeModule | null;
private readonly requireNative: boolean;
constructor(opts: ClientOptions = {}) {
this.baseUrl = opts.baseUrl ?? process.env.RUNCHR_URL ?? "http://127.0.0.1:8080";
this.token = opts.token ?? process.env.RUNCHR_TOKEN ?? "";
this.native = loadNative();
this.requireNative = opts.requireNative ?? false;
}
async execute(kind: string, payload: Record<string, unknown>): Promise<unknown> {
if (this.native) {
return JSON.parse(this.native.executeNative(kind, JSON.stringify(payload)));
}
if (this.requireNative) {
throw new NativeUnavailableError();
}
return this.executeHttp(kind, payload);
}
private async executeHttp(kind: string, payload: Record<string, unknown>): Promise<unknown> {
const res = await fetch(`${this.baseUrl}/v1/execute`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.token}`,
},
body: JSON.stringify({ kind, payload }),
});
if (!res.ok) {
throw new Error(`execute failed: ${res.status}`);
}
const { job_id } = (await res.json()) as { job_id: string };
return this.poll(job_id);
}
private async poll(jobId: string): Promise<unknown> {
for (let i = 0; i < 120; i++) {
const res = await fetch(`${this.baseUrl}/v1/jobs/${jobId}`, {
headers: { Authorization: `Bearer ${this.token}` },
});
const status = (await res.json()) as {
state: string;
output?: unknown;
error?: string;
};
if (status.state === "done") return status.output;
if (status.state === "failed") throw new Error(status.error ?? "job failed");
await new Promise((r) => setTimeout(r, 250));
}
throw new Error("job timed out");
}
}
```
### `test/client.test.ts`
```typescript
import { describe, expect, it, vi } from "vitest";
import { Client, NativeUnavailableError } from "../src/index";
describe("Client", () => {
it("throws NATIVE_UNAVAILABLE when required and missing", async () => {
const c = new Client({ requireNative: true });
await expect(c.execute("noop", {})).rejects.toBeInstanceOf(NativeUnavailableError);
});
it("falls back to HTTP", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ job_id: "abc" }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ state: "done", output: { ok: 1 } }) });
vi.stubGlobal("fetch", fetchMock);
const c = new Client({ baseUrl: "http://x", token: "t" });
await expect(c.execute("noop", {})).resolves.toEqual({ ok: 1 });
});
});
```
## 4. Rust tests
### `tests/rest_paths.rs`
```rust
use std::path::PathBuf;
use runchr_headless::adapters::rest::paths::validate_materialized_path;
#[test]
fn rejects_absolute() {
let root = PathBuf::from("/tmp/sandbox");
assert!(validate_materialized_path("/etc/passwd", &root).is_err());
}
#[test]
fn rejects_parent_traversal() {
let root = PathBuf::from("/tmp/sandbox");
assert!(validate_materialized_path("../escape", &root).is_err());
assert!(validate_materialized_path("a/../../b", &root).is_err());
}
```
### `tests/rest_auth.rs`
```rust
use runchr_headless::adapters::rest::auth::{verify, AuthConfig, BearerToken};
#[test]
fn accepts_matching_token() {
let cfg = AuthConfig { token: "secret".into() };
let tok = BearerToken("secret".into());
assert!(verify(&tok, &cfg).is_ok());
}
#[test]
fn rejects_mismatched_token() {
let cfg = AuthConfig { token: "secret".into() };
let tok = BearerToken("wrong".into());
assert!(verify(&tok, &cfg).is_err());
}
```
### `tests/executor.rs`
```rust
use std::sync::Arc;
use runchr_headless::adapters::engine::CoreEngineExecutor;
use runchr_headless::ports::operation_executor::{Operation, OperationExecutor};
#[tokio::test]
async fn executor_returns_result() {
let engine = runchr_core::Engine::default();
let exec = Arc::new(CoreEngineExecutor::new(engine));
let op = Operation {
kind: "noop".into(),
payload: serde_json::json!({}),
};
let _ = exec.execute(op).await;
}
```
## 5. CI / Clippy
```yaml
# .github/workflows/headless.yml
name: headless
on: [push, pull_request]
jobs:
rust:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- run: cargo clippy --workspace --all-targets -- -D warnings
- run: cargo test --workspace
python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -e sdk/python pytest
- run: pytest sdk/python/tests
typescript:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20" }
- run: cd sdk/typescript && npm ci && npm test
```
## 6. Key design decisions
- **Hexagonal boundary**: `OperationExecutor` is the port; `CoreEngineExecutor` is the only adapter that touches `runchr-core`. REST and FFI both depend on the port, not the engine.
- **Panic containment**: every engine call is wrapped in `catch_unwind` inside `spawn_blocking`, so a panic in the core becomes `ExecutorError::Internal` rather than a process abort.
- **Path safety**: `validate_materialized_path` rejects absolute paths, `..`, and any canonicalized result outside the sandbox root — defense in depth against both lexical and symlink traversal.
- **Auth**: constant-time comparison via `subtle`, and `zeroize` on drop for both the config token and per-request bearer token.
- **Loopback default**: `default_bind_addr` returns `127.0.0.1`; external binding requires an explicit override.
- **Bounded concurrency**: `JobStore` uses a `Semaphore` sized at construction; jobs beyond the limit queue rather than spawn unbounded tasks.
- **Typed native error**: TypeScript throws `NativeUnavailableError` with `code: "NATIVE_UNAVAILABLE"` only when `requireNative` is set, preserving the HTTP fallback for the default path.
- **Lint posture**: `#![forbid(unsafe_code)]` plus `deny(clippy::unwrap_used, expect_used, panic)` at the crate root, enforced in CI with `-D warnings`.