History was rewritten with git filter-repo to purge owner-specific values (IPs, hostnames, SSH username, key name, personal names) from all commits, tags, and messages — including three LAN IPs and one Start9 address the v0.18.0:1 working-tree scrub had missed (one still live in HEAD at docs/AUDIO_API.md). Verified 0 hits across all refs. - AGENTS.md: Portability + Repo-wart + work-queue #2 + shipping note updated; commit-SHA references repointed to post-rewrite SHAs (367d986->8d839e3). - EVALUATION.md: P0 owner-data finding marked resolved; cleaned shorthand IP-octet fragments (/.87, /11) left by the placeholder substitution.
13 KiB
AGENTS.md
This file provides guidance to coding agents (Claude Code and others) when working with code in this repository. (Claude Code reads it via the CLAUDE.md symlink.)
Browser-based StartOS 0.4 package controlling a dual NVIDIA DGX Spark AI cluster: one-click vLLM model swaps, plus health, proxying, and APIs for speech (STT/diarization/TTS), embeddings, and redaction.
Subsystem guidance lives in docs/guides/ and loads when matching files are touched (Claude Code lazy-loads via .claude/rules/ symlinks; other agents read the guides directly): startos-package.md (build/versioning, package/**), fastapi-image.md (dev server/env/layout, image/**), redaction.md (vendoring + test gates), audio-speech.md (parakeet patches, cluster-container footguns, audio testing). Read docs/guides/audio-speech.md before touching the Sparks' containers over SSH — ops sessions don't trip the path scoping.
Stack
- Two halves, always coordinated:
image/— standalone FastAPI app (Python ≥3.11; UI on port 9999; vanilla HTML/CSS/JS).package/— StartOS 0.4 wrapper (TypeScript) that ships the Docker image as an s9pk.
- Build host needs
start-cli, Node ≥22 + npm, and Docker. - Cluster runtimes live on the Sparks, not in this repo (
spark-vllm-docker, the parakeet/kokoro/embeddings containers). This repo is the controller; it reaches them over SSH + HTTP. - Sparks are ARM64 (GB10 Grace-Blackwell, sm_121, CUDA 13). Services: vLLM
:8888(Spark 1);parakeet-asr:8000, Kokoro TTS:8880, bge-m3 embeddings + Qdrant (Spark 2). Seedocs/for API contracts.
Commands (headlines — details in the scoped rules)
(cd package && make x86) # build the s9pk; make install sideloads (restarts live service — ask first)
(cd image && uvicorn app.server:app --port 9999) # local dev — needs env vars, see fastapi-image rule
(cd image && .venv/bin/python -m app.redaction.test_gateway) # offline redaction suite 1
(cd image && .venv/bin/python app/redaction/test_scrub_leak.py) # offline redaction suite 2
./scripts/test-audio-with-speakers.sh <audio-file> # e2e audio — hits the LIVE cluster
Layout
image/app/— FastAPI app (server.pyentry, routers in sibling modules,static/dashboard UI).package/startos/— StartOS manifest, interfaces, actions, version + release notes.docs/—AUDIO_API.md,EMBEDDINGS.md,REDACTION_GATEWAY.md(consumer-facing API refs; update with API changes).README.md(overview),HANDOFF.md(fresh-user install guide),runbook.md(ops notes),known-issues.md,ROADMAP.md(longer-term backlog — items move into "Current state" below when picked up).
Conventions
- Every shipped change = version bump + release notes + rebuilt s9pk (version format
X.Y.Z:N; details in the startos-package rule). - Commit messages:
vX.Y.Z:N - short lowercase summary. Never add a Co-Authored-By / Claude attribution trailer. - The package owner is non-technical: explain infra effects in plain English and get an explicit go/no-go before mutating the cluster.
- New external-facing endpoints get documented in
docs/and noted in release notes for downstream app developers (Recap Relay, Ten31 Transcripts, CRM, Signal Engine consume these APIs). - Doc layout:
AGENTS.mdis the canonical file;CLAUDE.mdis a symlink to it (don't overwrite it). Subsystem guides are real files indocs/guides/<topic>.md(withpaths:frontmatter);.claude/rules/<topic>.mdare relative symlinks into them. A new guide = adddocs/guides/<topic>.md, symlink it from.claude/rules/, and add an index line above.
Always / Never (cluster-wide)
- Always confirm with the user before swap/stop/restart of anything on the live cluster. Read-only probes and dry-runs are fine without asking.
- Always use the Spark's IP for HTTP probes —
.localmDNS names can resolve IPv6-first and hang httpx (vLLM and friends bind IPv4 only). Never trust.localhostnames inside HTTP client code. - Always pass
SSH_KEY_PATH/-i <key>explicitly in scripted SSH; non-interactive shells have no ssh-agent identities. - Never route audio or transcripts to cloud services — speech stays on the LAN. (Scrubbed text via
/scrubis the only sanctioned path toward frontier models.) - Never commit owner-specific hostnames, IPs, usernames, or names into package strings, UI text, or docs — this package gets shared; use placeholders. Canonical set:
<spark-1-ip>/<spark-2-ip>,<spark-1-host>/<spark-2-host>,<spark-user>, and generic example names (Alice/Bob). - Never install
cuda-pythoninparakeet-asr— crashes real decode on this GPU/CUDA-13 stack; full story in the audio-speech rule.
Current state
- Working (v0.18.0:0, installed and serving): swap dashboard; chat / transcribe / diarize(+chunk) / TTS proxies; embeddings + rerank + hybrid search (Qdrant);
/scrub+/rehydrate; label-merge incl. dual-channel mode. Spark 2 audio stack is healthy (11k+ requests/12h, all 200). - In progress — Signal Engine "flakiness": diagnosed, not a server bug — transient 1–4s unresponsiveness while the single GPU is continuously busy. Remedy is client-side; a drafted message (in-flight cap 2, hard ceiling 3 global across audio endpoints, retry-with-backoff on timeout/503) is with the owner to forward to that dev.
- Decided, not implemented: remote access stays WireGuard/Tailscale split-tunnel — no public interface, so no API auth built; an empirical concurrency sweep is offered but needs the owner's explicit OK in a quiet window. Revisit (full-eval 2026-06-12): the "LAN-only, so no auth" call is now load-bearing against RCE — unquoted user input reaches the SSH shell on several endpoints, so the network boundary is the only thing preventing cluster takeover. Quoting the injection sinks (work queue) is needed regardless of the auth decision; a defense-in-depth auth/CSRF gate is the follow-on.
- Known limits:
/healthblips while the GPU is busy (mitigated client-side); dual-channel can miss a quiet local word under loud remote bleed; the connectivity log misses sub-5s outages between 5s polls; diarizer caps at 4 speakers. - Portability: working tree + full git history scrubbed 2026-06-12 — all owner-specific IPs/hostnames/usernames/names replaced with placeholders;
claude-code-starter-prompt.mddeleted (old build-time prompt). Real cluster values live only in StartOS install config, shell env vars, and the gitignoredsettings.local.json. History was rewritten withgit filter-repo(every commit SHA changed; force-pushed togitea); a token sweep across all refs now returns 0 for every owner-specific value, including three LAN IPs + one Start9 address the original working-tree scrub had missed. Backup bundle of the pre-rewrite history is at../spark-control-prehistory-rewrite.bundle. Still owner-side: the<ssh-key>SSH key was leaked by name only (not material) — rotate it if it's still authorized on the Sparks. - Repo wart: commit
8d839e3(was367d986before the 2026-06-12 history rewrite) is labeledv0.13.0:4but actually contains everything through v0.18.0:0 — per-version commits for v0.14–v0.18 are missing. Keep commit messages accurate going forward. - Hosting: repo pushes to the owner's self-hosted Gitea — remote
gitea, branchmaster, over SSH (host alias + key live in the local~/.ssh/config; no owner-specific details belong in the repo). Push there after committing. - Next (pre-eval backlog): (1) owner forwards the concurrency note to the Signal Engine dev; (2) run the concurrency sweep if the dev wants the measured knee; (3) add the
--memorycap to parakeet-asr via the Reapply-patches action; (4) pick the next item from ROADMAP.md.
Full-eval triage (2026-06-12)
Source: EVALUATION.md at repo root (full evidence, file:line pointers, scorecard). Findings triaged below; do these before the pre-eval backlog above where they overlap.
Work queue — P0/P1, fix before sharing the package wider:
[P0] Shell-quote/validate every user value crossing into SSH— DONE (code, 2026-06-12; not yet shipped). Newimage/app/shellsafe.py(validate_repo/validate_image/validate_containerwhitelists +quote_arg/quote_args). Boundary validation added toPOST /api/models(repo) andPOST /api/nim/install(image+container);shlex.quoteapplied at every SSH sink —models.build_launch_command(repo+args, coversvllm_args+knobs),download._do(repo),nim._do(image/container/volume/port/env),services.docker_state+run_action(container). Verified: injection survives only as a single quoted token, vLLM preflightshlex.splitround-trip intact, both redaction suites still pass. Side-benefit: NGC key nowshlex.quote'd innim._do(was single-quoted) — closes the quote-breakout half of the P2 NGC-key item; the process-list-exposure half remains. Ship step pending: version bump + release notes + rebuilt s9pk.[P0] Decide the git-history question— DONE (2026-06-12). Chose to rewrite.git filter-repo --replace-text --replace-messagemapped every owner-specific value to its canonical placeholder across all commits + tags + messages (incl. three LAN IPs and one Start9 address the working-tree scrub missed — the Start9 address was still live in HEAD atdocs/AUDIO_API.md). Verified 0 hits across all refs; force-pushed togitea. Pre-rewrite backup bundle kept at../spark-control-prehistory-rewrite.bundle. Owner follow-up: rotate the<ssh-key>SSH key if still authorized on the Sparks (only its name leaked, not the key).[P1] Defense-in-depth gate on mutating endpoints— DONE (code, 2026-06-12; not yet shipped).csrf_guardHTTP middleware inserver.pyrejects state-changing requests whoseOrigin/Refererhostname ≠ the served host. Scoped to control endpoints; the programmatic API surface is exempt (/v1/*,/scrub,/rehydrate,/api/search,/api/audio/,/api/health-event) so downstream consumers are unaffected. No app-layer token auth (deliberate — would break consumers + the non-technical owner). Verified via TestClient: cross-origin control POST→403, same-origin/no-Origin→pass, exempt prefixes always pass, GET never blocked. Verify on-box: confirm the StartOS reverse proxy passesHost/Originso the dashboard isn't false-positive-blocked.[P1] Validate the Qdrant— DONE (code, 2026-06-12; not yet shipped).collection_safe_collectionwhitelist ([A-Za-z0-9._-], rejects..) + URL-encoded path segment inembeddings_proxy.py. The rawfilteris left as a passthrough (Qdrant parses it; pydantic enforcesdict) — locking it to an allowlist would break hybrid-search consumers; the path segment was the real injection vector.
Shipping (all of #1/#3/#4 batched): SHIPPED 2026-06-12 — version 0.18.0:1→0.19.0:0, release notes in versions/v0_1_0.ts, s9pk rebuilt (make x86) and sideloaded to the live Start9 server (make install). Committed as 1c4e861 (its SHA after the same-day history rewrite) and force-pushed to gitea. On-box check still pending: click a control action (swap / service stop) to confirm the new CSRF guard doesn't false-positive-block the dashboard behind the StartOS proxy.
Known debt — P2, track but not blocking:
- Test coverage is redaction-only; swap state machine, proxies, SSH wrapper, and the package have zero automated tests. Live-cluster paths (swap exec, audio, embeddings/search) couldn't be exercised at all — biggest blind spot.
- Loose dependency floors permit vulnerable
python-multipart/starlette(DoS CVEs) on rebuild; no lockfile; no upload size caps (pyproject.toml:6-13). - StartOS registry blockers (only if pursuing the registry): source not public +
packageRepo/upstreamRepoareexample.complaceholders (manifest/index.ts:12-13). - Opaque HTTP 500 on
POST /api/models/PUT /knobswhenMODELS_OVERRIDESunset in dev (write to read-only/data) — catch theOSError. - NGC API key inlined single-quoted into a remote shell command (
nim.py:147) — pass via stdin/env. - Global mutable
catalogreassigned viaglobal, shared across async requests with no snapshot (server.py:107) — latent race as concurrency grows. - Container runs uvicorn as root bound to
0.0.0.0:9999(noUSERin Dockerfile) — amplifies any RCE blast radius.
Parked — P3+, do in bulk when next touching docs/packaging:
- README Status block stale (
v0.2.3 / 0.13.0:4→ v0.18.0:1, undercounts features); deprecated@app.on_event+ hardcodedapp.version="0.1.0";NimInstallBody.registershadowsBaseModel(rename →register_service); httpx class names leak into TTS/speech-models error text; one unescapedinnerHTMLsink (app.js:177) +task_idreflected in scrub JSON. - Packaging cosmetics:
marketingUrlplaceholder; brokeninstructions.mdsource link; per-service SSH users (parakeet_useretc.) absent from the Configure-Sparks action inputSpec (silent default-empty);Makefilebuilds only x86 though manifest declaresaarch64; release notes describe the scrub, not capabilities. - Hardening misc: no body/upload size limits on
/v1/audio/*,/v1/chat/completions,/scrub;int(_env(...))startup crash on badVLLM_PORT; upstream error text echoed to clients.