diff --git a/.devin/config.local.json b/.devin/config.local.json
new file mode 100644
index 000000000..816e0055e
--- /dev/null
+++ b/.devin/config.local.json
@@ -0,0 +1,8 @@
+{
+ "permissions": {
+ "allow": [
+ "Exec(git)",
+ "Exec(find)"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/.env.example b/.env.example
index e345524dc..34f05c0c9 100644
--- a/.env.example
+++ b/.env.example
@@ -21,6 +21,10 @@ LOCAL_STUDIO_API_KEY=
# Optional browser allowlist for direct controller access (comma-separated origins)
# LOCAL_STUDIO_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
+# Comma-separated hostnames permitted for controller-managed remote AI providers.
+# Defaults include api.tprime.vlans.ca, api.thalesdigital.io, and pop-os-1.tailadb2c1.ts.net.
+# LOCAL_STUDIO_PROVIDER_HOST_ALLOWLIST=api.tprime.vlans.ca,api.thalesdigital.io
+
# --- Privileged-capability opt-ins (safe defaults are OFF) ---
# Allow recipes to specify a raw launch_command/custom_command (arbitrary binary
# execution). Leave off unless you rely on custom launch commands.
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..39f678585
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+frontend/public/appliances/cortaix-factory/cortaix-tui.ans whitespace=-trailing-space
+frontend/public/appliances/cortaix-factory/cortaix-tui.txt whitespace=-trailing-space
diff --git a/.gitignore b/.gitignore
index 31b8cd896..cef4eea48 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,6 +38,7 @@ data/
node_modules/
services/node_modules
.next/
+.next-dev/
.turbo/
# IDE
@@ -97,3 +98,6 @@ frontend/dist-desktop-dev/
frontend/dist-installers/
.vercel
.env*
+# CocoIndex Code (ccc)
+/.cocoindex_code/
+/shared/node_modules
diff --git a/README.md b/README.md
index 513e82cf1..ecfd09521 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,10 @@
# Local Studio
+
+
+
+
+
Local Studio is a local-first workstation for running, managing, and using
self-hosted LLM backends. One machine can launch models, watch GPU/runtime
state, chat with OpenAI-compatible endpoints, and run agent sessions against
@@ -90,16 +95,22 @@ Start the controller (listens on `127.0.0.1:8080`, data dir + SQLite created
automatically, model weights in `LOCAL_STUDIO_MODELS_DIR`, default `/models`):
```bash
-cd controller && bun install && bun src/main.ts
+cd controller && bun install
```
Start the frontend in a second terminal, then open
:
```bash
-cd frontend && npm ci && npm run dev
+cd frontend && npm ci
+cd .. && npm run dev
```
+The root command reserves ports `3000`, `8080`, and `8081` and stops before launch
+when another stack owns any of them. Use `npm run dev:frontend` when the controller
+and agent runtime are managed separately. Development output is isolated in
+`frontend/.next-dev`, so `npm run check` cannot invalidate a running dev server.
+
`npm ci` runs a postinstall patch against `@earendil-works/pi-ai`. If that step
prints a warning, agent streaming may misrender. The setup wizard walks through
choosing a models directory, installing an engine, downloading a model,
diff --git a/controller/README.md b/controller/README.md
index 22b0580b6..1075be5ee 100644
--- a/controller/README.md
+++ b/controller/README.md
@@ -1,5 +1,10 @@
# Controller
+
+
+
+
+
`controller/` is the Bun/Hono backend for Local Studio. It exposes the HTTP API that the frontend and desktop app use to manage models, proxy inference requests, read runtime status, and inspect usage/system data.
## What It Does
diff --git a/controller/assets/notebooks/agent-collaboration-node.ipynb b/controller/assets/notebooks/agent-collaboration-node.ipynb
new file mode 100644
index 000000000..e00b7606f
--- /dev/null
+++ b/controller/assets/notebooks/agent-collaboration-node.ipynb
@@ -0,0 +1,51 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "mission",
+ "metadata": {},
+ "source": [
+ "# Node.js analysis demonstration\n",
+ "\n",
+ "A governed C2 notebook for revision-bound JavaScript analysis and bounded Node.js execution."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "analysis",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "const measurements = [18.4, 19.1, 18.9, 19.4, 18.7];\n",
+ "const mean = measurements.reduce((total, value) => total + value, 0) / measurements.length;\n",
+ "console.log(`Observed mean: ${mean.toFixed(2)} ms`);"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "decision",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "const thresholdMs = 20;\n",
+ "const status = mean <= thresholdMs ? \"within envelope\" : \"review required\";\n",
+ "console.log(`Assessment: ${status}`);"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Node.js",
+ "language": "javascript",
+ "name": "nodejs"
+ },
+ "language_info": {
+ "name": "javascript",
+ "version": "ES2022"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/controller/assets/notebooks/agent-collaboration.ipynb b/controller/assets/notebooks/agent-collaboration.ipynb
new file mode 100644
index 000000000..82aabb1f2
--- /dev/null
+++ b/controller/assets/notebooks/agent-collaboration.ipynb
@@ -0,0 +1,51 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "mission",
+ "metadata": {},
+ "source": [
+ "# Agent collaboration demonstration\n",
+ "\n",
+ "A governed C2 notebook used to demonstrate revision-bound inspection, approved edits and bounded execution."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "analysis",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "measurements = [18.4, 19.1, 18.9, 19.4, 18.7]\n",
+ "mean = sum(measurements) / len(measurements)\n",
+ "print(f\"Observed mean: {mean:.2f} ms\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "decision",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "threshold_ms = 20.0\n",
+ "status = \"within envelope\" if mean <= threshold_ms else \"review required\"\n",
+ "print(f\"Assessment: {status}\")"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python",
+ "version": "3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/controller/assets/notebooks/python-smolvm/Dockerfile b/controller/assets/notebooks/python-smolvm/Dockerfile
new file mode 100644
index 000000000..f20962b14
--- /dev/null
+++ b/controller/assets/notebooks/python-smolvm/Dockerfile
@@ -0,0 +1,6 @@
+FROM python:3.12.11-slim-bookworm@sha256:519591d6871b7bc437060736b9f7456b8731f1499a57e22e6c285135ae657bf7
+COPY requirements.lock /tmp/requirements.lock
+RUN python -m pip install --no-cache-dir --require-hashes -r /tmp/requirements.lock
+RUN useradd --create-home --uid 65532 notebook
+USER 65532:65532
+WORKDIR /workspace
diff --git a/controller/assets/notebooks/python-smolvm/requirements.in b/controller/assets/notebooks/python-smolvm/requirements.in
new file mode 100644
index 000000000..529844014
--- /dev/null
+++ b/controller/assets/notebooks/python-smolvm/requirements.in
@@ -0,0 +1,3 @@
+ipykernel==6.30.1
+nbclient==0.10.2
+nbformat==5.10.4
diff --git a/controller/assets/notebooks/python-smolvm/requirements.lock b/controller/assets/notebooks/python-smolvm/requirements.lock
new file mode 100644
index 000000000..e6af9d37e
--- /dev/null
+++ b/controller/assets/notebooks/python-smolvm/requirements.lock
@@ -0,0 +1,450 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile assets/notebooks/python-smolvm/requirements.in --generate-hashes --output-file assets/notebooks/python-smolvm/requirements.lock
+appnope==0.1.4 \
+ --hash=sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee \
+ --hash=sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c
+ # via ipykernel
+asttokens==3.0.2 \
+ --hash=sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2 \
+ --hash=sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933
+ # via stack-data
+attrs==26.1.0 \
+ --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \
+ --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32
+ # via
+ # jsonschema
+ # referencing
+comm==0.2.3 \
+ --hash=sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971 \
+ --hash=sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417
+ # via ipykernel
+debugpy==1.8.21 \
+ --hash=sha256:0042da0ecd0a8b50dc4a54395ecd870d258d73fa18776f50c91fdcabdcad2675 \
+ --hash=sha256:0fddfdc130ac6d8bfc0415b0409822fa901c8f310e5c945ac5653a0352532344 \
+ --hash=sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88 \
+ --hash=sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c \
+ --hash=sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1 \
+ --hash=sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e \
+ --hash=sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9 \
+ --hash=sha256:4e70cc8b5079f885cb43910924ee0aab73b8b6b2a14eff23afdd9895d86e79eb \
+ --hash=sha256:4e7c2d784d78ad4b71a5f8cd7b59c167719ec8a7a0211dbb3eb1bfeda78bc4e2 \
+ --hash=sha256:72b5d676c4cbfac3bac5bb01c138a4656e843f93f03ce2a5f4e394ad49fbee73 \
+ --hash=sha256:84c564d8cc701d41843b29a92814c1f1bef6798724ca9d675c284ad9f6a547d7 \
+ --hash=sha256:8eeab7b5462f683452c57c0126aaa5ec4e974ddb705f39ba87dff8818c8e08f9 \
+ --hash=sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782 \
+ --hash=sha256:9f5171176a0084b95d2ebe55a4d1f7b2a75b74c5dbec577ebd3a85c740551c36 \
+ --hash=sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e \
+ --hash=sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6 \
+ --hash=sha256:a7fe47fd23da57b9e0bec3f4a8ee65a2dc55782455ed7f2141d75ab5d2eaeef5 \
+ --hash=sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0 \
+ --hash=sha256:aa9d941d6dfe3d0407e4b3ca0b9ec466030e260fbf1174094f68785680f66db6 \
+ --hash=sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92 \
+ --hash=sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c \
+ --hash=sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176 \
+ --hash=sha256:da456226c7b4c69e35dbe35dcee6623d912000a77816db7856a41af1c72a0264 \
+ --hash=sha256:e935f9dc0501be523c8a8e1853c39432e1354e9ece717ae5998fd2371c4542c3 \
+ --hash=sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2 \
+ --hash=sha256:f15c10084f9861b5e8414a48f18f8e4aadf51a98a59e72c16aa28281ca994672 \
+ --hash=sha256:f68b891688e61bdc08b8d364d919ff0051e0b94657b39dcd027bc3173edb7cdc \
+ --hash=sha256:f843a8b08c2edeaf9b1582eed4f25441af21a297c22ff16bf76a662557aa9c9e \
+ --hash=sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8 \
+ --hash=sha256:ffd932c6796afadab6993ec96745918a8cb2444dbd392074f769db5ea40ab440
+ # via ipykernel
+decorator==5.3.1 \
+ --hash=sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82 \
+ --hash=sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c
+ # via ipython
+executing==2.2.1 \
+ --hash=sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4 \
+ --hash=sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017
+ # via stack-data
+fastjsonschema==2.22.1 \
+ --hash=sha256:0b83d1ce8d7845b959dcb20e1a5c3c8883b6541d9c52ab02cce5166b75ec805f \
+ --hash=sha256:cf377ff5c9a6f4f3125fb35f75a2c5767bd824ffbcf62c209a93cd48d1453999
+ # via nbformat
+ipykernel==6.30.1 \
+ --hash=sha256:6abb270161896402e76b91394fcdce5d1be5d45f456671e5080572f8505be39b \
+ --hash=sha256:aa6b9fb93dca949069d8b85b6c79b2518e32ac583ae9c7d37c51d119e18b3fb4
+ # via -r assets/notebooks/python-smolvm/requirements.in
+ipython==9.15.0 \
+ --hash=sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e \
+ --hash=sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756
+ # via ipykernel
+ipython-pygments-lexers==1.1.1 \
+ --hash=sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81 \
+ --hash=sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c
+ # via ipython
+jedi==0.20.0 \
+ --hash=sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67 \
+ --hash=sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011
+ # via ipython
+jsonschema==4.26.0 \
+ --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \
+ --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce
+ # via nbformat
+jsonschema-specifications==2025.9.1 \
+ --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \
+ --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d
+ # via jsonschema
+jupyter-client==8.9.1 \
+ --hash=sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81 \
+ --hash=sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa
+ # via
+ # ipykernel
+ # nbclient
+jupyter-core==5.9.1 \
+ --hash=sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508 \
+ --hash=sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407
+ # via
+ # ipykernel
+ # jupyter-client
+ # nbclient
+ # nbformat
+matplotlib-inline==0.2.2 \
+ --hash=sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6 \
+ --hash=sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79
+ # via
+ # ipykernel
+ # ipython
+nbclient==0.10.2 \
+ --hash=sha256:4ffee11e788b4a27fabeb7955547e4318a5298f34342a4bfd01f2e1faaeadc3d \
+ --hash=sha256:90b7fc6b810630db87a6d0c2250b1f0ab4cf4d3c27a299b0cde78a4ed3fd9193
+ # via -r assets/notebooks/python-smolvm/requirements.in
+nbformat==5.10.4 \
+ --hash=sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a \
+ --hash=sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b
+ # via
+ # -r assets/notebooks/python-smolvm/requirements.in
+ # nbclient
+nest-asyncio==1.6.0 \
+ --hash=sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe \
+ --hash=sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c
+ # via ipykernel
+packaging==26.2 \
+ --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
+ --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
+ # via ipykernel
+parso==0.8.7 \
+ --hash=sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c \
+ --hash=sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1
+ # via jedi
+pexpect==4.9.0 \
+ --hash=sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523 \
+ --hash=sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f
+ # via ipython
+platformdirs==4.11.0 \
+ --hash=sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0 \
+ --hash=sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74
+ # via jupyter-core
+prompt-toolkit==3.0.53 \
+ --hash=sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2 \
+ --hash=sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6
+ # via ipython
+psutil==7.2.2 \
+ --hash=sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372 \
+ --hash=sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9 \
+ --hash=sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841 \
+ --hash=sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63 \
+ --hash=sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979 \
+ --hash=sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a \
+ --hash=sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b \
+ --hash=sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9 \
+ --hash=sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee \
+ --hash=sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312 \
+ --hash=sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b \
+ --hash=sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9 \
+ --hash=sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e \
+ --hash=sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc \
+ --hash=sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1 \
+ --hash=sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf \
+ --hash=sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea \
+ --hash=sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988 \
+ --hash=sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486 \
+ --hash=sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00 \
+ --hash=sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8
+ # via
+ # ipykernel
+ # ipython
+ptyprocess==0.7.0 \
+ --hash=sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35 \
+ --hash=sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220
+ # via pexpect
+pure-eval==0.2.3 \
+ --hash=sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0 \
+ --hash=sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42
+ # via stack-data
+pygments==2.20.0 \
+ --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
+ --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
+ # via
+ # ipython
+ # ipython-pygments-lexers
+python-dateutil==2.9.0.post0 \
+ --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
+ --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
+ # via jupyter-client
+pyzmq==27.1.0 \
+ --hash=sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d \
+ --hash=sha256:01f9437501886d3a1dd4b02ef59fb8cc384fa718ce066d52f175ee49dd5b7ed8 \
+ --hash=sha256:03ff0b279b40d687691a6217c12242ee71f0fba28bf8626ff50e3ef0f4410e1e \
+ --hash=sha256:05b12f2d32112bf8c95ef2e74ec4f1d4beb01f8b5e703b38537f8849f92cb9ba \
+ --hash=sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581 \
+ --hash=sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05 \
+ --hash=sha256:08e90bb4b57603b84eab1d0ca05b3bbb10f60c1839dc471fc1c9e1507bef3386 \
+ --hash=sha256:0c996ded912812a2fcd7ab6574f4ad3edc27cb6510349431e4930d4196ade7db \
+ --hash=sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28 \
+ --hash=sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e \
+ --hash=sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea \
+ --hash=sha256:18339186c0ed0ce5835f2656cdfb32203125917711af64da64dbaa3d949e5a1b \
+ --hash=sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066 \
+ --hash=sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97 \
+ --hash=sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0 \
+ --hash=sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113 \
+ --hash=sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92 \
+ --hash=sha256:1f8426a01b1c4098a750973c37131cf585f61c7911d735f729935a0c701b68d3 \
+ --hash=sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86 \
+ --hash=sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd \
+ --hash=sha256:346e9ba4198177a07e7706050f35d733e08c1c1f8ceacd5eb6389d653579ffbc \
+ --hash=sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233 \
+ --hash=sha256:3970778e74cb7f85934d2b926b9900e92bfe597e62267d7499acc39c9c28e345 \
+ --hash=sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31 \
+ --hash=sha256:448f9cb54eb0cee4732b46584f2710c8bc178b0e5371d9e4fc8125201e413a74 \
+ --hash=sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc \
+ --hash=sha256:49d3980544447f6bd2968b6ac913ab963a49dcaa2d4a2990041f16057b04c429 \
+ --hash=sha256:4a19387a3dddcc762bfd2f570d14e2395b2c9701329b266f83dd87a2b3cbd381 \
+ --hash=sha256:4c618fbcd069e3a29dcd221739cacde52edcc681f041907867e0f5cc7e85f172 \
+ --hash=sha256:50081a4e98472ba9f5a02850014b4c9b629da6710f8f14f3b15897c666a28f1b \
+ --hash=sha256:507b6f430bdcf0ee48c0d30e734ea89ce5567fd7b8a0f0044a369c176aa44556 \
+ --hash=sha256:508e23ec9bc44c0005c4946ea013d9317ae00ac67778bd47519fdf5a0e930ff4 \
+ --hash=sha256:510869f9df36ab97f89f4cff9d002a89ac554c7ac9cadd87d444aa4cf66abd27 \
+ --hash=sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c \
+ --hash=sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd \
+ --hash=sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e \
+ --hash=sha256:677e744fee605753eac48198b15a2124016c009a11056f93807000ab11ce6526 \
+ --hash=sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e \
+ --hash=sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f \
+ --hash=sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128 \
+ --hash=sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96 \
+ --hash=sha256:722ea791aa233ac0a819fc2c475e1292c76930b31f1d828cb61073e2fe5e208f \
+ --hash=sha256:726b6a502f2e34c6d2ada5e702929586d3ac948a4dbbb7fed9854ec8c0466027 \
+ --hash=sha256:753d56fba8f70962cd8295fb3edb40b9b16deaa882dd2b5a3a2039f9ff7625aa \
+ --hash=sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f \
+ --hash=sha256:7be883ff3d722e6085ee3f4afc057a50f7f2e0c72d289fd54df5706b4e3d3a50 \
+ --hash=sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c \
+ --hash=sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2 \
+ --hash=sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146 \
+ --hash=sha256:849ca054d81aa1c175c49484afaaa5db0622092b5eccb2055f9f3bb8f703782d \
+ --hash=sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97 \
+ --hash=sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5 \
+ --hash=sha256:9541c444cfe1b1c0156c5c86ece2bb926c7079a18e7b47b0b1b3b1b875e5d098 \
+ --hash=sha256:96c71c32fff75957db6ae33cd961439f386505c6e6b377370af9b24a1ef9eafb \
+ --hash=sha256:9a916f76c2ab8d045b19f2286851a38e9ac94ea91faf65bd64735924522a8b32 \
+ --hash=sha256:9c1790386614232e1b3a40a958454bdd42c6d1811837b15ddbb052a032a43f62 \
+ --hash=sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf \
+ --hash=sha256:a1aa0ee920fb3825d6c825ae3f6c508403b905b698b6460408ebd5bb04bbb312 \
+ --hash=sha256:a5b42d7a0658b515319148875fcb782bbf118dd41c671b62dae33666c2213bda \
+ --hash=sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540 \
+ --hash=sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604 \
+ --hash=sha256:ad68808a61cbfbbae7ba26d6233f2a4aa3b221de379ce9ee468aa7a83b9c36b0 \
+ --hash=sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db \
+ --hash=sha256:b1267823d72d1e40701dcba7edc45fd17f71be1285557b7fe668887150a14b78 \
+ --hash=sha256:b2e592db3a93128daf567de9650a2f3859017b3f7a66bc4ed6e4779d6034976f \
+ --hash=sha256:b721c05d932e5ad9ff9344f708c96b9e1a485418c6618d765fca95d4daacfbef \
+ --hash=sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2 \
+ --hash=sha256:bd67e7c8f4654bef471c0b1ca6614af0b5202a790723a58b79d9584dc8022a78 \
+ --hash=sha256:bf7b38f9fd7b81cb6d9391b2946382c8237fd814075c6aa9c3b746d53076023b \
+ --hash=sha256:c0bb87227430ee3aefcc0ade2088100e528d5d3298a0a715a64f3d04c60ba02f \
+ --hash=sha256:c17e03cbc9312bee223864f1a2b13a99522e0dc9f7c5df0177cd45210ac286e6 \
+ --hash=sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39 \
+ --hash=sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f \
+ --hash=sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355 \
+ --hash=sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a \
+ --hash=sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a \
+ --hash=sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856 \
+ --hash=sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9 \
+ --hash=sha256:da96ecdcf7d3919c3be2de91a8c513c186f6762aa6cf7c01087ed74fad7f0968 \
+ --hash=sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7 \
+ --hash=sha256:dd2fec2b13137416a1c5648b7009499bcc8fea78154cd888855fa32514f3dad1 \
+ --hash=sha256:df7cd397ece96cf20a76fae705d40efbab217d217897a5053267cd88a700c266 \
+ --hash=sha256:e2687c2d230e8d8584fbea433c24382edfeda0c60627aca3446aa5e58d5d1831 \
+ --hash=sha256:e30a74a39b93e2e1591b58eb1acef4902be27c957a8720b0e368f579b82dc22f \
+ --hash=sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7 \
+ --hash=sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394 \
+ --hash=sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07 \
+ --hash=sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496 \
+ --hash=sha256:f328d01128373cb6763823b2b4e7f73bdf767834268c565151eacb3b7a392f90 \
+ --hash=sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271 \
+ --hash=sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6 \
+ --hash=sha256:ff8d114d14ac671d88c89b9224c63d6c4e5a613fe8acd5594ce53d752a3aafe9
+ # via
+ # ipykernel
+ # jupyter-client
+referencing==0.37.0 \
+ --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \
+ --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8
+ # via
+ # jsonschema
+ # jsonschema-specifications
+rpds-py==2026.6.3 \
+ --hash=sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5 \
+ --hash=sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680 \
+ --hash=sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9 \
+ --hash=sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538 \
+ --hash=sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804 \
+ --hash=sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf \
+ --hash=sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4 \
+ --hash=sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97 \
+ --hash=sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6 \
+ --hash=sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96 \
+ --hash=sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a \
+ --hash=sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187 \
+ --hash=sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975 \
+ --hash=sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f \
+ --hash=sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703 \
+ --hash=sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9 \
+ --hash=sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127 \
+ --hash=sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f \
+ --hash=sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa \
+ --hash=sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05 \
+ --hash=sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171 \
+ --hash=sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba \
+ --hash=sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c \
+ --hash=sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223 \
+ --hash=sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4 \
+ --hash=sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885 \
+ --hash=sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698 \
+ --hash=sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f \
+ --hash=sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7 \
+ --hash=sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed \
+ --hash=sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f \
+ --hash=sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf \
+ --hash=sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e \
+ --hash=sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f \
+ --hash=sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24 \
+ --hash=sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a \
+ --hash=sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41 \
+ --hash=sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc \
+ --hash=sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d \
+ --hash=sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146 \
+ --hash=sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e \
+ --hash=sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e \
+ --hash=sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4 \
+ --hash=sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12 \
+ --hash=sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7 \
+ --hash=sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261 \
+ --hash=sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6 \
+ --hash=sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5 \
+ --hash=sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93 \
+ --hash=sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7 \
+ --hash=sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda \
+ --hash=sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8 \
+ --hash=sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342 \
+ --hash=sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c \
+ --hash=sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb \
+ --hash=sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0 \
+ --hash=sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77 \
+ --hash=sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3 \
+ --hash=sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885 \
+ --hash=sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826 \
+ --hash=sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617 \
+ --hash=sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb \
+ --hash=sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577 \
+ --hash=sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80 \
+ --hash=sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e \
+ --hash=sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945 \
+ --hash=sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90 \
+ --hash=sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7 \
+ --hash=sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0 \
+ --hash=sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140 \
+ --hash=sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822 \
+ --hash=sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba \
+ --hash=sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9 \
+ --hash=sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4 \
+ --hash=sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a \
+ --hash=sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8 \
+ --hash=sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf \
+ --hash=sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4 \
+ --hash=sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324 \
+ --hash=sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53 \
+ --hash=sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b \
+ --hash=sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41 \
+ --hash=sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9 \
+ --hash=sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca \
+ --hash=sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1 \
+ --hash=sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d \
+ --hash=sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690 \
+ --hash=sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107 \
+ --hash=sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2 \
+ --hash=sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76 \
+ --hash=sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d \
+ --hash=sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af \
+ --hash=sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6 \
+ --hash=sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db \
+ --hash=sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369 \
+ --hash=sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd \
+ --hash=sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911 \
+ --hash=sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504 \
+ --hash=sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a \
+ --hash=sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9 \
+ --hash=sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13 \
+ --hash=sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc \
+ --hash=sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278 \
+ --hash=sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868 \
+ --hash=sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2 \
+ --hash=sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd \
+ --hash=sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4 \
+ --hash=sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6 \
+ --hash=sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9 \
+ --hash=sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00 \
+ --hash=sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f \
+ --hash=sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e \
+ --hash=sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442 \
+ --hash=sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da \
+ --hash=sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90 \
+ --hash=sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef
+ # via
+ # jsonschema
+ # referencing
+six==1.17.0 \
+ --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
+ --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
+ # via python-dateutil
+stack-data==0.6.3 \
+ --hash=sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9 \
+ --hash=sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695
+ # via ipython
+tornado==6.5.7 \
+ --hash=sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163 \
+ --hash=sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2 \
+ --hash=sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92 \
+ --hash=sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b \
+ --hash=sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972 \
+ --hash=sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100 \
+ --hash=sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4 \
+ --hash=sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5 \
+ --hash=sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4 \
+ --hash=sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796
+ # via
+ # ipykernel
+ # jupyter-client
+traitlets==5.15.1 \
+ --hash=sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92 \
+ --hash=sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722
+ # via
+ # ipykernel
+ # ipython
+ # jupyter-client
+ # jupyter-core
+ # matplotlib-inline
+ # nbclient
+ # nbformat
+typing-extensions==4.16.0 \
+ --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
+ --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
+ # via
+ # jupyter-client
+ # referencing
+wcwidth==0.8.2 \
+ --hash=sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda \
+ --hash=sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85
+ # via prompt-toolkit
diff --git a/controller/assets/notebooks/ray_llm_verification.py b/controller/assets/notebooks/ray_llm_verification.py
new file mode 100644
index 000000000..00006cf38
--- /dev/null
+++ b/controller/assets/notebooks/ray_llm_verification.py
@@ -0,0 +1,59 @@
+import json
+import os
+import socket
+import urllib.request
+
+BASE_URL = os.environ.get("LOCAL_STUDIO_LLM_BASE_URL", "http://172.18.7.206").rstrip("/")
+HOST_HEADER = os.environ.get("LOCAL_STUDIO_LLM_HOST", "api.tprime.vlans.ca")
+MODEL = os.environ.get("LOCAL_STUDIO_LLM_MODEL", "qwen3-next-80b-a3b-nvfp4")
+PROMPT = "Return exactly LOCAL_STUDIO_RAY_NOTEBOOK_OK"
+EXPECTED_ANSWER = "LOCAL_STUDIO_RAY_NOTEBOOK_OK"
+
+
+def ask_local_llm():
+ body = json.dumps(
+ {
+ "model": MODEL,
+ "messages": [{"role": "user", "content": PROMPT}],
+ "temperature": 0,
+ }
+ ).encode()
+ request = urllib.request.Request(
+ f"{BASE_URL}/v1/chat/completions",
+ data=body,
+ headers={"Content-Type": "application/json", "Host": HOST_HEADER},
+ )
+ with urllib.request.urlopen(request, timeout=60) as response:
+ return json.load(response)["choices"][0]["message"]["content"].strip()
+
+
+answer = ask_local_llm()
+assert answer == EXPECTED_ANSWER
+print(f"LOCAL_LLM model={MODEL} answer={answer}")
+
+if os.environ.get("RAY_ADDRESS"):
+ import ray
+
+ ray.init()
+
+ @ray.remote
+ def verify_task(index):
+ return {"task": index, "host": socket.gethostname(), "value": 2 + 2}
+
+ results = ray.get([verify_task.remote(index) for index in range(4)])
+ print(
+ "RAY_NOTEBOOK "
+ + json.dumps(
+ {
+ "answer": answer,
+ "driver": socket.gethostname(),
+ "resources": {
+ key: value
+ for key, value in ray.cluster_resources().items()
+ if key in ("CPU", "GPU")
+ },
+ "tasks": results,
+ },
+ sort_keys=True,
+ )
+ )
diff --git a/controller/bun.lock b/controller/bun.lock
index 2e143ff48..224fa5f44 100644
--- a/controller/bun.lock
+++ b/controller/bun.lock
@@ -6,6 +6,7 @@
"name": "local-studio-controller",
"dependencies": {
"@earendil-works/pi-ai": "0.80.8",
+ "@hono/node-server": "2.0.12",
"@hono/standard-validator": "0.2.3",
"@hono/swagger-ui": "0.5.3",
"@standard-community/standard-json": "0.3.5",
@@ -14,6 +15,7 @@
"effect": "4.0.0-beta.90",
"hono": "4.12.30",
"hono-openapi": "1.3.1",
+ "jose": "6.2.4",
"openapi-types": "12.1.3",
"semver": "7.8.5",
},
@@ -134,6 +136,8 @@
"@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="],
+ "@hono/node-server": ["@hono/node-server@2.0.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg=="],
+
"@hono/standard-validator": ["@hono/standard-validator@0.2.3", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "hono": ">=3.9.0" } }, "sha512-bp9vHu6Va6SfMHC3D4ZLBbT/woi+AZ9CRdTXQu3kLJuLh2W/Gb9UO4hijS+BQAGFXi4EGpXdetxpzwTAawSVeg=="],
"@hono/swagger-ui": ["@hono/swagger-ui@0.5.3", "", { "peerDependencies": { "hono": ">=4.0.0" } }, "sha512-Hn90DOOJ62ICJQplQvCDVpi9Jcn6EhtRaiffyJIS53wA5RmRLtMCDQGVc0bor8vQD7JIwpkweWjs+3cycp+IvA=="],
@@ -590,6 +594,8 @@
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
+ "jose": ["jose@6.2.4", "", {}, "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA=="],
+
"js-stringify": ["js-stringify@1.0.2", "", {}, "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
diff --git a/controller/contracts/enterprise-auth.ts b/controller/contracts/enterprise-auth.ts
new file mode 100644
index 000000000..97daa3b26
--- /dev/null
+++ b/controller/contracts/enterprise-auth.ts
@@ -0,0 +1,163 @@
+import { Schema } from "effect";
+
+export const EnterpriseRoleSchema = Schema.Literals([
+ "viewer",
+ "scientist",
+ "operator",
+ "agent_admin",
+ "platform_admin",
+]);
+export type EnterpriseRole = typeof EnterpriseRoleSchema.Type;
+
+export const ClearanceSchema = Schema.Literals(["open", "internal", "C1", "C2"]);
+export type Clearance = typeof ClearanceSchema.Type;
+
+export const EnterpriseEntitlementSchema = Schema.Literals([
+ "notebook:read",
+ "notebook:execute",
+ "ray:admit",
+ "model:invoke",
+ "agent:invoke",
+ "configuration:write",
+ "audit:read",
+]);
+export type EnterpriseEntitlement = typeof EnterpriseEntitlementSchema.Type;
+
+export const OidcIssuerConfigSchema = Schema.Struct({
+ id: Schema.String,
+ kind: Schema.Literals(["entra", "keycloak"]),
+ issuer: Schema.String,
+ client_id: Schema.String,
+ audience: Schema.String,
+ id_token_signing_algorithm: Schema.optional(Schema.Literals(["RS256", "PS256", "ES256"])),
+ scopes: Schema.Array(Schema.String),
+ tenant: Schema.optional(Schema.String),
+ realm: Schema.optional(Schema.String),
+ logout_endpoint: Schema.optional(Schema.String),
+ backchannel_logout: Schema.optional(
+ Schema.Struct({
+ enabled: Schema.Boolean,
+ session_required: Schema.Boolean,
+ }),
+ ),
+ role_claim: Schema.String,
+ group_claim: Schema.String,
+ role_mappings: Schema.Record(Schema.String, Schema.Array(EnterpriseRoleSchema)),
+ clearance_mappings: Schema.Record(Schema.String, ClearanceSchema),
+});
+export type OidcIssuerConfig = typeof OidcIssuerConfigSchema.Type;
+
+export const EnterpriseAuthConfigSchema = Schema.Struct({
+ mode: Schema.Literals(["local", "optional_oidc", "required_oidc"]),
+ issuers: Schema.Array(OidcIssuerConfigSchema),
+ session_idle_seconds: Schema.Number,
+ session_absolute_seconds: Schema.Number,
+});
+export type EnterpriseAuthConfig = typeof EnterpriseAuthConfigSchema.Type;
+
+export const NormalizedPrincipalSchema = Schema.Struct({
+ subject: Schema.String,
+ issuer: Schema.String,
+ issuer_id: Schema.String,
+ tenant: Schema.String,
+ display_name: Schema.String,
+ email: Schema.optional(Schema.String),
+ roles: Schema.Array(EnterpriseRoleSchema),
+ entitlements: Schema.Array(EnterpriseEntitlementSchema),
+ clearance: ClearanceSchema,
+ issued_at: Schema.Number,
+ expires_at: Schema.Number,
+});
+export type NormalizedPrincipal = typeof NormalizedPrincipalSchema.Type;
+
+export const EnterprisePrincipalScopeSchema = Schema.Struct({
+ subject: Schema.String,
+ issuer: Schema.String,
+ issuer_id: Schema.String,
+ tenant: Schema.String,
+ clearance: ClearanceSchema,
+});
+export type EnterprisePrincipalScope = typeof EnterprisePrincipalScopeSchema.Type;
+
+export const ProviderAuthenticationSchema = Schema.Union([
+ Schema.Struct({
+ type: Schema.Literal("none"),
+ }),
+ Schema.Struct({
+ type: Schema.Literal("api_key"),
+ secret_ref: Schema.optional(Schema.String),
+ }),
+ Schema.Struct({
+ type: Schema.Literal("oidc_user"),
+ issuer_id: Schema.String,
+ audience: Schema.String,
+ scopes: Schema.Array(Schema.String),
+ token_exchange: Schema.optional(
+ Schema.Struct({
+ mode: Schema.Literals(["rfc8693", "entra_obo"]),
+ token_endpoint: Schema.String,
+ client_id: Schema.String,
+ client_secret_ref: Schema.optional(Schema.String),
+ }),
+ ),
+ }),
+ Schema.Struct({
+ type: Schema.Literal("managed_identity"),
+ resource: Schema.String,
+ }),
+ Schema.Struct({
+ type: Schema.Literal("apim_gateway"),
+ issuer_id: Schema.String,
+ audience: Schema.String,
+ scopes: Schema.Array(Schema.String),
+ token_exchange: Schema.optional(
+ Schema.Struct({
+ mode: Schema.Literals(["rfc8693", "entra_obo"]),
+ token_endpoint: Schema.String,
+ client_id: Schema.String,
+ client_secret_ref: Schema.optional(Schema.String),
+ }),
+ ),
+ }),
+ Schema.Struct({
+ type: Schema.Literal("apim_client"),
+ issuer_id: Schema.String,
+ audience: Schema.String,
+ scopes: Schema.Array(Schema.String),
+ token_endpoint: Schema.String,
+ client_id: Schema.String,
+ client_secret_ref: Schema.optional(Schema.String),
+ }),
+]);
+export type ProviderAuthentication = typeof ProviderAuthenticationSchema.Type;
+
+export const FoundryProjectConnectionSchema = Schema.Struct({
+ provider_id: Schema.String,
+ gateway_url: Schema.String,
+ project_endpoint: Schema.String,
+ project_name: Schema.String,
+ allowed_models: Schema.Array(Schema.String),
+ allowed_agents: Schema.Array(Schema.String),
+ authentication: ProviderAuthenticationSchema,
+});
+export type FoundryProjectConnection = typeof FoundryProjectConnectionSchema.Type;
+
+const ROLE_ENTITLEMENTS: Record = {
+ viewer: ["notebook:read"],
+ scientist: ["notebook:read", "notebook:execute", "ray:admit", "model:invoke", "agent:invoke"],
+ operator: ["notebook:read", "notebook:execute", "model:invoke", "audit:read"],
+ agent_admin: ["notebook:read", "model:invoke", "agent:invoke", "configuration:write"],
+ platform_admin: [
+ "notebook:read",
+ "notebook:execute",
+ "ray:admit",
+ "model:invoke",
+ "agent:invoke",
+ "configuration:write",
+ "audit:read",
+ ],
+};
+
+export const entitlementsForRoles = (roles: readonly EnterpriseRole[]): EnterpriseEntitlement[] => [
+ ...new Set(roles.flatMap((role) => ROLE_ENTITLEMENTS[role])),
+];
diff --git a/controller/contracts/enterprise-authorization.ts b/controller/contracts/enterprise-authorization.ts
new file mode 100644
index 000000000..d218ec65a
--- /dev/null
+++ b/controller/contracts/enterprise-authorization.ts
@@ -0,0 +1,109 @@
+import type {
+ Clearance,
+ EnterpriseEntitlement,
+ EnterpriseRole,
+ NormalizedPrincipal,
+} from "./enterprise-auth";
+
+export type EnterpriseOperationPolicy = {
+ entitlement: EnterpriseEntitlement;
+ clearance?: Clearance;
+ role?: EnterpriseRole;
+};
+
+const CONFIGURATION_PREFIXES = [
+ "/api/agent/access-fabric",
+ "/api/agent/accounts",
+ "/api/agent/connectors",
+ "/api/agent/lifecycle",
+ "/api/agent/onboarding",
+ "/api/agent/plugins",
+ "/api/agent/providers",
+ "/api/agent/provisioning",
+ "/api/agent/setup-checks",
+ "/api/provisioning",
+ "/api/bootstrap",
+ "/api/local-agents",
+ "/api/settings",
+ "/api/setup",
+] as const;
+
+const C2_CONFIGURATION_PREFIXES = [
+ "/api/agent/access-fabric",
+ "/api/agent/lifecycle",
+ "/api/agent/onboarding",
+ "/api/agent/provisioning",
+ "/api/provisioning",
+] as const;
+
+export const enterpriseOperationPolicy = (
+ method: string,
+ path: string,
+): EnterpriseOperationPolicy | null => {
+ const pathname = path.split("?")[0] ?? path;
+ const controllerPath = pathname.startsWith("/api/proxy/")
+ ? pathname.slice("/api/proxy".length)
+ : pathname;
+ if (
+ pathname === "/health" ||
+ pathname === "/api/health" ||
+ pathname === "/api/desktop-health" ||
+ pathname.startsWith("/api/auth/")
+ ) {
+ return null;
+ }
+ if (C2_CONFIGURATION_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
+ return { entitlement: "configuration:write", clearance: "C2" };
+ }
+ if (CONFIGURATION_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
+ return { entitlement: "configuration:write" };
+ }
+ if (pathname.startsWith("/api/agent/models")) {
+ return { entitlement: "model:invoke" };
+ }
+ if (pathname.startsWith("/api/agent/") || pathname.startsWith("/api/litter-bridge/")) {
+ return { entitlement: "agent:invoke" };
+ }
+ if (pathname.startsWith("/api/huggingface/")) {
+ return { entitlement: "model:invoke" };
+ }
+ if (controllerPath.startsWith("/workbench/")) {
+ if (controllerPath.includes("/ray-jobs") || controllerPath.includes("/compute-leases")) {
+ return { entitlement: "ray:admit", clearance: "C2", role: "scientist" };
+ }
+ return {
+ entitlement: method.toUpperCase() === "GET" ? "notebook:read" : "notebook:execute",
+ };
+ }
+ if (
+ controllerPath.startsWith("/environment/") ||
+ controllerPath.startsWith("/studio/providers")
+ ) {
+ return { entitlement: "configuration:write" };
+ }
+ if (controllerPath.startsWith("/ai/v1/agents")) {
+ return { entitlement: "agent:invoke" };
+ }
+ if (
+ controllerPath.startsWith("/ai/v1/") ||
+ controllerPath.startsWith("/v1/chat/") ||
+ controllerPath.startsWith("/v1/completions") ||
+ controllerPath.startsWith("/v1/responses")
+ ) {
+ return { entitlement: "model:invoke" };
+ }
+ if (pathname.startsWith("/api/proxy/") && method.toUpperCase() !== "GET") {
+ return { entitlement: "configuration:write" };
+ }
+ return null;
+};
+
+export const enterpriseOperationDenial = (
+ principal: NormalizedPrincipal,
+ policy: EnterpriseOperationPolicy,
+): "entitlement" | "clearance" | "role" | null => {
+ if (!principal.entitlements.includes(policy.entitlement)) return "entitlement";
+ if (policy.clearance && principal.clearance !== policy.clearance) return "clearance";
+ if (policy.role && !principal.roles.includes(policy.role)) return "role";
+ return null;
+};
diff --git a/controller/contracts/environment-commissioning.ts b/controller/contracts/environment-commissioning.ts
new file mode 100644
index 000000000..94215d15a
--- /dev/null
+++ b/controller/contracts/environment-commissioning.ts
@@ -0,0 +1,24 @@
+import { Schema } from "effect";
+
+export const KubernetesConnectionConfigSchema = Schema.Struct({
+ enabled: Schema.Boolean,
+ api_url: Schema.String,
+ token_file: Schema.String,
+ ca_file: Schema.NullOr(Schema.String),
+});
+export type KubernetesConnectionConfig = typeof KubernetesConnectionConfigSchema.Type;
+
+export const KubernetesConnectionProbeSchema = Schema.Struct({
+ state: Schema.Literals(["unconfigured", "claimed", "observed", "contradicted"]),
+ checked_at: Schema.NullOr(Schema.String),
+ kubernetes_version: Schema.NullOr(Schema.String),
+ ray_api_version: Schema.NullOr(Schema.String),
+ detail: Schema.String,
+});
+export type KubernetesConnectionProbe = typeof KubernetesConnectionProbeSchema.Type;
+
+export const KubernetesConnectionStateSchema = Schema.Struct({
+ configuration: KubernetesConnectionConfigSchema,
+ probe: KubernetesConnectionProbeSchema,
+});
+export type KubernetesConnectionState = typeof KubernetesConnectionStateSchema.Type;
diff --git a/controller/contracts/experiment-tracking.ts b/controller/contracts/experiment-tracking.ts
new file mode 100644
index 000000000..04597b612
--- /dev/null
+++ b/controller/contracts/experiment-tracking.ts
@@ -0,0 +1,47 @@
+import { Schema } from "effect";
+
+export const ExperimentArtifactSchema = Schema.Struct({
+ name: Schema.String,
+ path: Schema.optional(Schema.String),
+ digest: Schema.optional(Schema.String),
+ size_bytes: Schema.optional(Schema.Number),
+ kind: Schema.Literals(["model", "data", "plot", "report", "log", "other"]),
+});
+
+export const ExperimentRecordSchema = Schema.Struct({
+ id: Schema.String,
+ project_id: Schema.String,
+ name: Schema.String,
+ parameters: Schema.Record(Schema.String, Schema.Unknown),
+ metrics: Schema.Record(Schema.String, Schema.Unknown),
+ notes: Schema.optional(Schema.String),
+ artifacts: Schema.Array(ExperimentArtifactSchema),
+ parent_experiment_id: Schema.optional(Schema.String),
+ status: Schema.Literals(["running", "succeeded", "failed", "cancelled"]),
+ created_at: Schema.String,
+ updated_at: Schema.String,
+ completed_at: Schema.optional(Schema.String),
+});
+
+export const ExperimentRecordCreateSchema = Schema.Struct({
+ project_id: Schema.String,
+ name: Schema.String,
+ parameters: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
+ notes: Schema.optional(Schema.String),
+ parent_experiment_id: Schema.optional(Schema.String),
+});
+
+export const ExperimentRecordUpdateSchema = Schema.Struct({
+ name: Schema.optional(Schema.String),
+ parameters: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
+ metrics: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
+ notes: Schema.optional(Schema.String),
+ artifacts: Schema.optional(Schema.Array(ExperimentArtifactSchema)),
+ status: Schema.optional(Schema.Literals(["running", "succeeded", "failed", "cancelled"])),
+ completed_at: Schema.optional(Schema.String),
+});
+
+export type ExperimentRecord = typeof ExperimentRecordSchema.Type;
+export type ExperimentRecordCreate = typeof ExperimentRecordCreateSchema.Type;
+export type ExperimentRecordUpdate = typeof ExperimentRecordUpdateSchema.Type;
+export type ExperimentArtifact = typeof ExperimentArtifactSchema.Type;
diff --git a/controller/contracts/foundry.ts b/controller/contracts/foundry.ts
new file mode 100644
index 000000000..333615970
--- /dev/null
+++ b/controller/contracts/foundry.ts
@@ -0,0 +1,41 @@
+import { Schema } from "effect";
+
+export const FoundryCatalogItemSchema = Schema.Struct({
+ id: Schema.String,
+ name: Schema.optional(Schema.String),
+ object: Schema.optional(Schema.String),
+});
+export type FoundryCatalogItem = typeof FoundryCatalogItemSchema.Type;
+
+export const FoundryCatalogSchema = Schema.Struct({
+ data: Schema.Array(FoundryCatalogItemSchema),
+});
+
+export const FoundryCatalogViewSchema = Schema.Struct({
+ object: Schema.Literal("list"),
+ data: Schema.Array(FoundryCatalogItemSchema),
+ provider_id: Schema.String,
+ correlation_id: Schema.String,
+ observed_at: Schema.String,
+});
+export type FoundryCatalogView = typeof FoundryCatalogViewSchema.Type;
+
+export const FoundryUsageSchema = Schema.Struct({
+ input_tokens: Schema.optional(Schema.Number),
+ output_tokens: Schema.optional(Schema.Number),
+ total_tokens: Schema.optional(Schema.Number),
+});
+export type FoundryUsage = typeof FoundryUsageSchema.Type;
+
+export const FoundryHealthSchema = Schema.Struct({
+ configured: Schema.Boolean,
+ required: Schema.Boolean,
+ state: Schema.Literals(["claimed", "observed", "contradicted"]),
+ detail: Schema.String,
+ provider_id: Schema.optional(Schema.String),
+ correlation_ids: Schema.Array(Schema.String),
+ checked_at: Schema.optional(Schema.String),
+ model_count: Schema.Number,
+ agent_count: Schema.Number,
+});
+export type FoundryHealth = typeof FoundryHealthSchema.Type;
diff --git a/controller/contracts/machine-enrollment.ts b/controller/contracts/machine-enrollment.ts
new file mode 100644
index 000000000..3c08907fd
--- /dev/null
+++ b/controller/contracts/machine-enrollment.ts
@@ -0,0 +1,107 @@
+import { Schema } from "effect";
+
+export const MachineLifecycleStates = [
+ "draft",
+ "probed",
+ "admitted",
+ "configured",
+ "active",
+ "draining",
+ "revoked",
+ "failed",
+] as const;
+
+export const MachineLifecycleStateSchema = Schema.Literals(MachineLifecycleStates);
+export type MachineLifecycleState = typeof MachineLifecycleStateSchema.Type;
+
+export const MachineLocalitySchema = Schema.Literals(["local", "remote"] as const);
+export type MachineLocality = typeof MachineLocalitySchema.Type;
+
+export const MachineAccessKindSchema = Schema.Literals([
+ "ssh",
+ "netbird",
+ "boundary",
+] as const);
+export type MachineAccessKind = typeof MachineAccessKindSchema.Type;
+
+export const MachineReferenceSchema = Schema.Struct({
+ id: Schema.String,
+});
+export type MachineReference = typeof MachineReferenceSchema.Type;
+
+export const MachineAccessReferenceSchema = Schema.Struct({
+ id: Schema.String,
+ kind: MachineAccessKindSchema,
+ endpoint: Schema.String,
+ credential_ref: Schema.optional(Schema.String),
+});
+export type MachineAccessReference = typeof MachineAccessReferenceSchema.Type;
+
+export const MachineEnrollmentProfileSchema = Schema.Struct({
+ machine_id: Schema.String,
+ display_name: Schema.String,
+ locality: MachineLocalitySchema,
+ appliance_id: Schema.String,
+ classification: Schema.Literal("C2"),
+ rig_id: Schema.optional(Schema.String),
+ rig_node_id: Schema.optional(Schema.String),
+ runtime_refs: Schema.Array(MachineReferenceSchema),
+ access_refs: Schema.Array(MachineAccessReferenceSchema),
+ agent_refs: Schema.Array(MachineReferenceSchema),
+});
+export type MachineEnrollmentProfile = typeof MachineEnrollmentProfileSchema.Type;
+
+export const MachineOwnedResourceSchema = Schema.Struct({
+ resource_id: Schema.String,
+ kind: Schema.String,
+ external_ref: Schema.String,
+ ownership: Schema.Literal("local-studio"),
+ apply_action: Schema.Literals(["create", "update"] as const),
+ rollback_action: Schema.Literals(["remove", "restore"] as const),
+ previous_digest: Schema.optional(Schema.String),
+});
+export type MachineOwnedResource = typeof MachineOwnedResourceSchema.Type;
+
+export const MachineRollbackEntrySchema = Schema.Struct({
+ resource_id: Schema.String,
+ status: Schema.Literals(["pending", "rolled_back", "failed"] as const),
+ attempted_at: Schema.optional(Schema.String),
+});
+export type MachineRollbackEntry = typeof MachineRollbackEntrySchema.Type;
+
+export const MachineLifecycleEventSchema = Schema.Struct({
+ from: MachineLifecycleStateSchema,
+ to: MachineLifecycleStateSchema,
+ at: Schema.String,
+ reason: Schema.String,
+});
+export type MachineLifecycleEvent = typeof MachineLifecycleEventSchema.Type;
+
+export const MachineEnrollmentReceiptSchema = Schema.Struct({
+ receipt_id: Schema.String,
+ machine_id: Schema.String,
+ plan_digest: Schema.String,
+ applied_at: Schema.String,
+ classification: Schema.Literal("C2"),
+ owned_resources: Schema.Array(MachineOwnedResourceSchema),
+ rollback_journal: Schema.Array(MachineRollbackEntrySchema),
+});
+export type MachineEnrollmentReceipt = typeof MachineEnrollmentReceiptSchema.Type;
+
+export const MachineEnrollmentRecordSchema = Schema.Struct({
+ profile: MachineEnrollmentProfileSchema,
+ state: MachineLifecycleStateSchema,
+ plan_digest: Schema.String,
+ created_at: Schema.String,
+ updated_at: Schema.String,
+ events: Schema.Array(MachineLifecycleEventSchema),
+ receipt: Schema.NullOr(MachineEnrollmentReceiptSchema),
+ recovery_required: Schema.Boolean,
+});
+export type MachineEnrollmentRecord = typeof MachineEnrollmentRecordSchema.Type;
+
+export const MachineEnrollmentFileSchema = Schema.Struct({
+ version: Schema.Literal(1),
+ machines: Schema.Array(MachineEnrollmentRecordSchema),
+});
+export type MachineEnrollmentFile = typeof MachineEnrollmentFileSchema.Type;
diff --git a/controller/contracts/notebook-agent.ts b/controller/contracts/notebook-agent.ts
new file mode 100644
index 000000000..901703a30
--- /dev/null
+++ b/controller/contracts/notebook-agent.ts
@@ -0,0 +1,85 @@
+import { Schema } from "effect";
+
+export const NotebookCellOutputSchema = Schema.Struct({
+ type: Schema.Literals(["stream", "display_data", "execute_result", "error"]),
+ text: Schema.String,
+});
+
+export const NotebookCellSchema = Schema.Struct({
+ index: Schema.Number,
+ cell_type: Schema.Literals(["code", "markdown", "raw"]),
+ source: Schema.String,
+ execution_count: Schema.NullOr(Schema.Number),
+ outputs: Schema.Array(NotebookCellOutputSchema),
+});
+
+export const NotebookDocumentSchema = Schema.Struct({
+ path: Schema.String,
+ revision: Schema.String,
+ runtime: Schema.Literals(["jupyter", "python", "node"]),
+ kernel_name: Schema.String,
+ cells: Schema.Array(NotebookCellSchema),
+});
+
+export const NotebookCellPatchSchema = Schema.Struct({
+ expected_revision: Schema.String,
+ cell_index: Schema.Number,
+ source: Schema.String,
+ approval_id: Schema.String,
+});
+
+export const NotebookCellExecuteSchema = Schema.Struct({
+ expected_revision: Schema.String,
+ cell_index: Schema.Number,
+ approval_id: Schema.String,
+ timeout_seconds: Schema.optional(Schema.Number),
+});
+
+export const NotebookCellStructureSchema = Schema.Struct({
+ expected_revision: Schema.String,
+ operation: Schema.Literals(["insert", "delete", "move"]),
+ cell_index: Schema.Number,
+ cell_type: Schema.optional(Schema.Literals(["code", "markdown", "raw"])),
+ direction: Schema.optional(Schema.Literals(["up", "down"])),
+ approval_id: Schema.String,
+});
+
+export const NotebookApprovalRequestSchema = Schema.Struct({
+ actor_id: Schema.optional(Schema.String),
+ project_id: Schema.optional(Schema.String),
+ expected_revision: Schema.String,
+ operation: Schema.Literals(["patch", "execute", "structure"]),
+ cell_index: Schema.Number,
+});
+
+export const NotebookApprovalSchema = Schema.Struct({
+ id: Schema.String,
+ actor_id: Schema.String,
+ project_id: Schema.String,
+ notebook_id: Schema.String,
+ expected_revision: Schema.String,
+ operation: Schema.Literals(["patch", "execute", "structure"]),
+ cell_index: Schema.Number,
+ expires_at: Schema.String,
+});
+
+export const NotebookInteractionEventSchema = Schema.Struct({
+ id: Schema.String,
+ notebook_id: Schema.String,
+ project_id: Schema.String,
+ actor_id: Schema.String,
+ operation: Schema.Literals(["inspect", "patch", "execute", "structure"]),
+ revision_before: Schema.String,
+ revision_after: Schema.String,
+ cell_index: Schema.NullOr(Schema.Number),
+ approval_id: Schema.NullOr(Schema.String),
+ occurred_at: Schema.String,
+});
+
+export type NotebookDocument = Schema.Schema.Type;
+export type NotebookCellPatch = Schema.Schema.Type;
+export type NotebookCellExecute = Schema.Schema.Type;
+export type NotebookCellStructure = Schema.Schema.Type;
+export type NotebookApprovalRequest = Schema.Schema.Type;
+export type NotebookApproval = Schema.Schema.Type;
+export type NotebookInteractionEvent = Schema.Schema.Type;
diff --git a/controller/contracts/project-templates.ts b/controller/contracts/project-templates.ts
new file mode 100644
index 000000000..cd5f90542
--- /dev/null
+++ b/controller/contracts/project-templates.ts
@@ -0,0 +1,353 @@
+import { Schema } from "effect";
+import {
+ ScientistComputePreferenceSchema,
+ ScientistDataTypeSchema,
+ ScientistGoalSchema,
+} from "./scientist-profile";
+
+export const ProjectTemplateIdSchema = Schema.Literals([
+ "literature-review",
+ "data-analysis",
+ "experiment-pipeline",
+ "blank",
+]);
+
+export const ProjectTemplateCellSchema = Schema.Struct({
+ cell_type: Schema.Literals(["code", "markdown"]),
+ source: Schema.String,
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
+});
+
+export const ProjectTemplateSchema = Schema.Struct({
+ id: ProjectTemplateIdSchema,
+ name: Schema.String,
+ description: Schema.String,
+ icon: Schema.String,
+ recommended_goals: Schema.Array(ScientistGoalSchema),
+ recommended_data_types: Schema.Array(ScientistDataTypeSchema),
+ compute_preference: ScientistComputePreferenceSchema,
+ notebook_cells: Schema.Array(ProjectTemplateCellSchema),
+ agent_prompt: Schema.String,
+ agent_skills: Schema.optional(Schema.Array(Schema.String)),
+});
+
+export type ProjectTemplate = typeof ProjectTemplateSchema.Type;
+export type ProjectTemplateCell = typeof ProjectTemplateCellSchema.Type;
+export type ProjectTemplateId = typeof ProjectTemplateIdSchema.Type;
+
+export const PROJECT_TEMPLATES: ProjectTemplate[] = [
+ {
+ id: "literature-review",
+ name: "Literature Review",
+ description: "Search, summarize, and synthesize scientific papers on a topic",
+ icon: "book-open",
+ recommended_goals: ["literature_review", "report_writing"],
+ recommended_data_types: ["text"],
+ compute_preference: "local-smolvm",
+ notebook_cells: [
+ {
+ cell_type: "markdown",
+ source: [
+ "# Literature Review\n",
+ "This notebook helps you search, summarize, and synthesize scientific literature.\n",
+ "Use the agent chat to search for papers, ask questions about findings, and generate summaries.",
+ ].join(""),
+ },
+ {
+ cell_type: "code",
+ source: [
+ "# Import libraries for literature analysis\n",
+ "import json\n",
+ "from pathlib import Path\n",
+ "\n",
+ "# Create a results directory for your literature search\n",
+ "results_dir = Path(\"literature_results\")\n",
+ "results_dir.mkdir(exist_ok=True)\n",
+ "print(f\"Results will be saved to: {results_dir.resolve()}\")",
+ ].join(""),
+ },
+ {
+ cell_type: "markdown",
+ source: [
+ "## Search Strategy\n",
+ "Describe your research question below, then ask the agent to search for relevant papers.\n",
+ "The agent can use web search to find papers and summarize key findings.",
+ ].join(""),
+ },
+ {
+ cell_type: "code",
+ source: [
+ "# Define your research question\n",
+ "research_question = \"\"\"\n",
+ "What are the effects of temperature on crop yield in temperate climates?\n",
+ "\"\"\"\n",
+ "print(f\"Research question: {research_question.strip()}\")\n",
+ "\n",
+ "# Ask the agent to search for papers on this topic\n",
+ "# Use the chat panel and say: \"Search for papers about: \"",
+ ].join(""),
+ },
+ {
+ cell_type: "markdown",
+ source: [
+ "## Synthesis\n",
+ "After collecting papers, use this section to synthesize findings.\n",
+ "Ask the agent: \"Summarize the key findings from the papers I found.\"",
+ ].join(""),
+ },
+ ],
+ agent_prompt: [
+ "You are a research assistant helping with a literature review.",
+ "The scientist's research question is defined in the notebook.",
+ "Use web search (crw_search) to find relevant papers and articles.",
+ "Summarize key findings, identify gaps in the literature, and suggest new directions.",
+ "When the scientist asks to save results, write them to the literature_results directory.",
+ ].join(" "),
+ agent_skills: [],
+ },
+ {
+ id: "data-analysis",
+ name: "Data Analysis",
+ description: "Load, explore, visualize, and analyze your experimental data",
+ icon: "chart-bar",
+ recommended_goals: ["data_analysis", "visualization", "hypothesis_testing"],
+ recommended_data_types: ["tabular", "time_series", "sensor", "spatial"],
+ compute_preference: "local-smolvm",
+ notebook_cells: [
+ {
+ cell_type: "markdown",
+ source: [
+ "# Data Analysis\n",
+ "This notebook guides you through loading, exploring, and analyzing your data.\n",
+ "Ask the agent for help at any step — it can write code, explain results, and suggest next steps.",
+ ].join(""),
+ },
+ {
+ cell_type: "code",
+ source: [
+ "# Import essential libraries\n",
+ "import json\n",
+ "from pathlib import Path\n",
+ "\n",
+ "# Point this to your data file\n",
+ "# The agent can help you find the right path or load data from other formats\n",
+ "data_path = Path(\"data\")\n",
+ "data_path.mkdir(exist_ok=True)\n",
+ "print(f\"Data directory: {data_path.resolve()}\")\n",
+ "print(\"Place your data files in this directory, or ask the agent to help load them.\")",
+ ].join(""),
+ },
+ {
+ cell_type: "markdown",
+ source: [
+ "## Explore Your Data\n",
+ "Ask the agent: \"Load my data from data/ and show me a summary.\"\n",
+ "The agent will detect the file format and generate the right loading code.",
+ ].join(""),
+ },
+ {
+ cell_type: "code",
+ source: [
+ "# Quick data summary template\n",
+ "# The agent can fill this in based on your actual data\n",
+ "def summarize_data(df):\n",
+ " \"\"\"Print a quick summary of a data table.\"\"\"\n",
+ " print(f\"Shape: {df.shape}\")\n",
+ " print(f\"Columns: {list(df.columns)}\")\n",
+ " print(f\"\\nFirst rows:\")\n",
+ " print(df.head())\n",
+ " print(f\"\\nData types:\")\n",
+ " print(df.dtypes)\n",
+ " return df.describe()\n",
+ "\n",
+ "print(\"Ask the agent to load your data and run summarize_data() on it.\")",
+ ].join(""),
+ },
+ {
+ cell_type: "markdown",
+ source: [
+ "## Visualization\n",
+ "Ask the agent: \"Create a visualization of my data.\"\n",
+ "The agent will suggest appropriate plot types based on your data shape.",
+ ].join(""),
+ },
+ {
+ cell_type: "code",
+ source: [
+ "# Visualization helper\n",
+ "def quick_plot(df, x=None, y=None, kind=\"auto\"):\n",
+ " \"\"\"Create a quick plot of the data.\"\"\"\n",
+ " import matplotlib\n",
+ " matplotlib.use(\"Agg\") # Use non-interactive backend\n",
+ " import matplotlib.pyplot as plt\n",
+ " \n",
+ " if kind == \"auto\":\n",
+ " if x and y:\n",
+ " kind = \"scatter\" if df[x].dtype != \"object\" else \"bar\"\n",
+ " else:\n",
+ " kind = \"hist\"\n",
+ " \n",
+ " fig, ax = plt.subplots(figsize=(10, 6))\n",
+ " if kind == \"scatter\" and x and y:\n",
+ " ax.scatter(df[x], df[y])\n",
+ " ax.set_xlabel(x)\n",
+ " ax.set_ylabel(y)\n",
+ " elif kind == \"hist\":\n",
+ " df.hist(ax=ax, bins=30)\n",
+ " elif kind == \"bar\" and x:\n",
+ " df[x].value_counts().plot.bar(ax=ax)\n",
+ " \n",
+ " ax.set_title(f\"{kind} plot\")\n",
+ " plt.tight_layout()\n",
+ " fig.savefig(\"data/quick_plot.png\", dpi=150)\n",
+ " print(f\"Plot saved to data/quick_plot.png\")\n",
+ " return fig\n",
+ "\n",
+ "print(\"Ask the agent to call quick_plot() with your data.\")",
+ ].join(""),
+ },
+ ],
+ agent_prompt: [
+ "You are a data analysis assistant for a scientist.",
+ "Help the scientist load, explore, visualize, and interpret their data.",
+ "When the scientist mentions a file, read it and detect the format (CSV, JSON, Excel, etc.).",
+ "Generate code that uses pandas and matplotlib for analysis and visualization.",
+ "Explain statistical results in plain language — avoid jargon.",
+ "Save all plots to the data/ directory.",
+ "When the scientist asks a question about their data, write and run code to answer it.",
+ ].join(" "),
+ agent_skills: [],
+ },
+ {
+ id: "experiment-pipeline",
+ name: "Experiment Pipeline",
+ description: "Define, run, and track experiments with parameters and results",
+ icon: "flask",
+ recommended_goals: ["experiment_pipeline", "model_training", "hypothesis_testing"],
+ recommended_data_types: ["tabular", "time_series", "images", "sensor"],
+ compute_preference: "local-jupyter",
+ notebook_cells: [
+ {
+ cell_type: "markdown",
+ source: [
+ "# Experiment Pipeline\n",
+ "This notebook helps you define, run, and track experiments.\n",
+ "Each experiment records its parameters, results, and artifacts for reproducibility.\n",
+ "Ask the agent to help design and run experiments.",
+ ].join(""),
+ },
+ {
+ cell_type: "code",
+ source: [
+ "# Experiment tracking helpers\n",
+ "import json\n",
+ "from datetime import datetime\n",
+ "from pathlib import Path\n",
+ "\n",
+ "experiments_dir = Path(\"experiments\")\n",
+ "experiments_dir.mkdir(exist_ok=True)\n",
+ "\n",
+ "def log_experiment(name, parameters, metrics, notes=\"\"):\n",
+ " \"\"\"Log an experiment with parameters, metrics, and notes.\"\"\"\n",
+ " experiment = {\n",
+ " \"name\": name,\n",
+ " \"parameters\": parameters,\n",
+ " \"metrics\": metrics,\n",
+ " \"notes\": notes,\n",
+ " \"timestamp\": datetime.now().isoformat(),\n",
+ " }\n",
+ " exp_file = experiments_dir / f\"{name}.json\"\n",
+ " with open(exp_file, \"w\") as f:\n",
+ " json.dump(experiment, f, indent=2)\n",
+ " print(f\"Logged experiment: {name}\")\n",
+ " print(f\" Parameters: {parameters}\")\n",
+ " print(f\" Metrics: {metrics}\")\n",
+ " print(f\" Saved to: {exp_file}\")\n",
+ " return experiment\n",
+ "\n",
+ "def list_experiments():\n",
+ " \"\"\"List all logged experiments.\"\"\"\n",
+ " experiments = []\n",
+ " for f in sorted(experiments_dir.glob(\"*.json\")):\n",
+ " with open(f) as fh:\n",
+ " experiments.append(json.load(fh))\n",
+ " print(f\"Found {len(experiments)} experiments:\")\n",
+ " for exp in experiments:\n",
+ " print(f\" {exp['name']}: {exp['metrics']}\")\n",
+ " return experiments\n",
+ "\n",
+ "print(\"Experiment tracking ready.\")\n",
+ "print(\"Use log_experiment(name, parameters, metrics) to record results.\")\n",
+ "print(\"Use list_experiments() to see all past experiments.\")",
+ ].join(""),
+ },
+ {
+ cell_type: "markdown",
+ source: [
+ "## Define Your Experiment\n",
+ "Describe what you want to test, then ask the agent to help design the experiment.\n",
+ "The agent can suggest parameters, write the experiment code, and log results.",
+ ].join(""),
+ },
+ {
+ cell_type: "code",
+ source: [
+ "# Example experiment — replace with your own\n",
+ "# Ask the agent: \"Help me design an experiment to test ...\"\n",
+ "\n",
+ "experiment_name = \"baseline_v1\"\n",
+ "parameters = {\n",
+ " # Add your experiment parameters here\n",
+ " # e.g., \"learning_rate\": 0.001, \"batch_size\": 32\n",
+ "}\n",
+ "metrics = {\n",
+ " # Add your results here after running\n",
+ " # e.g., \"accuracy\": 0.95, \"loss\": 0.12\n",
+ "}\n",
+ "\n",
+ "# log_experiment(experiment_name, parameters, metrics, notes=\"Baseline run\")\n",
+ "# list_experiments()",
+ ].join(""),
+ },
+ {
+ cell_type: "markdown",
+ source: [
+ "## Compare Results\n",
+ "After running multiple experiments, ask the agent:\n",
+ "\"Compare my experiments and show me which parameters worked best.\"\n",
+ ].join(""),
+ },
+ ],
+ agent_prompt: [
+ "You are an experiment pipeline assistant for a scientist.",
+ "Help the scientist design experiments, write code to run them, and track results.",
+ "Use the log_experiment() and list_experiments() functions defined in the notebook.",
+ "Suggest parameters to vary and metrics to track based on the scientist's field.",
+ "After each experiment, help the scientist interpret the results and suggest next steps.",
+ "When comparing experiments, create a summary table showing which parameters led to the best metrics.",
+ "Save all experiment artifacts to the experiments/ directory.",
+ ].join(" "),
+ agent_skills: [],
+ },
+ {
+ id: "blank",
+ name: "Blank Project",
+ description: "Start from scratch with an empty notebook",
+ icon: "document",
+ recommended_goals: [],
+ recommended_data_types: [],
+ compute_preference: "local-smolvm",
+ notebook_cells: [
+ {
+ cell_type: "markdown",
+ source: "# New Project\n\nAsk the agent for help at any time. It can write code, search the web, and run notebook cells for you.",
+ },
+ {
+ cell_type: "code",
+ source: "# Start coding here\nprint(\"Hello from your new project!\")",
+ },
+ ],
+ agent_prompt: "You are a helpful assistant for a scientist working in a notebook environment. Help them write code, analyze data, and answer questions. Use plain language and explain technical concepts when needed.",
+ agent_skills: [],
+ },
+];
diff --git a/controller/contracts/scientific-workbench.ts b/controller/contracts/scientific-workbench.ts
new file mode 100644
index 000000000..54a9102f6
--- /dev/null
+++ b/controller/contracts/scientific-workbench.ts
@@ -0,0 +1,330 @@
+import { Schema } from "effect";
+import { ClearanceSchema, EnterprisePrincipalScopeSchema } from "./enterprise-auth";
+
+export const SCIENTIFIC_NOTEBOOK_STATES = [
+ "requested",
+ "provisioning",
+ "ready",
+ "active",
+ "idle",
+ "suspended",
+ "archived",
+ "failed",
+] as const;
+
+export const SCIENTIFIC_COMPUTE_LEASE_STATES = [
+ "requested",
+ "admitted",
+ "provisioning",
+ "running",
+ "draining",
+ "completed",
+ "failed",
+ "cancelled",
+ "expired",
+] as const;
+
+export const SCIENTIFIC_JOB_STATES = [
+ "queued",
+ "submitted",
+ "running",
+ "succeeded",
+ "failed",
+ "cancelled",
+] as const;
+
+export const ScientificClassificationSchema = Schema.Literal("C2");
+export const ScientificNotebookRuntimeSchema = Schema.Literals([
+ "python-jupyter",
+ "python-smolvm",
+ "node-smolvm",
+]);
+
+export const ScientificNotebookSessionSchema = Schema.Struct({
+ id: Schema.String,
+ project_id: Schema.String,
+ owner_id: Schema.String,
+ owner_principal: Schema.optional(EnterprisePrincipalScopeSchema),
+ runtime: Schema.optional(ScientificNotebookRuntimeSchema),
+ document_path: Schema.optional(Schema.String),
+ state: Schema.Literals(SCIENTIFIC_NOTEBOOK_STATES),
+ classification: ScientificClassificationSchema,
+ compute_profile_id: Schema.String,
+ image_digest: Schema.String,
+ created_at: Schema.String,
+ updated_at: Schema.String,
+ expires_at: Schema.String,
+});
+
+export const ScientificNotebookCreateSchema = Schema.Struct({
+ project_id: Schema.String,
+ owner_id: Schema.String,
+ runtime: ScientificNotebookRuntimeSchema,
+ document_path: Schema.String,
+ classification: ScientificClassificationSchema,
+ compute_profile_id: Schema.String,
+ image_digest: Schema.String,
+ expires_at: Schema.String,
+});
+
+export const ScientificNotebookStateUpdateSchema = Schema.Struct({
+ state: Schema.Literals(SCIENTIFIC_NOTEBOOK_STATES),
+});
+
+export const ScientificComputeProfileSchema = Schema.Struct({
+ id: Schema.String,
+ name: Schema.String,
+ cpu_cores: Schema.Number,
+ memory_gb: Schema.Number,
+ gpu_count: Schema.Number,
+ gpu_resource: Schema.NullOr(Schema.String),
+ min_workers: Schema.Number,
+ max_workers: Schema.Number,
+ max_runtime_minutes: Schema.Number,
+ idle_timeout_minutes: Schema.Number,
+ network_policy: Schema.Literal("deny-by-default"),
+ classification_ceiling: ScientificClassificationSchema,
+});
+
+export const ScientificComputeLeaseSchema = Schema.Struct({
+ id: Schema.String,
+ project_id: Schema.String,
+ notebook_id: Schema.String,
+ profile_id: Schema.String,
+ profile: ScientificComputeProfileSchema,
+ classification: ScientificClassificationSchema,
+ state: Schema.Literals(SCIENTIFIC_COMPUTE_LEASE_STATES),
+ requested_at: Schema.String,
+ expires_at: Schema.String,
+});
+
+export const ScientificComputeLeaseIssueSchema = Schema.Struct({
+ project_id: Schema.String,
+ notebook_id: Schema.String,
+ profile: ScientificComputeProfileSchema,
+ classification: ScientificClassificationSchema,
+ expires_at: Schema.String,
+});
+
+export const ScientificDatasetAttachmentSchema = Schema.Struct({
+ attachment_id: Schema.String,
+ project_id: Schema.String,
+ dataset_id: Schema.String,
+ version: Schema.String,
+ digest: Schema.String,
+ classification: ScientificClassificationSchema,
+ access: Schema.Literal("read-only"),
+ purpose: Schema.String,
+ issued_at: Schema.String,
+ lease_expires_at: Schema.String,
+});
+
+export const ScientificDatasetAttachmentIssueSchema = Schema.Struct({
+ project_id: Schema.String,
+ dataset_id: Schema.String,
+ version: Schema.String,
+ digest: Schema.String,
+ classification: ScientificClassificationSchema,
+ purpose: Schema.String,
+ lease_expires_at: Schema.String,
+});
+
+export const ScientificModelReferenceSchema = Schema.Struct({
+ provider_id: Schema.String,
+ model_id: Schema.String,
+ qualified_id: Schema.String,
+ endpoint_class: Schema.Literal("openai-compatible"),
+ tool_mode: Schema.Literals(["none", "approved"]),
+});
+
+export const ScientificRayJobSubmissionSchema = Schema.Struct({
+ id: Schema.String,
+ project_id: Schema.String,
+ notebook_id: Schema.String,
+ compute_lease_id: Schema.String,
+ experiment_id: Schema.String,
+ classification: ScientificClassificationSchema,
+ compute_profile: ScientificComputeProfileSchema,
+ environment_image: Schema.String,
+ environment_digest: Schema.String,
+ entrypoint: Schema.String,
+ datasets: Schema.Array(ScientificDatasetAttachmentSchema),
+ models: Schema.Array(ScientificModelReferenceSchema),
+ parameters: Schema.Record(Schema.String, Schema.Unknown),
+ random_seeds: Schema.Array(Schema.Number),
+ approval_ids: Schema.Array(Schema.String),
+ requested_by: Schema.String,
+ requested_at: Schema.String,
+});
+
+export const ScientificResourceUsageSchema = Schema.Struct({
+ cpu_seconds: Schema.Number,
+ gpu_seconds: Schema.Number,
+ peak_memory_gb: Schema.Number,
+});
+
+export const ScientificExperimentReceiptFinalizeSchema = Schema.Struct({
+ artifact_digests: Schema.Array(Schema.String),
+ policy_decision_ids: Schema.Array(Schema.String),
+ resource_usage: ScientificResourceUsageSchema,
+});
+
+export const ScientificExperimentReceiptSchema = Schema.Struct({
+ id: Schema.String,
+ receipt_digest: Schema.String,
+ receipt_signature: Schema.String,
+ evidence_source: Schema.Literal("controller-reconciled"),
+ submission_id: Schema.String,
+ ray_job_id: Schema.String,
+ state: Schema.Literals(SCIENTIFIC_JOB_STATES),
+ classification: ScientificClassificationSchema,
+ notebook_digest: Schema.String,
+ notebook_revision: Schema.String,
+ notebook_interaction_digest: Schema.String,
+ notebook_interaction_count: Schema.Number,
+ environment_digest: Schema.String,
+ datasets: Schema.Array(ScientificDatasetAttachmentSchema),
+ models: Schema.Array(ScientificModelReferenceSchema),
+ artifact_digests: Schema.Array(Schema.String),
+ policy_decision_ids: Schema.Array(Schema.String),
+ apim_correlation_ids: Schema.optional(Schema.Array(Schema.String)),
+ principal: Schema.optional(
+ Schema.Struct({
+ subject: Schema.String,
+ issuer: Schema.optional(Schema.String),
+ issuer_id: Schema.String,
+ tenant: Schema.String,
+ clearance: ClearanceSchema,
+ }),
+ ),
+ agents: Schema.optional(
+ Schema.Array(
+ Schema.Struct({
+ provider_id: Schema.optional(Schema.String),
+ agent_id: Schema.String,
+ }),
+ ),
+ ),
+ foundry_invocations: Schema.optional(
+ Schema.Array(
+ Schema.Struct({
+ kind: Schema.Literals(["model", "agent"]),
+ provider_id: Schema.String,
+ resource_id: Schema.String,
+ correlation_id: Schema.String,
+ principal: EnterprisePrincipalScopeSchema,
+ }),
+ ),
+ ),
+ approval_ids: Schema.Array(Schema.String),
+ resource_usage: ScientificResourceUsageSchema,
+ started_at: Schema.String,
+ completed_at: Schema.NullOr(Schema.String),
+ issued_at: Schema.String,
+});
+
+export type ScientificNotebookSession = Schema.Schema.Type;
+export type ScientificNotebookRuntime = Schema.Schema.Type;
+export type ScientificNotebookCreate = Schema.Schema.Type;
+export type ScientificComputeProfile = Schema.Schema.Type;
+export type ScientificComputeLease = Schema.Schema.Type;
+export type ScientificComputeLeaseIssue = Schema.Schema.Type<
+ typeof ScientificComputeLeaseIssueSchema
+>;
+export type ScientificDatasetAttachment = Schema.Schema.Type<
+ typeof ScientificDatasetAttachmentSchema
+>;
+export type ScientificDatasetAttachmentIssue = Schema.Schema.Type<
+ typeof ScientificDatasetAttachmentIssueSchema
+>;
+export type ScientificModelReference = Schema.Schema.Type;
+export type ScientificRayJobSubmission = Schema.Schema.Type<
+ typeof ScientificRayJobSubmissionSchema
+>;
+export type ScientificExperimentReceipt = Schema.Schema.Type<
+ typeof ScientificExperimentReceiptSchema
+>;
+export type ScientificExperimentReceiptFinalize = Schema.Schema.Type<
+ typeof ScientificExperimentReceiptFinalizeSchema
+>;
+
+export type ScientificContractViolation = {
+ field: string;
+ reason: string;
+};
+
+const hasDigestPrefix = (value: string): boolean => /^[a-z0-9]+:[a-f0-9]{32,}$/u.test(value);
+
+export const validateScientificRayJobSubmission = (
+ submission: ScientificRayJobSubmission,
+): ScientificContractViolation[] => {
+ const violations: ScientificContractViolation[] = [];
+ const profile = submission.compute_profile;
+
+ if (profile.cpu_cores <= 0) {
+ violations.push({ field: "compute_profile.cpu_cores", reason: "must be positive" });
+ }
+ if (profile.memory_gb <= 0) {
+ violations.push({ field: "compute_profile.memory_gb", reason: "must be positive" });
+ }
+ if (profile.gpu_count < 0) {
+ violations.push({ field: "compute_profile.gpu_count", reason: "must not be negative" });
+ }
+ if (profile.min_workers < 0 || profile.max_workers < profile.min_workers) {
+ violations.push({
+ field: "compute_profile.max_workers",
+ reason: "must be greater than or equal to min_workers",
+ });
+ }
+ if (profile.max_runtime_minutes <= 0 || profile.idle_timeout_minutes <= 0) {
+ violations.push({
+ field: "compute_profile",
+ reason: "runtime and idle timeouts must be positive",
+ });
+ }
+ if (!hasDigestPrefix(submission.environment_digest)) {
+ violations.push({
+ field: "environment_digest",
+ reason: "must include an algorithm-prefixed digest",
+ });
+ }
+ if (!submission.compute_lease_id.trim()) {
+ violations.push({ field: "compute_lease_id", reason: "is required" });
+ }
+ if (!/^[^@\s]+@sha256:[a-f0-9]{64}$/u.test(submission.environment_image)) {
+ violations.push({
+ field: "environment_image",
+ reason: "must be an OCI image reference pinned by sha256 digest",
+ });
+ }
+
+ submission.datasets.forEach((dataset, index) => {
+ if (!dataset.attachment_id.trim() || !dataset.project_id.trim()) {
+ violations.push({
+ field: `datasets.${index}`,
+ reason: "requires controller-issued attachment and project identity",
+ });
+ }
+ if (!hasDigestPrefix(dataset.digest)) {
+ violations.push({
+ field: `datasets.${index}.digest`,
+ reason: "must include an algorithm-prefixed digest",
+ });
+ }
+ });
+
+ submission.models.forEach((model, index) => {
+ if (model.qualified_id !== `${model.provider_id}/${model.model_id}`) {
+ violations.push({
+ field: `models.${index}.qualified_id`,
+ reason: "must equal provider_id/model_id",
+ });
+ }
+ });
+
+ if (submission.approval_ids.length === 0) {
+ violations.push({ field: "approval_ids", reason: "requires at least one approval" });
+ }
+
+ return violations;
+};
diff --git a/controller/contracts/scientist-profile.ts b/controller/contracts/scientist-profile.ts
new file mode 100644
index 000000000..1a772a2ab
--- /dev/null
+++ b/controller/contracts/scientist-profile.ts
@@ -0,0 +1,103 @@
+import { Schema } from "effect";
+
+export const ScientistResearchFieldSchema = Schema.Literals([
+ "biology",
+ "chemistry",
+ "physics",
+ "climate",
+ "materials",
+ "computer_science",
+ "social_science",
+ "medicine",
+ "engineering",
+ "mathematics",
+ "other",
+]);
+
+export const ScientistDataTypeSchema = Schema.Literals([
+ "tabular",
+ "images",
+ "text",
+ "time_series",
+ "genomic",
+ "spatial",
+ "sensor",
+ "audio",
+ "video",
+ "graphs",
+ "other",
+]);
+
+export const ScientistGoalSchema = Schema.Literals([
+ "literature_review",
+ "data_analysis",
+ "experiment_pipeline",
+ "model_training",
+ "report_writing",
+ "hypothesis_testing",
+ "visualization",
+ "other",
+]);
+
+export const ScientistComputePreferenceSchema = Schema.Literals([
+ "local-smolvm",
+ "local-jupyter",
+ "remote",
+]);
+
+export const ScientistExperienceLevelSchema = Schema.Literals([
+ "no_code",
+ "some_code",
+ "expert",
+]);
+
+export const ScientistProcessStepSchema = Schema.Struct({
+ id: Schema.String,
+ label: Schema.String,
+ description: Schema.optional(Schema.String),
+ step_type: Schema.Literals([
+ "data_collection",
+ "data_cleaning",
+ "exploration",
+ "analysis",
+ "modeling",
+ "visualization",
+ "interpretation",
+ "reporting",
+ "custom",
+ ]),
+ order: Schema.Number,
+});
+
+export const ScientistProfileSchema = Schema.Struct({
+ research_field: ScientistResearchFieldSchema,
+ specialization: Schema.optional(Schema.String),
+ data_types: Schema.Array(ScientistDataTypeSchema),
+ goals: Schema.Array(ScientistGoalSchema),
+ compute_preference: ScientistComputePreferenceSchema,
+ experience_level: ScientistExperienceLevelSchema,
+ process_steps: Schema.optional(Schema.Array(ScientistProcessStepSchema)),
+ preferred_templates: Schema.optional(Schema.Array(Schema.String)),
+ created_at: Schema.String,
+ updated_at: Schema.String,
+});
+
+export const ScientistProfileCreateSchema = Schema.Struct({
+ research_field: ScientistResearchFieldSchema,
+ specialization: Schema.optional(Schema.String),
+ data_types: Schema.Array(ScientistDataTypeSchema),
+ goals: Schema.Array(ScientistGoalSchema),
+ compute_preference: ScientistComputePreferenceSchema,
+ experience_level: ScientistExperienceLevelSchema,
+ process_steps: Schema.optional(Schema.Array(ScientistProcessStepSchema)),
+ preferred_templates: Schema.optional(Schema.Array(Schema.String)),
+});
+
+export type ScientistProfile = typeof ScientistProfileSchema.Type;
+export type ScientistProfileCreate = typeof ScientistProfileCreateSchema.Type;
+export type ScientistProcessStep = typeof ScientistProcessStepSchema.Type;
+export type ScientistResearchField = typeof ScientistResearchFieldSchema.Type;
+export type ScientistDataType = typeof ScientistDataTypeSchema.Type;
+export type ScientistGoal = typeof ScientistGoalSchema.Type;
+export type ScientistComputePreference = typeof ScientistComputePreferenceSchema.Type;
+export type ScientistExperienceLevel = typeof ScientistExperienceLevelSchema.Type;
diff --git a/controller/contracts/setup-commissioning.ts b/controller/contracts/setup-commissioning.ts
new file mode 100644
index 000000000..e5ba6f3bb
--- /dev/null
+++ b/controller/contracts/setup-commissioning.ts
@@ -0,0 +1,110 @@
+import { Schema } from "effect";
+import { TensorPrimeServiceKindSchema } from "./tensorprime";
+
+export const SetupEvidenceStateSchema = Schema.Literals([
+ "claimed",
+ "observed",
+ "attested",
+ "contradicted",
+]);
+export type SetupEvidenceState = typeof SetupEvidenceStateSchema.Type;
+
+export const SetupConnectionProbeSchema = Schema.Struct({
+ state: SetupEvidenceStateSchema,
+ checked_at: Schema.NullOr(Schema.String),
+ status: Schema.NullOr(Schema.Number),
+ detail: Schema.String,
+});
+export type SetupConnectionProbe = typeof SetupConnectionProbeSchema.Type;
+
+export const SetupOidcConnectionSchema = Schema.Struct({
+ enabled: Schema.Boolean,
+ kind: Schema.Literals(["entra", "keycloak"]),
+ issuer: Schema.String,
+ client_id: Schema.String,
+ audience: Schema.String,
+ tenant_or_realm: Schema.String,
+ probe: SetupConnectionProbeSchema,
+});
+export type SetupOidcConnection = typeof SetupOidcConnectionSchema.Type;
+
+export const SetupRemoteServiceSchema = Schema.Struct({
+ id: Schema.Literals(["api", "embed", "audio", "ray"]),
+ label: Schema.String,
+ kind: TensorPrimeServiceKindSchema,
+ catalog_service_id: Schema.NullOr(Schema.String),
+ enabled: Schema.Boolean,
+ base_url: Schema.String,
+ host_header: Schema.String,
+ probe_path: Schema.String,
+ probe: SetupConnectionProbeSchema,
+});
+export type SetupRemoteService = typeof SetupRemoteServiceSchema.Type;
+
+export const SetupCommissioningRequirementsSchema = Schema.Struct({
+ controller_credential: Schema.Boolean,
+ oidc: Schema.Boolean,
+ kubernetes: Schema.Boolean,
+ tensorprime: Schema.Boolean,
+ agents: Schema.Boolean,
+ workload_svid: Schema.Boolean,
+});
+export type SetupCommissioningRequirements = typeof SetupCommissioningRequirementsSchema.Type;
+
+export const SetupSpiffePhaseSchema = Schema.Struct({
+ trust_domain: Schema.String,
+ identity_plane: SetupEvidenceStateSchema,
+ workload_svid: SetupEvidenceStateSchema,
+ service_mtls: Schema.Literals(["not_enforced", "observed", "contradicted"]),
+ detail: Schema.String,
+});
+export type SetupSpiffePhase = typeof SetupSpiffePhaseSchema.Type;
+
+export const SetupCommissioningProfileSchema = Schema.Struct({
+ version: Schema.Literal(1),
+ revision: Schema.Number,
+ classification: Schema.Literal("C2"),
+ updated_at: Schema.String,
+ requirements: SetupCommissioningRequirementsSchema,
+ oidc: SetupOidcConnectionSchema,
+ tensorprime_probes: Schema.Array(SetupRemoteServiceSchema),
+ spiffe: SetupSpiffePhaseSchema,
+});
+export type SetupCommissioningProfile = typeof SetupCommissioningProfileSchema.Type;
+
+export const SetupCommissioningSaveSchema = Schema.Struct({
+ revision: Schema.Number,
+ requirements: SetupCommissioningRequirementsSchema,
+ oidc: Schema.Struct({
+ enabled: Schema.Boolean,
+ kind: Schema.Literals(["entra", "keycloak"]),
+ issuer: Schema.String,
+ client_id: Schema.String,
+ audience: Schema.String,
+ tenant_or_realm: Schema.String,
+ }),
+ tensorprime_probes: Schema.Array(
+ Schema.Struct({
+ id: Schema.Literals(["api", "embed", "audio", "ray"]),
+ label: Schema.String,
+ kind: TensorPrimeServiceKindSchema,
+ catalog_service_id: Schema.NullOr(Schema.String),
+ enabled: Schema.Boolean,
+ base_url: Schema.String,
+ host_header: Schema.String,
+ probe_path: Schema.String,
+ }),
+ ),
+});
+export type SetupCommissioningSave = typeof SetupCommissioningSaveSchema.Type;
+
+export const SetupCommissioningProbeInputSchema = Schema.Struct({
+ target: Schema.Union([
+ Schema.Literal("oidc"),
+ Schema.Literal("api"),
+ Schema.Literal("embed"),
+ Schema.Literal("audio"),
+ Schema.Literal("ray"),
+ ]),
+});
+export type SetupCommissioningProbeInput = typeof SetupCommissioningProbeInputSchema.Type;
diff --git a/controller/contracts/tensorprime.ts b/controller/contracts/tensorprime.ts
new file mode 100644
index 000000000..438a65f4c
--- /dev/null
+++ b/controller/contracts/tensorprime.ts
@@ -0,0 +1,85 @@
+import { Schema } from "effect";
+
+export const TensorPrimeServiceKindSchema = Schema.Literals([
+ "ray-client",
+ "ray-dashboard",
+ "ray-serve",
+ "vllm",
+ "litellm",
+ "embedding-http",
+ "embedding-grpc",
+ "asr",
+ "unified-api",
+]);
+export type TensorPrimeServiceKind = typeof TensorPrimeServiceKindSchema.Type;
+
+export const TensorPrimeEndpointScopeSchema = Schema.Literals(["in-cluster", "external"]);
+export type TensorPrimeEndpointScope = typeof TensorPrimeEndpointScopeSchema.Type;
+
+export const TensorPrimeProtocolSchema = Schema.Literals(["http", "grpc"]);
+export type TensorPrimeProtocol = typeof TensorPrimeProtocolSchema.Type;
+
+export const TensorPrimeServiceEndpointSchema = Schema.Struct({
+ id: Schema.String,
+ kind: TensorPrimeServiceKindSchema,
+ scope: TensorPrimeEndpointScopeSchema,
+ protocol: TensorPrimeProtocolSchema,
+ url: Schema.String,
+ host_header: Schema.NullOr(Schema.String),
+ openai_compatible: Schema.Boolean,
+ transport_security: Schema.Literal("plaintext"),
+ server_mtls_enforced: Schema.Literal(false),
+});
+export type TensorPrimeServiceEndpoint = typeof TensorPrimeServiceEndpointSchema.Type;
+
+export const TensorPrimeWorkloadIdentitySchema = Schema.Struct({
+ component: Schema.Literals(["frontend", "controller", "agent-runtime"]),
+ namespace: Schema.String,
+ service_account: Schema.String,
+ spiffe_id: Schema.String,
+});
+export type TensorPrimeWorkloadIdentity = typeof TensorPrimeWorkloadIdentitySchema.Type;
+
+export const TensorPrimeConnectionProfileSchema = Schema.Struct({
+ version: Schema.Literal(1),
+ id: Schema.String,
+ phase: Schema.Literal("phase0"),
+ trust_domain: Schema.String,
+ spiffe_id_template: Schema.String,
+ workload_api: Schema.Struct({
+ csi_driver: Schema.Literal("csi.spiffe.io"),
+ mount_path: Schema.String,
+ socket_path: Schema.String,
+ endpoint: Schema.String,
+ }),
+ x509_svid: Schema.Struct({
+ ttl_seconds: Schema.Number,
+ rotation: Schema.Literal("workload-api-stream"),
+ persistence: Schema.Literal("memory-only"),
+ }),
+ capabilities: Schema.Struct({
+ svid_issuance: Schema.Literal("available"),
+ svid_rotation: Schema.Literal("available"),
+ service_mtls_enforcement: Schema.Literal("not-configured"),
+ ray_tls: Schema.Literal("not-configured"),
+ }),
+ identities: Schema.Array(TensorPrimeWorkloadIdentitySchema),
+ services: Schema.Array(TensorPrimeServiceEndpointSchema),
+});
+export type TensorPrimeConnectionProfile = typeof TensorPrimeConnectionProfileSchema.Type;
+
+export const TensorPrimeSvidReadinessEvidenceSchema = Schema.Struct({
+ state: Schema.Literals(["claimed", "observed", "contradicted"]),
+ checked_at: Schema.String,
+ expected_spiffe_id: Schema.String,
+ observed_spiffe_id: Schema.NullOr(Schema.String),
+ workload_api_endpoint: Schema.String,
+ x509_svid_expires_at: Schema.NullOr(Schema.String),
+ rotation_generation: Schema.Number,
+ svid_available: Schema.Boolean,
+ rotation_observed: Schema.Boolean,
+ service_mtls_enforced: Schema.Literal(false),
+ ray_tls_configured: Schema.Literal(false),
+ detail: Schema.String,
+});
+export type TensorPrimeSvidReadinessEvidence = typeof TensorPrimeSvidReadinessEvidenceSchema.Type;
diff --git a/controller/contracts/workload-identity.ts b/controller/contracts/workload-identity.ts
new file mode 100644
index 000000000..14bb75b68
--- /dev/null
+++ b/controller/contracts/workload-identity.ts
@@ -0,0 +1,56 @@
+import { Schema } from "effect";
+
+export const WorkloadIdentityModeSchema = Schema.Literals(["disabled", "optional", "required"]);
+export type WorkloadIdentityMode = typeof WorkloadIdentityModeSchema.Type;
+
+export const WorkloadIdentityConfigSchema = Schema.Struct({
+ mode: WorkloadIdentityModeSchema,
+ x509_mtls: Schema.optional(WorkloadIdentityModeSchema),
+ endpoint: Schema.String,
+ trust_domain: Schema.String,
+ frontend_id: Schema.String,
+ controller_id: Schema.String,
+ agent_runtime_id: Schema.String,
+ agent_runtime_audience: Schema.String,
+ controller_audience: Schema.String,
+});
+export type WorkloadIdentityConfig = typeof WorkloadIdentityConfigSchema.Type;
+
+export const ControllerWorkloadProbeSchema = Schema.Struct({
+ configured: Schema.Boolean,
+ observed: Schema.Boolean,
+ source: Schema.optional(Schema.String),
+ destination: Schema.optional(Schema.String),
+ jwt_svid: Schema.optional(Schema.Boolean),
+ x509_mtls: Schema.optional(Schema.Boolean),
+});
+export type ControllerWorkloadProbe = typeof ControllerWorkloadProbeSchema.Type;
+
+export const WorkloadIdentityEvidenceSchema = Schema.Struct({
+ configured: Schema.Boolean,
+ required: Schema.Boolean,
+ state: Schema.Literals(["unconfigured", "claimed", "observed", "contradicted"]),
+ spiffe_id: Schema.NullOr(Schema.String),
+ trust_domain: Schema.NullOr(Schema.String),
+ audience: Schema.NullOr(Schema.String),
+ expires_at: Schema.NullOr(Schema.Number),
+ checked_at: Schema.NullOr(Schema.String),
+ jwt_svid_validated: Schema.Boolean,
+ x509_mtls: Schema.Literals(["disabled", "not_verified", "observed", "contradicted"]),
+ x509_svid_expires_at: Schema.optional(Schema.NullOr(Schema.String)),
+ x509_svid_serial: Schema.optional(Schema.NullOr(Schema.String)),
+ rotation_generation: Schema.optional(Schema.Number),
+ hops: Schema.optional(
+ Schema.Array(
+ Schema.Struct({
+ source: Schema.String,
+ destination: Schema.String,
+ jwt_svid: Schema.Boolean,
+ x509_mtls: Schema.Boolean,
+ peer_id: Schema.NullOr(Schema.String),
+ }),
+ ),
+ ),
+ detail: Schema.String,
+});
+export type WorkloadIdentityEvidence = typeof WorkloadIdentityEvidenceSchema.Type;
diff --git a/controller/package.json b/controller/package.json
index 2173bb4b4..acc13aa55 100644
--- a/controller/package.json
+++ b/controller/package.json
@@ -18,6 +18,7 @@
"dependencies": {
"@earendil-works/pi-ai": "0.80.8",
"@hono/standard-validator": "0.2.3",
+ "@hono/node-server": "2.0.12",
"@hono/swagger-ui": "0.5.3",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
@@ -25,6 +26,7 @@
"effect": "4.0.0-beta.90",
"hono": "4.12.30",
"hono-openapi": "1.3.1",
+ "jose": "6.2.4",
"openapi-types": "12.1.3",
"semver": "7.8.5"
},
diff --git a/controller/scripts/build-python-notebook-image.sh b/controller/scripts/build-python-notebook-image.sh
new file mode 100755
index 000000000..34c177595
--- /dev/null
+++ b/controller/scripts/build-python-notebook-image.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+set -euo pipefail
+context="$(cd "$(dirname "${BASH_SOURCE[0]}")/../assets/notebooks/python-smolvm" && pwd)"
+output="${1:-$(cd "$context/../../../.." && pwd)/data/python-notebook-image.tar}"
+image="local-studio-python-notebook:3.12.11"
+docker build --pull=false --tag "$image" "$context"
+mkdir -p "$(dirname "$output")"
+docker save "$image" --output "$output"
+digest="$(shasum -a 256 "$output" | awk '{print $1}')"
+printf 'LOCAL_STUDIO_NOTEBOOK_PYTHON_IMAGE=%s@sha256:%s\n' "$output" "$digest"
diff --git a/controller/scripts/node_notebook_bridge.mjs b/controller/scripts/node_notebook_bridge.mjs
new file mode 100644
index 000000000..1ac86b1fb
--- /dev/null
+++ b/controller/scripts/node_notebook_bridge.mjs
@@ -0,0 +1,92 @@
+import { readFile, writeFile } from "node:fs/promises";
+import { inspect } from "node:util";
+import vm from "node:vm";
+
+const request = JSON.parse(await readFile(process.argv[2], "utf8"));
+const notebook = JSON.parse(await readFile(request.path, "utf8"));
+const sourceText = (source) => (Array.isArray(source) ? source.join("") : String(source ?? ""));
+const kernelName = notebook.metadata?.kernelspec?.name ?? "nodejs";
+
+const renderDocument = () => ({
+ kernel_name: kernelName,
+ cells: notebook.cells.map((cell, index) => ({
+ index,
+ cell_type: cell.cell_type,
+ source: sourceText(cell.source),
+ execution_count: cell.execution_count ?? null,
+ outputs: (cell.outputs ?? []).map((output) => ({
+ type: output.output_type ?? "stream",
+ text:
+ output.output_type === "error"
+ ? (output.traceback ?? []).join("\n")
+ : sourceText(output.text ?? output.data?.["text/plain"] ?? ""),
+ })),
+ })),
+});
+
+if (request.operation !== "execute") {
+ throw new Error("Node.js notebook bridge only supports execution");
+}
+
+const selected = notebook.cells[request.cell_index];
+if (!selected) throw new Error("cell index is outside the notebook");
+if (selected.cell_type !== "code") throw new Error("only code cells can be executed");
+
+const context = vm.createContext({
+ Buffer,
+ URL,
+ clearInterval,
+ clearTimeout,
+ setInterval,
+ setTimeout,
+});
+let executionCount = 0;
+const formatValue = (value) => (typeof value === "string" ? value : inspect(value));
+
+for (let index = 0; index <= request.cell_index; index += 1) {
+ const cell = notebook.cells[index];
+ if (cell.cell_type !== "code") continue;
+ executionCount += 1;
+ const output = [];
+ context.console = Object.freeze({
+ log: (...values) => {
+ output.push(values.map(formatValue).join(" "));
+ },
+ error: (...values) => {
+ output.push(values.map(formatValue).join(" "));
+ },
+ warn: (...values) => {
+ output.push(values.map(formatValue).join(" "));
+ },
+ });
+ try {
+ const script = new vm.Script(sourceText(cell.source), {
+ filename: `${request.path}#cell-${index}`,
+ });
+ const value = await Promise.resolve(
+ script.runInContext(context, { timeout: request.timeout_seconds * 1000 }),
+ );
+ cell.outputs = output.map((text) => ({ output_type: "stream", name: "stdout", text }));
+ if (value !== undefined) {
+ cell.outputs.push({
+ output_type: "execute_result",
+ execution_count: executionCount,
+ data: { "text/plain": inspect(value) },
+ metadata: {},
+ });
+ }
+ } catch (error) {
+ cell.outputs = [
+ {
+ output_type: "error",
+ ename: error instanceof Error ? error.name : "Error",
+ evalue: error instanceof Error ? error.message : String(error),
+ traceback: [error instanceof Error ? error.stack ?? error.message : String(error)],
+ },
+ ];
+ }
+ cell.execution_count = executionCount;
+}
+
+await writeFile(request.path, `${JSON.stringify(notebook, null, 1)}\n`, "utf8");
+process.stdout.write(JSON.stringify(renderDocument()));
diff --git a/controller/scripts/notebook_bridge.py b/controller/scripts/notebook_bridge.py
new file mode 100644
index 000000000..0ce4e059d
--- /dev/null
+++ b/controller/scripts/notebook_bridge.py
@@ -0,0 +1,130 @@
+import json
+import pathlib
+import sys
+
+import nbformat
+from nbclient import NotebookClient
+
+
+def render_output(output):
+ output_type = output.get("output_type", "stream")
+ if output_type == "stream":
+ text = output.get("text", "")
+ elif output_type == "error":
+ text = "\n".join(output.get("traceback", []))
+ else:
+ data = output.get("data", {})
+ text = data.get("text/plain", json.dumps(data, sort_keys=True))
+ return {"type": output_type, "text": str(text)[:20000]}
+
+
+def document(path):
+ notebook = nbformat.read(path, as_version=4)
+ kernel_name = notebook.metadata.get("kernelspec", {}).get("name", "python3")
+ cells = []
+ for index, cell in enumerate(notebook.cells):
+ cells.append(
+ {
+ "index": index,
+ "cell_type": cell.cell_type,
+ "source": cell.source,
+ "execution_count": cell.get("execution_count"),
+ "outputs": [render_output(value) for value in cell.get("outputs", [])],
+ }
+ )
+ return {"kernel_name": kernel_name, "cells": cells}
+
+
+def execute(path, cell_index, timeout_seconds):
+ notebook = nbformat.read(path, as_version=4)
+ if cell_index < 0 or cell_index >= len(notebook.cells):
+ raise ValueError("cell index is outside the notebook")
+ if notebook.cells[cell_index].cell_type != "code":
+ raise ValueError("only code cells can be executed")
+ kernel_name = notebook.metadata.get("kernelspec", {}).get("name", "python3")
+ client = NotebookClient(
+ notebook,
+ timeout=timeout_seconds,
+ kernel_name=kernel_name,
+ allow_errors=True,
+ )
+ with client.setup_kernel():
+ for index in range(cell_index + 1):
+ if notebook.cells[index].cell_type == "code":
+ client.execute_cell(notebook.cells[index], index)
+ nbformat.write(notebook, path)
+ return document(path)
+
+
+def patch(path, cell_index, source):
+ notebook = nbformat.read(path, as_version=4)
+ if cell_index < 0 or cell_index >= len(notebook.cells):
+ raise ValueError("cell index is outside the notebook")
+ notebook.cells[cell_index].source = source
+ if notebook.cells[cell_index].cell_type == "code":
+ notebook.cells[cell_index].outputs = []
+ notebook.cells[cell_index].execution_count = None
+ nbformat.write(notebook, path)
+ return document(path)
+
+
+def structure(path, action, cell_index, cell_type=None, direction=None):
+ notebook = nbformat.read(path, as_version=4)
+ if action == "insert":
+ if cell_index < 0 or cell_index > len(notebook.cells):
+ raise ValueError("cell index is outside the notebook")
+ factories = {
+ "code": nbformat.v4.new_code_cell,
+ "markdown": nbformat.v4.new_markdown_cell,
+ "raw": nbformat.v4.new_raw_cell,
+ }
+ if cell_type not in factories:
+ raise ValueError("unsupported cell type")
+ notebook.cells.insert(cell_index, factories[cell_type]())
+ elif action == "delete":
+ if cell_index < 0 or cell_index >= len(notebook.cells):
+ raise ValueError("cell index is outside the notebook")
+ notebook.cells.pop(cell_index)
+ elif action == "move":
+ offset = -1 if direction == "up" else 1 if direction == "down" else 0
+ target = cell_index + offset
+ if offset == 0 or cell_index < 0 or target < 0 or target >= len(notebook.cells):
+ raise ValueError("cell cannot move in that direction")
+ notebook.cells[cell_index], notebook.cells[target] = (
+ notebook.cells[target],
+ notebook.cells[cell_index],
+ )
+ else:
+ raise ValueError("unsupported structure operation")
+ nbformat.write(notebook, path)
+ return document(path)
+
+
+def main():
+ request = json.loads(
+ pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")
+ if len(sys.argv) > 1
+ else sys.stdin.read()
+ )
+ path = pathlib.Path(request["path"])
+ if request["operation"] == "inspect":
+ result = document(path)
+ elif request["operation"] == "execute":
+ result = execute(path, request["cell_index"], request["timeout_seconds"])
+ elif request["operation"] == "patch":
+ result = patch(path, request["cell_index"], request["source"])
+ elif request["operation"] == "structure":
+ result = structure(
+ path,
+ request["action"],
+ request["cell_index"],
+ request.get("cell_type"),
+ request.get("direction"),
+ )
+ else:
+ raise ValueError("unsupported operation")
+ sys.stdout.write(json.dumps(result))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/controller/src/app-context.ts b/controller/src/app-context.ts
index c1834fc2f..f6ad8b287 100644
--- a/controller/src/app-context.ts
+++ b/controller/src/app-context.ts
@@ -1,6 +1,7 @@
import { existsSync } from "node:fs";
-import { mkdir } from "node:fs/promises";
-import { resolve } from "node:path";
+import { copyFile, mkdir } from "node:fs/promises";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
import { Context, Effect, Layer, Schema } from "effect";
import { createConfig, type Config } from "./config/env";
import { createLogger, resolveLogLevel, type Logger } from "./core/logger";
@@ -25,6 +26,12 @@ import { ControllerRequestStore } from "./stores/controller-request-store";
import { ControllerSettingsStore } from "./stores/controller-settings-store";
import { InferenceRequestStore } from "./stores/inference-request-store";
import { RigStore } from "./stores/rig-store";
+import { ScientificWorkbenchStore } from "./modules/workbench/store";
+import { ExperimentTrackingStore } from "./modules/workbench/experiment-store";
+import { KubeRayGateway } from "./modules/workbench/kuberay-gateway";
+import { NotebookGateway } from "./modules/workbench/notebook-gateway";
+import { MachineEnrollmentService } from "./modules/machines/enrollment-service";
+import { ProviderSecretStore } from "./services/provider-secret-store";
export interface AppContext {
config: Config;
@@ -36,6 +43,10 @@ export interface AppContext {
bridge: ComputeBridge;
gpuLeaseRegistry: GpuLeaseRegistry;
speechService: SpeechService;
+ kubeRayGateway: KubeRayGateway | null;
+ notebookGateway: NotebookGateway;
+ machineEnrollmentService: MachineEnrollmentService;
+ providerSecretStore: ProviderSecretStore;
stores: {
recipeStore: RecipeStore;
downloadStore: DownloadStore;
@@ -45,6 +56,8 @@ export interface AppContext {
controllerSettingsStore: ControllerSettingsStore;
controllerRequestStore: ControllerRequestStore;
rigStore: RigStore;
+ scientificWorkbenchStore: ScientificWorkbenchStore;
+ experimentTrackingStore: ExperimentTrackingStore;
};
}
@@ -102,6 +115,16 @@ const ensureModelsDirectory = (modelsDirectory: string): Effect.Effect
+ new ProviderSecretStore(
+ config.data_dir,
+ !loopback ||
+ (config.enterprise_auth !== undefined && config.enterprise_auth.mode !== "local"),
+ ),
+ );
yield* initialize(
"data-directory.create",
Effect.tryPromise({
@@ -163,6 +186,84 @@ export const makeAppContext = Effect.gen(function* () {
initializeSync("rig-store.open", () => new RigStore(dbPath)),
(resource) => releaseSafely("rig-store.close", logger, resource.close()),
);
+ const scientificWorkbenchStore = yield* Effect.acquireRelease(
+ initializeSync("scientific-workbench-store.open", () => new ScientificWorkbenchStore(dbPath)),
+ (resource) => releaseSafely("scientific-workbench-store.close", logger, resource.close()),
+ );
+ const experimentTrackingStore = yield* Effect.acquireRelease(
+ initializeSync("experiment-tracking-store.open", () => new ExperimentTrackingStore(dbPath)),
+ (resource) => releaseSafely("experiment-tracking-store.close", logger, resource.close()),
+ );
+ const kubeRayGateway =
+ config.kuberay_api_url && config.kuberay_token_file
+ ? new KubeRayGateway({
+ apiUrl: config.kuberay_api_url,
+ tokenFile: config.kuberay_token_file,
+ ...(config.kuberay_ca_file ? { caFile: config.kuberay_ca_file } : {}),
+ })
+ : null;
+ yield* initialize(
+ "scientific-workbench.notebook-root",
+ Effect.tryPromise({
+ try: () => mkdir(config.notebook_root, { recursive: true }),
+ catch: (source) => source,
+ }),
+ );
+ const sampleNotebook = resolve(config.notebook_root, "agent-collaboration.ipynb");
+ if (!existsSync(sampleNotebook)) {
+ const bundledSample = resolve(
+ dirname(fileURLToPath(import.meta.url)),
+ "../assets/notebooks/agent-collaboration.ipynb",
+ );
+ yield* initialize(
+ "scientific-workbench.sample-notebook",
+ Effect.tryPromise({
+ try: () => copyFile(bundledSample, sampleNotebook),
+ catch: (source) => source,
+ }),
+ );
+ }
+ const pythonSmolvmSample = resolve(
+ config.notebook_root,
+ "agent-collaboration-python-smolvm.ipynb",
+ );
+ if (!existsSync(pythonSmolvmSample)) {
+ const bundledSample = resolve(
+ dirname(fileURLToPath(import.meta.url)),
+ "../assets/notebooks/agent-collaboration.ipynb",
+ );
+ yield* initialize(
+ "scientific-workbench.python-smolvm-sample-notebook",
+ Effect.tryPromise({
+ try: () => copyFile(bundledSample, pythonSmolvmSample),
+ catch: (source) => source,
+ }),
+ );
+ }
+ const nodeSampleNotebook = resolve(config.notebook_root, "agent-collaboration-node.ipynb");
+ if (!existsSync(nodeSampleNotebook)) {
+ const bundledNodeSample = resolve(
+ dirname(fileURLToPath(import.meta.url)),
+ "../assets/notebooks/agent-collaboration-node.ipynb",
+ );
+ yield* initialize(
+ "scientific-workbench.node-sample-notebook",
+ Effect.tryPromise({
+ try: () => copyFile(bundledNodeSample, nodeSampleNotebook),
+ catch: (source) => source,
+ }),
+ );
+ }
+ const notebookGateway = new NotebookGateway(
+ config.notebook_root,
+ config.notebook_python,
+ undefined,
+ config.notebook_smolvm,
+ config.notebook_node_image,
+ undefined,
+ config.notebook_python_image,
+ );
+ const machineEnrollmentService = new MachineEnrollmentService(config.data_dir);
yield* initialize(
"lifetime-metrics-store.initialize",
lifetimeMetricsStore.ensureFirstStartedEffect(),
@@ -224,6 +325,10 @@ export const makeAppContext = Effect.gen(function* () {
bridge,
gpuLeaseRegistry,
speechService,
+ kubeRayGateway,
+ notebookGateway,
+ machineEnrollmentService,
+ providerSecretStore,
stores: {
recipeStore,
downloadStore,
@@ -233,6 +338,8 @@ export const makeAppContext = Effect.gen(function* () {
controllerSettingsStore,
controllerRequestStore,
rigStore,
+ scientificWorkbenchStore,
+ experimentTrackingStore,
},
} satisfies AppContext;
});
diff --git a/controller/src/config/env.ts b/controller/src/config/env.ts
index 9ef93cd89..adbc388b2 100644
--- a/controller/src/config/env.ts
+++ b/controller/src/config/env.ts
@@ -1,11 +1,20 @@
import { config as loadEnvironment } from "dotenv";
import { Schema } from "effect";
-import { existsSync } from "node:fs";
+import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
-import { dirname, join, resolve } from "node:path";
+import { delimiter, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { loadPersistedConfig, type ProviderConfig } from "./persisted-config";
import { parseBooleanFlag } from "../core/validation";
+import {
+ EnterpriseAuthConfigSchema,
+ type EnterpriseAuthConfig,
+} from "@local-studio/contracts/enterprise-auth";
+import {
+ normalizeKubernetesApiUrl,
+ prepareKubernetesConnection,
+} from "../modules/environment/configuration";
+import { ProviderSecretStore } from "../services/provider-secret-store";
const positiveIntegerSchema = Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0));
@@ -25,6 +34,16 @@ export interface Config {
mlx_python?: string;
strict_openai_models: boolean;
providers: ProviderConfig[];
+ enterprise_auth?: EnterpriseAuthConfig;
+ kuberay_api_url?: string;
+ kuberay_token_file?: string;
+ kuberay_ca_file?: string;
+ scientific_receipt_signing_key?: string;
+ notebook_root: string;
+ notebook_python: string;
+ notebook_smolvm: string;
+ notebook_node_image: string;
+ notebook_python_image: string;
}
export const loadDotEnvironment = (): string | undefined => {
@@ -44,6 +63,21 @@ export const loadDotEnvironment = (): string | undefined => {
const defaultModelsDirectory = (): string =>
process.platform === "win32" ? join(homedir(), "models") : "/models";
+const defaultNotebookPython = (): string => {
+ for (const directory of (process.env["PATH"] ?? "").split(delimiter)) {
+ const executable = join(directory, process.platform === "win32" ? "jupyter.exe" : "jupyter");
+ if (!existsSync(executable)) continue;
+ try {
+ const firstLine = readFileSync(executable, "utf8").split(/\r?\n/u)[0] ?? "";
+ const interpreter = firstLine.startsWith("#!") ? firstLine.slice(2).trim() : "";
+ if (interpreter && existsSync(interpreter)) return interpreter;
+ } catch {
+ continue;
+ }
+ }
+ return "python3";
+};
+
export const createConfig = (): Config => {
loadDotEnvironment();
@@ -102,6 +136,16 @@ export const createConfig = (): Config => {
LOCAL_STUDIO_LLAMA_BIN: Schema.optional(Schema.String),
LOCAL_STUDIO_MLX_PYTHON: Schema.optional(Schema.String),
LOCAL_STUDIO_STRICT_OPENAI_MODELS: Schema.optional(Schema.String),
+ LOCAL_STUDIO_KUBERAY_API_URL: Schema.optional(Schema.String),
+ LOCAL_STUDIO_KUBERAY_TOKEN_FILE: Schema.optional(Schema.String),
+ LOCAL_STUDIO_KUBERAY_CA_FILE: Schema.optional(Schema.String),
+ LOCAL_STUDIO_SCIENTIFIC_RECEIPT_SIGNING_KEY: Schema.optional(Schema.String),
+ LOCAL_STUDIO_NOTEBOOK_ROOT: Schema.optional(Schema.String),
+ LOCAL_STUDIO_NOTEBOOK_PYTHON: Schema.optional(Schema.String),
+ LOCAL_STUDIO_NOTEBOOK_SMOLVM: Schema.optional(Schema.String),
+ LOCAL_STUDIO_NOTEBOOK_NODE_IMAGE: Schema.optional(Schema.String),
+ LOCAL_STUDIO_NOTEBOOK_PYTHON_IMAGE: Schema.optional(Schema.String),
+ LOCAL_STUDIO_ENTERPRISE_AUTH_CONFIG: Schema.optional(Schema.String),
});
const coercePositiveInteger = (
@@ -146,6 +190,17 @@ export const createConfig = (): Config => {
strict_openai_models: strictOpenAIModelsEnabled,
cors_origins: parseCorsOrigins(parsed.LOCAL_STUDIO_CORS_ORIGINS),
providers: [],
+ notebook_root: parsed.LOCAL_STUDIO_NOTEBOOK_ROOT
+ ? resolve(parsed.LOCAL_STUDIO_NOTEBOOK_ROOT)
+ : resolve(dataDirectory, "notebooks"),
+ notebook_python: parsed.LOCAL_STUDIO_NOTEBOOK_PYTHON?.trim() || defaultNotebookPython(),
+ notebook_smolvm: parsed.LOCAL_STUDIO_NOTEBOOK_SMOLVM?.trim() || "smolvm",
+ notebook_node_image:
+ parsed.LOCAL_STUDIO_NOTEBOOK_NODE_IMAGE?.trim() ||
+ resolve(dataDirectory, "node-notebook-image.tar"),
+ notebook_python_image:
+ parsed.LOCAL_STUDIO_NOTEBOOK_PYTHON_IMAGE?.trim() ||
+ resolve(dataDirectory, "python-notebook-image.tar"),
};
if (parsed.LOCAL_STUDIO_API_KEY) {
@@ -168,8 +223,31 @@ export const createConfig = (): Config => {
if (parsed.LOCAL_STUDIO_MLX_PYTHON) {
config.mlx_python = parsed.LOCAL_STUDIO_MLX_PYTHON;
}
+ if (parsed.LOCAL_STUDIO_KUBERAY_API_URL?.trim()) {
+ config.kuberay_api_url = normalizeKubernetesApiUrl(parsed.LOCAL_STUDIO_KUBERAY_API_URL);
+ }
+ if (parsed.LOCAL_STUDIO_KUBERAY_TOKEN_FILE?.trim()) {
+ config.kuberay_token_file = resolve(parsed.LOCAL_STUDIO_KUBERAY_TOKEN_FILE);
+ }
+ if (parsed.LOCAL_STUDIO_KUBERAY_CA_FILE?.trim()) {
+ config.kuberay_ca_file = resolve(parsed.LOCAL_STUDIO_KUBERAY_CA_FILE);
+ }
+ if (parsed.LOCAL_STUDIO_SCIENTIFIC_RECEIPT_SIGNING_KEY?.trim()) {
+ config.scientific_receipt_signing_key =
+ parsed.LOCAL_STUDIO_SCIENTIFIC_RECEIPT_SIGNING_KEY.trim();
+ }
+ if (parsed.LOCAL_STUDIO_ENTERPRISE_AUTH_CONFIG?.trim()) {
+ const authPath = resolve(parsed.LOCAL_STUDIO_ENTERPRISE_AUTH_CONFIG);
+ const authDocument = JSON.parse(readFileSync(authPath, "utf8")) as unknown;
+ config.enterprise_auth = Schema.decodeUnknownSync(EnterpriseAuthConfigSchema)(authDocument);
+ }
- const persisted = loadPersistedConfig(config.data_dir);
+ const providerSecrets = new ProviderSecretStore(
+ config.data_dir,
+ !isLoopbackHost(host) ||
+ (config.enterprise_auth !== undefined && config.enterprise_auth.mode !== "local"),
+ );
+ const persisted = loadPersistedConfig(config.data_dir, providerSecrets);
if (persisted.models_dir) {
config.models_dir = resolve(persisted.models_dir);
}
@@ -177,6 +255,27 @@ export const createConfig = (): Config => {
if (Array.isArray(persisted.providers)) {
config.providers = persisted.providers;
}
+ if (persisted.kubernetes_connection) {
+ const connection = prepareKubernetesConnection(
+ persisted.kubernetes_connection,
+ config.data_dir,
+ {
+ ...(config.kuberay_api_url ? { apiUrl: config.kuberay_api_url } : {}),
+ ...(config.kuberay_token_file ? { tokenFile: config.kuberay_token_file } : {}),
+ ...(config.kuberay_ca_file ? { caFile: config.kuberay_ca_file } : {}),
+ },
+ ).runtime;
+ if (connection.enabled) {
+ config.kuberay_api_url = connection.api_url;
+ config.kuberay_token_file = connection.token_file;
+ if (connection.ca_file) config.kuberay_ca_file = connection.ca_file;
+ else delete config.kuberay_ca_file;
+ } else {
+ delete config.kuberay_api_url;
+ delete config.kuberay_token_file;
+ delete config.kuberay_ca_file;
+ }
+ }
return config;
};
diff --git a/controller/src/config/persisted-config.ts b/controller/src/config/persisted-config.ts
index d42083bef..370723241 100644
--- a/controller/src/config/persisted-config.ts
+++ b/controller/src/config/persisted-config.ts
@@ -1,43 +1,305 @@
+import { randomUUID } from "node:crypto";
import {
chmodSync,
+ closeSync,
existsSync,
+ fsyncSync,
mkdirSync,
+ openSync,
readFileSync,
renameSync,
+ unlinkSync,
writeFileSync,
} from "node:fs";
-import { resolve } from "node:path";
+import { dirname, resolve } from "node:path";
+import {
+ FoundryProjectConnectionSchema,
+ ProviderAuthenticationSchema,
+ type ProviderAuthentication,
+} from "@local-studio/contracts/enterprise-auth";
+import {
+ KubernetesConnectionConfigSchema,
+ type KubernetesConnectionConfig,
+} from "@local-studio/contracts/environment-commissioning";
+import {
+ ScientistProfileSchema,
+ type ScientistProfile,
+} from "@local-studio/contracts/scientist-profile";
+import { Schema } from "effect";
+import { normalizeAdmittedProviderBaseUrl } from "../services/provider-boundary";
+import { normalizeProviderAuthentication } from "../services/provider-authentication";
+import {
+ ProviderSecretStore,
+ newProviderApiKeyReference,
+ newProviderSubscriptionKeyReference,
+ providerApiKeyReference,
+ providerSecretReferenceMatches,
+ providerSubscriptionKeyReference,
+ type ProviderSecretMutation,
+} from "../services/provider-secret-store";
-export interface ProviderConfig {
- id: string;
- name: string;
- base_url: string;
- api_key: string;
- enabled: boolean;
-}
+export const ProviderConfigSchema = Schema.Struct({
+ id: Schema.String,
+ name: Schema.String,
+ base_url: Schema.String,
+ enabled: Schema.Boolean,
+ authentication: ProviderAuthenticationSchema,
+ subscription_key: Schema.optional(
+ Schema.Struct({ header: Schema.String, secret_ref: Schema.String }),
+ ),
+ foundry: Schema.optional(FoundryProjectConnectionSchema),
+ path_style: Schema.optional(Schema.Literals(["openai", "azure"])),
+ api_version: Schema.optional(Schema.String),
+});
+export type ProviderConfig = typeof ProviderConfigSchema.Type;
export interface PersistedConfig {
models_dir?: string;
providers?: ProviderConfig[];
selected_runtime_target_ids?: Partial>;
+ kubernetes_connection?: KubernetesConnectionConfig;
+ scientist_profile?: ScientistProfile;
}
export const getPersistedConfigPath = (dataDirectory: string): string => {
return resolve(dataDirectory, "studio-settings.json");
};
-export const loadPersistedConfig = (dataDirectory: string): PersistedConfig => {
- const path = getPersistedConfigPath(dataDirectory);
- if (!existsSync(path)) {
- return {};
+const validProviderId = (value: string): boolean =>
+ /^[a-z0-9][a-z0-9_-]{0,63}$/u.test(value) && value !== "openai";
+
+const syncDirectory = (path: string): void => {
+ try {
+ const handle = openSync(path, "r");
+ try {
+ fsyncSync(handle);
+ } finally {
+ closeSync(handle);
+ }
+ } catch (source) {
+ const code = (source as NodeJS.ErrnoException).code;
+ if (
+ process.platform === "win32" &&
+ ["EINVAL", "EISDIR", "ENOTSUP", "EPERM"].includes(code ?? "")
+ ) {
+ return;
+ }
+ throw source;
+ }
+};
+
+const decodeProviders = (
+ value: unknown,
+): { providers: ProviderConfig[]; changed: boolean; secretMutations: ProviderSecretMutation[] } => {
+ if (!Array.isArray(value)) {
+ return { providers: [], changed: value !== undefined, secretMutations: [] };
+ }
+ const providers: ProviderConfig[] = [];
+ const ids = new Set();
+ const secretMutations: ProviderSecretMutation[] = [];
+ let changed = false;
+ for (const candidate of value) {
+ try {
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
+ changed = true;
+ continue;
+ }
+ const record = candidate as Record;
+ const id = typeof record["id"] === "string" ? record["id"].trim().toLowerCase() : "";
+ const name = typeof record["name"] === "string" ? record["name"].trim() : "";
+ const baseUrl =
+ typeof record["base_url"] === "string"
+ ? normalizeAdmittedProviderBaseUrl(record["base_url"])
+ : "";
+ if (!validProviderId(id) || !name || !baseUrl || ids.has(id)) {
+ changed = true;
+ continue;
+ }
+ const legacyKey = typeof record["api_key"] === "string" ? record["api_key"].trim() : "";
+ let authentication: ProviderAuthentication;
+ if (record["authentication"] === undefined) {
+ authentication = legacyKey ? { type: "api_key" } : { type: "none" };
+ changed = true;
+ } else {
+ authentication = Schema.decodeUnknownSync(ProviderAuthenticationSchema)(
+ record["authentication"],
+ );
+ }
+ if (authentication.type === "api_key") {
+ const existingReference = providerSecretReferenceMatches(
+ id,
+ authentication.secret_ref,
+ "api-key",
+ )
+ ? authentication.secret_ref
+ : providerApiKeyReference(id);
+ if (authentication.secret_ref !== existingReference) changed = true;
+ if (legacyKey) {
+ const migratedReference = newProviderApiKeyReference(id);
+ secretMutations.push({ ref: migratedReference, value: legacyKey });
+ authentication = { type: "api_key", secret_ref: migratedReference };
+ } else {
+ authentication = { type: "api_key", secret_ref: existingReference };
+ }
+ } else if (legacyKey) {
+ changed = true;
+ }
+ authentication = normalizeProviderAuthentication(id, authentication);
+ if ("api_key" in record) changed = true;
+ let subscriptionKey: { header: string; secret_ref: string } | undefined;
+ const rawSubscriptionKey = record["subscription_key"];
+ if (rawSubscriptionKey && typeof rawSubscriptionKey === "object" && !Array.isArray(rawSubscriptionKey)) {
+ const subRecord = rawSubscriptionKey as Record;
+ const header = typeof subRecord["header"] === "string" ? subRecord["header"].trim() : "";
+ const legacySubscriptionValue = typeof subRecord["value"] === "string" ? subRecord["value"].trim() : "";
+ const existingReference = providerSecretReferenceMatches(
+ id,
+ subRecord["secret_ref"] as string | undefined,
+ "subscription-key",
+ )
+ ? (subRecord["secret_ref"] as string)
+ : providerSubscriptionKeyReference(id);
+ if (header && legacySubscriptionValue) {
+ const migratedReference = newProviderSubscriptionKeyReference(id);
+ secretMutations.push({ ref: migratedReference, value: legacySubscriptionValue });
+ subscriptionKey = { header, secret_ref: migratedReference };
+ changed = true;
+ } else if (header && existingReference !== providerSubscriptionKeyReference(id)) {
+ subscriptionKey = { header, secret_ref: existingReference };
+ } else if (header || legacySubscriptionValue) {
+ changed = true;
+ }
+ }
+ const provider = Schema.decodeUnknownSync(ProviderConfigSchema)({
+ id,
+ name,
+ base_url: baseUrl,
+ enabled: record["enabled"] !== false,
+ authentication,
+ ...(subscriptionKey ? { subscription_key: subscriptionKey } : {}),
+ ...(record["foundry"] === undefined
+ ? {}
+ : {
+ foundry: Schema.decodeUnknownSync(FoundryProjectConnectionSchema)(record["foundry"]),
+ }),
+ ...(typeof record["path_style"] === "string" ? { path_style: record["path_style"] } : {}),
+ ...(typeof record["api_version"] === "string" ? { api_version: record["api_version"] } : {}),
+ });
+ providers.push(provider);
+ ids.add(id);
+ if (record["id"] !== id || record["name"] !== name || record["base_url"] !== baseUrl) {
+ changed = true;
+ }
+ } catch {
+ changed = true;
+ }
+ }
+ return { providers, changed, secretMutations };
+};
+
+const writePersistedConfig = (
+ path: string,
+ dataDirectory: string,
+ config: PersistedConfig,
+): void => {
+ mkdirSync(dataDirectory, { recursive: true, mode: 0o700 });
+ const temporaryPath = `${path}.tmp-${process.pid}-${randomUUID()}`;
+ try {
+ writeFileSync(temporaryPath, JSON.stringify(config, null, 2), { mode: 0o600 });
+ const temporaryHandle = openSync(temporaryPath, "r");
+ try {
+ fsyncSync(temporaryHandle);
+ } finally {
+ closeSync(temporaryHandle);
+ }
+ renameSync(temporaryPath, path);
+ syncDirectory(dirname(path));
+ } catch (error) {
+ if (existsSync(temporaryPath)) unlinkSync(temporaryPath);
+ throw error;
}
+ try {
+ chmodSync(dataDirectory, 0o700);
+ chmodSync(path, 0o600);
+ } catch {}
+};
+
+const activeProviderSecretReferences = (
+ providers: readonly ProviderConfig[],
+): ReadonlySet =>
+ new Set(
+ providers.flatMap((provider) => {
+ const references: string[] = [];
+ if (provider.authentication.type === "api_key" && provider.authentication.secret_ref) {
+ references.push(provider.authentication.secret_ref);
+ }
+ if (provider.subscription_key?.secret_ref) {
+ references.push(provider.subscription_key.secret_ref);
+ }
+ for (const authentication of [provider.authentication, provider.foundry?.authentication]) {
+ if (
+ (authentication?.type === "oidc_user" || authentication?.type === "apim_gateway") &&
+ authentication.token_exchange?.client_secret_ref
+ ) {
+ references.push(authentication.token_exchange.client_secret_ref);
+ }
+ if (authentication?.type === "apim_client" && authentication.client_secret_ref) {
+ references.push(authentication.client_secret_ref);
+ }
+ }
+ return references;
+ }),
+ );
+
+export const loadPersistedConfig = (
+ dataDirectory: string,
+ secretStore = new ProviderSecretStore(dataDirectory, false),
+ reconcileSecrets = true,
+): PersistedConfig => {
+ const path = getPersistedConfigPath(dataDirectory);
+ if (!existsSync(path)) return {};
+ let parsed: PersistedConfig;
try {
const content = readFileSync(path, "utf-8");
- const parsed = JSON.parse(content) as PersistedConfig;
- return parsed && typeof parsed === "object" ? parsed : {};
+ parsed = JSON.parse(content) as PersistedConfig;
+ if (!parsed || typeof parsed !== "object") return {};
} catch {
return {};
}
+ const decodedProviders = decodeProviders((parsed as { providers?: unknown }).providers);
+ if ((parsed as { providers?: unknown }).providers !== undefined) {
+ parsed.providers = decodedProviders.providers;
+ }
+ if (parsed.kubernetes_connection) {
+ try {
+ parsed.kubernetes_connection = Schema.decodeUnknownSync(KubernetesConnectionConfigSchema)(
+ parsed.kubernetes_connection,
+ );
+ } catch {
+ delete parsed.kubernetes_connection;
+ }
+ }
+ if (parsed.scientist_profile) {
+ try {
+ parsed.scientist_profile = Schema.decodeUnknownSync(ScientistProfileSchema)(
+ parsed.scientist_profile,
+ );
+ } catch {
+ delete parsed.scientist_profile;
+ }
+ }
+ const persistMigration = (): void => {
+ if (decodedProviders.changed) writePersistedConfig(path, dataDirectory, parsed);
+ };
+ if (decodedProviders.secretMutations.length > 0) {
+ secretStore.mutateSync(decodedProviders.secretMutations, persistMigration);
+ } else {
+ persistMigration();
+ }
+ if (reconcileSecrets) {
+ secretStore.reconcileSync(activeProviderSecretReferences(decodedProviders.providers));
+ }
+ return parsed;
};
type PersistedConfigUpdates = {
@@ -47,9 +309,10 @@ type PersistedConfigUpdates = {
export const savePersistedConfig = (
dataDirectory: string,
updates: PersistedConfigUpdates,
+ secretStore = new ProviderSecretStore(dataDirectory, false),
): PersistedConfig => {
const path = getPersistedConfigPath(dataDirectory);
- const current = loadPersistedConfig(dataDirectory);
+ const current = loadPersistedConfig(dataDirectory, secretStore, false);
const next: PersistedConfig = { ...current };
const writable = next as Record<
keyof PersistedConfig,
@@ -65,18 +328,7 @@ export const savePersistedConfig = (
writable[key] = value;
}
});
- mkdirSync(dataDirectory, { recursive: true, mode: 0o700 });
- // Write-then-rename so a crash mid-write can't truncate the file — a truncated
- // read is swallowed by loadPersistedConfig, silently resetting models_dir /
- // providers / selected_runtime_target_ids.
- const temporaryPath = `${path}.tmp-${process.pid}`;
- writeFileSync(temporaryPath, JSON.stringify(next, null, 2));
- renameSync(temporaryPath, path);
- try {
- chmodSync(dataDirectory, 0o700);
- chmodSync(path, 0o600);
- } catch {
- // Ignore permission hardening failures on unsupported filesystems.
- }
+ writePersistedConfig(path, dataDirectory, next);
+ secretStore.reconcileSync(activeProviderSecretReferences(next.providers ?? []));
return next;
};
diff --git a/controller/src/core/errors.ts b/controller/src/core/errors.ts
index ef73ac7be..b5c6fba7d 100644
--- a/controller/src/core/errors.ts
+++ b/controller/src/core/errors.ts
@@ -11,5 +11,7 @@ export const notFound = (detail: string): HttpStatus => new HttpStatus({ status:
export const badRequest = (detail: string): HttpStatus => new HttpStatus({ status: 400, detail });
+export const forbidden = (detail: string): HttpStatus => new HttpStatus({ status: 403, detail });
+
export const serviceUnavailable = (detail: string): HttpStatus =>
new HttpStatus({ status: 503, detail });
diff --git a/controller/src/http/app.ts b/controller/src/http/app.ts
index e29d47cbb..96f11766c 100644
--- a/controller/src/http/app.ts
+++ b/controller/src/http/app.ts
@@ -14,13 +14,21 @@ import { registerModelsRoutes } from "../modules/models/routes";
import { registerAllProxyRoutes } from "../modules/proxy/routes";
import { registerStudioRoutes } from "../modules/studio/routes";
import { registerAudioRoutes } from "../modules/audio/routes";
+import { registerScientificWorkbenchRoutes } from "../modules/workbench/routes";
+import { registerExperimentTrackingRoutes } from "../modules/workbench/experiment-routes";
import { registerSpeechRoutes } from "../modules/speech/routes";
+import { registerMachineRoutes } from "../modules/machines/routes";
+import { registerFoundryRoutes } from "../modules/foundry/routes";
+import { registerEnvironmentRoutes } from "../modules/environment/routes";
import { documentRoute, mergeRoutes, type ControllerRouteApp } from "./route-registrar";
import {
createMutatingAuthMiddleware,
createMutatingRateLimitMiddleware,
createReadRateLimitMiddleware,
+ createEnterpriseAuthMiddleware,
} from "./security-middleware";
+import { createSpiffeAuthMiddleware } from "./spiffe-auth";
+import { controllerWorkloadEvidence } from "./spiffe-evidence";
import {
createControllerRequestObservabilityMiddleware,
TELEMETRY_SKIP_PATHS,
@@ -39,7 +47,12 @@ type ControllerApplication = ReturnType &
ReturnType &
ReturnType &
ReturnType &
- ReturnType;
+ ReturnType &
+ ReturnType &
+ ReturnType &
+ ReturnType &
+ ReturnType &
+ ReturnType;
export const createApp = (
context: AppContext,
@@ -55,7 +68,12 @@ export const createApp = (
cors({
origin: (origin) => (allowedCorsOrigins.includes(origin) ? origin : null),
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
- allowHeaders: ["Authorization", "Content-Type", "X-API-Key"],
+ allowHeaders: [
+ "Authorization",
+ "Content-Type",
+ "X-API-Key",
+ "X-Local-Studio-Scientific-Submission-ID",
+ ],
exposeHeaders: [
"X-RateLimit-Limit",
"X-RateLimit-Remaining",
@@ -85,6 +103,8 @@ export const createApp = (
);
app.use("*", createControllerRequestObservabilityMiddleware(context));
+ app.use("*", createSpiffeAuthMiddleware());
+ app.use("*", createEnterpriseAuthMiddleware(context));
app.use("*", createMutatingRateLimitMiddleware(context));
app.use("*", createReadRateLimitMiddleware(context));
app.use("*", createMutatingAuthMiddleware(context));
@@ -97,7 +117,16 @@ export const createApp = (
registerStudioRoutes(app, context),
registerSpeechRoutes(app, context),
registerAudioRoutes(app, context),
+ registerScientificWorkbenchRoutes(app, context),
+ registerExperimentTrackingRoutes(app, context),
+ registerMachineRoutes(app, context),
+ registerFoundryRoutes(app, context),
+ registerEnvironmentRoutes(app, context),
registerAllProxyRoutes(app, context),
+ app.get(
+ "/environment/workload-identity",
+ effectHandler((ctx) => controllerWorkloadEvidence(ctx.req.raw.signal)),
+ ),
app.get(
"/health",
documentRoute,
diff --git a/controller/src/http/effect-handler.ts b/controller/src/http/effect-handler.ts
index 9db582abd..09b671a0b 100644
--- a/controller/src/http/effect-handler.ts
+++ b/controller/src/http/effect-handler.ts
@@ -2,11 +2,19 @@ import type { Context, Handler, MiddlewareHandler, Next, TypedResponse } from "h
import { Cause, Exit, type Effect } from "effect";
import type { AppContextService } from "../app-context";
import type { ControllerRuntime } from "../core/effect-runtime";
+import type { NormalizedPrincipal } from "@local-studio/contracts/enterprise-auth";
+import type { IncomingMessage } from "node:http";
export type ControllerEffect = Effect.Effect;
export type ControllerEnvironment = {
+ Bindings: {
+ incoming?: IncomingMessage;
+ };
Variables: {
controllerRuntime: ControllerRuntime;
+ enterprisePrincipal?: NormalizedPrincipal;
+ enterpriseBearerToken?: string;
+ workloadSpiffeId?: string;
};
};
diff --git a/controller/src/http/enterprise-audit.ts b/controller/src/http/enterprise-audit.ts
new file mode 100644
index 000000000..a0086cd1b
--- /dev/null
+++ b/controller/src/http/enterprise-audit.ts
@@ -0,0 +1,34 @@
+import type { NormalizedPrincipal } from "@local-studio/contracts/enterprise-auth";
+
+type ControllerEnterpriseAuditEvent = {
+ event:
+ | "authorization_denied"
+ | "model_invocation"
+ | "agent_invocation"
+ | "notebook_mutation"
+ | "ray_admission";
+ principal?: NormalizedPrincipal;
+ operation: string;
+ correlation_id?: string;
+ resource_id?: string;
+ reason?: string;
+};
+
+export const emitControllerEnterpriseAudit = (entry: ControllerEnterpriseAuditEvent): void => {
+ const { principal, ...event } = entry;
+ console.info(
+ JSON.stringify({
+ schema: "local-studio.enterprise-audit/v1",
+ timestamp: new Date().toISOString(),
+ ...event,
+ ...(principal
+ ? {
+ subject: principal.subject,
+ issuer_id: principal.issuer_id,
+ tenant: principal.tenant,
+ clearance: principal.clearance,
+ }
+ : {}),
+ }),
+ );
+};
diff --git a/controller/src/http/enterprise-auth.test.ts b/controller/src/http/enterprise-auth.test.ts
new file mode 100644
index 000000000..1922c4c28
--- /dev/null
+++ b/controller/src/http/enterprise-auth.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, test } from "bun:test";
+import type { JWTPayload } from "jose";
+import type { OidcIssuerConfig } from "@local-studio/contracts/enterprise-auth";
+import { normalizePrincipal } from "./enterprise-auth";
+
+const issuer: OidcIssuerConfig = {
+ id: "keycloak",
+ kind: "keycloak",
+ issuer: "https://identity.example.test/realms/science",
+ client_id: "local-studio",
+ audience: "local-studio-api",
+ scopes: ["openid", "profile"],
+ realm: "science",
+ role_claim: "realm_access.roles",
+ group_claim: "groups",
+ role_mappings: {
+ scientist: ["scientist"],
+ administrators: ["platform_admin"],
+ },
+ clearance_mappings: {
+ "c2-science": "C2",
+ },
+};
+
+describe("enterprise identity normalization", () => {
+ test("maps deployment roles and maximum clearance", () => {
+ const principal = normalizePrincipal(
+ {
+ sub: "subject-1",
+ iss: issuer.issuer,
+ aud: issuer.audience,
+ iat: 100,
+ exp: 200,
+ name: "Scientist",
+ realm_access: { roles: ["scientist"] },
+ groups: ["c2-science"],
+ } as JWTPayload,
+ issuer,
+ );
+
+ expect(principal.subject).toBe("subject-1");
+ expect(principal.roles).toEqual(["scientist"]);
+ expect(principal.clearance).toBe("C2");
+ expect(principal.entitlements).toContain("ray:admit");
+ expect(principal.entitlements).not.toContain("configuration:write");
+ });
+
+ test("fails closed when deployment mappings yield no role", () => {
+ expect(() =>
+ normalizePrincipal(
+ {
+ sub: "subject-2",
+ iat: 100,
+ exp: 200,
+ realm_access: { roles: ["unmapped"] },
+ } as JWTPayload,
+ issuer,
+ ),
+ ).toThrow("authorized principal");
+ });
+});
diff --git a/controller/src/http/enterprise-auth.ts b/controller/src/http/enterprise-auth.ts
new file mode 100644
index 000000000..72b5624a8
--- /dev/null
+++ b/controller/src/http/enterprise-auth.ts
@@ -0,0 +1,139 @@
+import type {
+ EnterpriseAuthConfig,
+ EnterpriseEntitlement,
+ EnterpriseRole,
+ NormalizedPrincipal,
+ OidcIssuerConfig,
+} from "@local-studio/contracts/enterprise-auth";
+import { entitlementsForRoles } from "@local-studio/contracts/enterprise-auth";
+import { Effect } from "effect";
+import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
+
+const clearanceRank = { open: 0, internal: 1, C1: 2, C2: 3 } as const;
+
+const stringValues = (value: unknown): string[] => {
+ if (typeof value === "string") return [value];
+ return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
+};
+
+const nestedValues = (payload: JWTPayload, path: string): string[] => {
+ let value: unknown = payload;
+ for (const segment of path.split(".")) {
+ if (!value || typeof value !== "object") return [];
+ value = (value as Record)[segment];
+ }
+ if (value && typeof value === "object" && !Array.isArray(value)) {
+ return Object.values(value as Record).flatMap(stringValues);
+ }
+ return stringValues(value);
+};
+
+export const normalizePrincipal = (
+ payload: JWTPayload,
+ issuer: OidcIssuerConfig,
+): NormalizedPrincipal => {
+ const assignments = [
+ ...nestedValues(payload, issuer.role_claim),
+ ...nestedValues(payload, issuer.group_claim),
+ ];
+ const roles = [
+ ...new Set(
+ assignments.flatMap((assignment) => issuer.role_mappings[assignment] ?? []),
+ ),
+ ] as EnterpriseRole[];
+ const clearances = assignments
+ .map((assignment) => issuer.clearance_mappings[assignment])
+ .filter((value): value is keyof typeof clearanceRank => Boolean(value));
+ const clearance = clearances.reduce(
+ (current, candidate) =>
+ clearanceRank[candidate] > clearanceRank[current] ? candidate : current,
+ "open",
+ );
+ const subject = typeof payload.sub === "string" ? payload.sub : "";
+ if (!subject || roles.length === 0 || !payload.iat || !payload.exp) {
+ throw new Error("OIDC token does not map to an authorized principal");
+ }
+ return {
+ subject,
+ issuer: issuer.issuer,
+ issuer_id: issuer.id,
+ tenant:
+ stringValues(payload["tid"])[0] ??
+ stringValues(payload["tenant"])[0] ??
+ issuer.tenant ??
+ issuer.realm ??
+ "",
+ display_name:
+ stringValues(payload["name"])[0] ??
+ stringValues(payload["preferred_username"])[0] ??
+ subject,
+ ...(stringValues(payload["email"])[0]
+ ? { email: stringValues(payload["email"])[0] }
+ : {}),
+ roles,
+ entitlements: entitlementsForRoles(roles),
+ clearance,
+ issued_at: payload.iat,
+ expires_at: payload.exp,
+ };
+};
+
+export class EnterpriseTokenVerifier {
+ readonly #issuers: EnterpriseAuthConfig["issuers"];
+ readonly #keySets = new Map>();
+
+ public constructor(config: EnterpriseAuthConfig) {
+ this.#issuers = config.issuers;
+ }
+
+ public verify(token: string): Effect.Effect {
+ const parts = token.split(".");
+ if (parts.length !== 3) return Effect.fail(new Error("Bearer token is not a signed JWT"));
+ const payload = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8")) as JWTPayload;
+ const tokenIssuer = typeof payload.iss === "string" ? payload.iss.replace(/\/+$/u, "") : "";
+ const issuer = this.#issuers.find(
+ (candidate) => candidate.issuer.replace(/\/+$/u, "") === tokenIssuer,
+ );
+ if (!issuer) return Effect.fail(new Error("OIDC issuer is not trusted"));
+ const keySets = this.#keySets;
+ let keySet = keySets.get(issuer.id);
+ return Effect.gen(function* () {
+ if (!keySet) {
+ const metadata = yield* Effect.tryPromise({
+ try: () =>
+ fetch(`${issuer.issuer.replace(/\/+$/u, "")}/.well-known/openid-configuration`),
+ catch: (error) => error,
+ });
+ if (!metadata.ok) return yield* Effect.fail(new Error("OIDC discovery failed"));
+ const document: unknown = yield* Effect.tryPromise({
+ try: () => metadata.json(),
+ catch: (error) => error,
+ });
+ if (
+ !document ||
+ typeof document !== "object" ||
+ typeof (document as { jwks_uri?: unknown }).jwks_uri !== "string"
+ ) {
+ return yield* Effect.fail(new Error("OIDC discovery has no JWKS URI"));
+ }
+ keySet = createRemoteJWKSet(new URL((document as { jwks_uri: string }).jwks_uri));
+ keySets.set(issuer.id, keySet);
+ }
+ const verified = yield* Effect.tryPromise({
+ try: () =>
+ jwtVerify(token, keySet!, {
+ issuer: issuer.issuer,
+ audience: issuer.audience,
+ algorithms: ["RS256", "PS256", "ES256"],
+ }),
+ catch: (error) => error,
+ });
+ return normalizePrincipal(verified.payload, issuer);
+ });
+ }
+}
+
+export const hasEntitlement = (
+ principal: NormalizedPrincipal,
+ entitlement: EnterpriseEntitlement,
+): boolean => principal.entitlements.includes(entitlement);
diff --git a/controller/src/http/environment-authorization.test.ts b/controller/src/http/environment-authorization.test.ts
new file mode 100644
index 000000000..229120b2e
--- /dev/null
+++ b/controller/src/http/environment-authorization.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, test } from "bun:test";
+import { requiredEntitlement } from "./security-middleware";
+
+describe("environment authorization policy", () => {
+ test("requires configuration authority for environment reads, probes, and mutations", () => {
+ expect(requiredEntitlement("GET", "/environment/kubernetes")).toBe("configuration:write");
+ expect(requiredEntitlement("POST", "/environment/kubernetes/probe")).toBe(
+ "configuration:write",
+ );
+ expect(requiredEntitlement("PUT", "/environment/kubernetes")).toBe("configuration:write");
+ });
+
+ test("requires configuration authority for provider reads, probes, and mutations", () => {
+ expect(requiredEntitlement("GET", "/studio/providers")).toBe("configuration:write");
+ expect(requiredEntitlement("POST", "/studio/providers/probe")).toBe("configuration:write");
+ expect(requiredEntitlement("PUT", "/studio/providers/tensorprime")).toBe("configuration:write");
+ });
+});
diff --git a/controller/src/http/security-middleware.ts b/controller/src/http/security-middleware.ts
index a28fdc8ef..b5bc80ce8 100644
--- a/controller/src/http/security-middleware.ts
+++ b/controller/src/http/security-middleware.ts
@@ -2,7 +2,13 @@ import { timingSafeEqual } from "node:crypto";
import { Effect } from "effect";
import type { MiddlewareHandler, Next } from "hono";
import type { AppContext } from "../app-context";
+import type {
+ EnterpriseEntitlement,
+ NormalizedPrincipal,
+} from "@local-studio/contracts/enterprise-auth";
import { effectMiddleware } from "./effect-handler";
+import { EnterpriseTokenVerifier, hasEntitlement } from "./enterprise-auth";
+import { emitControllerEnterpriseAudit } from "./enterprise-audit";
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
const PUBLIC_PATHS = new Set(["/health"]);
@@ -86,10 +92,24 @@ const rateLimitKey = (path: string, method: string, clientIp: string): string =>
const nextEffect = (next: Next): Effect.Effect =>
Effect.tryPromise({ try: next, catch: (error) => error });
+export const requiredEntitlement = (method: string, path: string): EnterpriseEntitlement | null => {
+ if (path.startsWith("/environment/")) return "configuration:write";
+ if (path.startsWith("/studio/providers")) return "configuration:write";
+ if (path.startsWith("/ai/v1/agents")) return "agent:invoke";
+ if (path.startsWith("/ai/v1/")) return "model:invoke";
+ if (!path.startsWith("/workbench/")) return null;
+ if (path.includes("/ray-jobs") || path.includes("/compute-leases")) return "ray:admit";
+ if (method === "GET") return "notebook:read";
+ return "notebook:execute";
+};
+
+const isC2 = (principal: NormalizedPrincipal): boolean => principal.clearance === "C2";
+
export function createMutatingAuthMiddleware(context: AppContext): MiddlewareHandler {
return effectMiddleware((ctx, next) =>
Effect.suspend(() => {
if (isPublicRequest(ctx.req.method, ctx.req.path)) return nextEffect(next);
+ if (ctx.get("enterprisePrincipal")) return nextEffect(next);
const expectedApiKey = context.config.api_key?.trim();
if (!expectedApiKey) return nextEffect(next);
const providedToken = extractAuthToken((name) => ctx.req.header(name));
@@ -100,6 +120,78 @@ export function createMutatingAuthMiddleware(context: AppContext): MiddlewareHan
);
}
+export function createEnterpriseAuthMiddleware(context: AppContext): MiddlewareHandler {
+ const config = context.config.enterprise_auth;
+ if (!config || config.mode === "local") {
+ return effectMiddleware((_ctx, next) => nextEffect(next));
+ }
+ const verifier = new EnterpriseTokenVerifier(config);
+ return effectMiddleware((ctx, next) => {
+ if (isPublicRequest(ctx.req.method, ctx.req.path)) return nextEffect(next);
+ const token = extractAuthToken((name) => ctx.req.header(name));
+ if (!token || token.split(".").length !== 3) {
+ if (config.mode === "optional_oidc") return nextEffect(next);
+ ctx.header("WWW-Authenticate", 'Bearer realm="local-studio-enterprise"');
+ return Effect.succeed(ctx.json({ detail: "Enterprise sign-in required" }, { status: 401 }));
+ }
+ return verifier.verify(token).pipe(
+ Effect.catch(() => Effect.succeed(undefined)),
+ Effect.flatMap((principal) => {
+ if (!principal) {
+ ctx.header("WWW-Authenticate", 'Bearer error="invalid_token"');
+ return Effect.succeed(ctx.json({ detail: "Invalid enterprise token" }, { status: 401 }));
+ }
+ ctx.set("enterprisePrincipal", principal);
+ ctx.set("enterpriseBearerToken", token);
+ const entitlement = requiredEntitlement(ctx.req.method, ctx.req.path);
+ if (entitlement && !hasEntitlement(principal, entitlement)) {
+ emitControllerEnterpriseAudit({
+ event: "authorization_denied",
+ principal,
+ operation: `${ctx.req.method} ${ctx.req.path}`,
+ reason: `missing_entitlement:${entitlement}`,
+ });
+ return Effect.succeed(
+ ctx.json({ detail: "Enterprise authorization denied" }, { status: 403 }),
+ );
+ }
+ if (
+ entitlement === "ray:admit" &&
+ (!principal.roles.includes("scientist") || !isC2(principal))
+ ) {
+ emitControllerEnterpriseAudit({
+ event: "authorization_denied",
+ principal,
+ operation: `${ctx.req.method} ${ctx.req.path}`,
+ reason: "ray_requires_scientist_c2",
+ });
+ return Effect.succeed(
+ ctx.json(
+ { detail: "Ray admission requires scientist role and C2 clearance" },
+ { status: 403 },
+ ),
+ );
+ }
+ if (ctx.req.method !== "GET" && ctx.req.path.startsWith("/workbench/notebooks")) {
+ emitControllerEnterpriseAudit({
+ event: "notebook_mutation",
+ principal,
+ operation: `${ctx.req.method} ${ctx.req.path}`,
+ });
+ }
+ if (ctx.req.method !== "GET" && entitlement === "ray:admit") {
+ emitControllerEnterpriseAudit({
+ event: "ray_admission",
+ principal,
+ operation: `${ctx.req.method} ${ctx.req.path}`,
+ });
+ }
+ return nextEffect(next);
+ }),
+ );
+ });
+}
+
export function createMutatingRateLimitMiddleware(
_context: AppContext,
options: { windowMs?: number; maxRequests?: number } = {},
diff --git a/controller/src/http/spiffe-auth.ts b/controller/src/http/spiffe-auth.ts
new file mode 100644
index 000000000..796c8818a
--- /dev/null
+++ b/controller/src/http/spiffe-auth.ts
@@ -0,0 +1,80 @@
+import type { MiddlewareHandler } from "hono";
+import type { TLSSocket } from "node:tls";
+import {
+ loadWorkloadIdentityConfig,
+ resolveX509MtlsMode,
+} from "@local-studio/agent-runtime/spiffe-config";
+import {
+ isWorkloadApiUnavailable,
+ validateJwtSvid,
+} from "@local-studio/agent-runtime/spiffe-workload-api";
+import {
+ readyX509Svid,
+ validateX509RequestProof,
+ validateX509PeerSocket,
+} from "@local-studio/agent-runtime/spiffe-x509";
+import { Effect } from "effect";
+import { effectMiddleware, type ControllerEnvironment } from "./effect-handler";
+
+export const createSpiffeAuthMiddleware = (): MiddlewareHandler => {
+ const config = loadWorkloadIdentityConfig();
+ return effectMiddleware((context, next) =>
+ Effect.tryPromise({
+ try: async () => {
+ if (!config || config.mode === "disabled" || context.req.path === "/health") {
+ return next();
+ }
+ const admittedIds = [config.frontend_id, config.agent_runtime_id];
+ const token = context.req.header("x-spiffe-jwt-svid")?.trim();
+ if (!token) {
+ if (config.mode === "required") {
+ return context.json({ detail: "Workload identity required" }, { status: 401 });
+ }
+ return next();
+ }
+ try {
+ const jwt = await validateJwtSvid(
+ config,
+ config.controller_audience,
+ token,
+ admittedIds,
+ context.req.raw.signal,
+ );
+ const x509Mode = resolveX509MtlsMode(config);
+ if (x509Mode !== "disabled") {
+ const socket = context.env?.incoming?.socket as TLSSocket | undefined;
+ if (!socket?.encrypted) {
+ if (x509Mode === "required") {
+ return context.json({ detail: "mTLS workload identity required" }, { status: 401 });
+ }
+ } else {
+ let peer: string;
+ try {
+ peer = validateX509PeerSocket(socket, admittedIds);
+ } catch (error) {
+ if (socket.authorized !== undefined) throw error;
+ const bundle = await readyX509Svid(config, config.controller_id);
+ peer = validateX509RequestProof(context.req.raw, bundle, admittedIds);
+ }
+ if (peer !== jwt.spiffeId) {
+ return context.json(
+ { detail: "Workload identities do not match" },
+ { status: 401 },
+ );
+ }
+ context.header("X-Local-Studio-mTLS", "observed");
+ }
+ }
+ context.set("workloadSpiffeId", jwt.spiffeId);
+ context.header("X-Local-Studio-Workload-ID", jwt.spiffeId);
+ return next();
+ } catch (error) {
+ return isWorkloadApiUnavailable(error)
+ ? context.json({ detail: "Workload identity service unavailable" }, { status: 503 })
+ : context.json({ detail: "Invalid workload identity" }, { status: 401 });
+ }
+ },
+ catch: (error) => error,
+ }),
+ );
+};
diff --git a/controller/src/http/spiffe-evidence.ts b/controller/src/http/spiffe-evidence.ts
new file mode 100644
index 000000000..3491899a5
--- /dev/null
+++ b/controller/src/http/spiffe-evidence.ts
@@ -0,0 +1,53 @@
+import { loadWorkloadIdentityConfig } from "@local-studio/agent-runtime/spiffe-config";
+import { fetchJwtSvid } from "@local-studio/agent-runtime/spiffe-workload-api";
+import { fetchWithX509Svid } from "@local-studio/agent-runtime/spiffe-x509";
+import { Effect } from "effect";
+
+export const controllerWorkloadEvidence = (signal: AbortSignal): Effect.Effect => {
+ const config = loadWorkloadIdentityConfig();
+ if (!config || config.mode === "disabled") {
+ return Effect.succeed(Response.json({ configured: false, observed: false }));
+ }
+ return Effect.tryPromise({
+ try: async () => {
+ const identity = await fetchJwtSvid(
+ config,
+ config.agent_runtime_audience,
+ config.controller_id,
+ signal,
+ );
+ const runtime = (
+ process.env["LOCAL_STUDIO_AGENT_RUNTIME_URL"] ?? "http://127.0.0.1:8081"
+ ).replace(/\/+$/u, "");
+ const response = await fetchWithX509Svid(
+ config,
+ config.controller_id,
+ config.agent_runtime_id,
+ `${runtime}/ready`,
+ {
+ headers: { "x-spiffe-jwt-svid": identity.svid },
+ signal,
+ },
+ );
+ if (!response.ok) throw new Error("Agent runtime rejected controller workload identity");
+ return Response.json({
+ configured: true,
+ observed: true,
+ source: config.controller_id,
+ destination: config.agent_runtime_id,
+ jwt_svid: true,
+ x509_mtls: config.x509_mtls === "required",
+ });
+ },
+ catch: (error) => error,
+ }).pipe(
+ Effect.catch(() =>
+ Effect.succeed(
+ Response.json(
+ { configured: true, observed: false },
+ { status: config.mode === "required" ? 503 : 200 },
+ ),
+ ),
+ ),
+ );
+};
diff --git a/controller/src/main.ts b/controller/src/main.ts
index 14387864d..e3a4e104e 100644
--- a/controller/src/main.ts
+++ b/controller/src/main.ts
@@ -1,11 +1,20 @@
import { Cause, Effect, Exit, Fiber, Schema } from "effect";
+import { createAdaptorServer } from "@hono/node-server";
+import { createServer as createHttpsServer } from "node:https";
import { startComputeSupervisor } from "./modules/compute/supervisor";
+import { startWorkbenchReconciler } from "./modules/workbench/reconciler";
import { AppContextService, getModelsDirectoryState, type AppContext } from "./app-context";
import { createControllerRuntime, type ControllerRuntime } from "./core/effect-runtime";
import { parseBooleanFlag } from "./core/validation";
import { createApp } from "./http/app";
import { startMetricsCollector } from "./modules/system/metrics-collector";
import { detectGpuMonitoringTool } from "./modules/system/platform/gpu";
+import {
+ loadWorkloadIdentityConfig,
+ resolveX509MtlsMode,
+} from "@local-studio/agent-runtime/spiffe-config";
+import { X509SvidSource, spiffeServerTlsOptions } from "@local-studio/agent-runtime/spiffe-x509";
+import type { WorkloadIdentityConfig } from "@local-studio/contracts/workload-identity";
class ControllerStartupError extends Schema.TaggedErrorClass()(
"ControllerStartupError",
@@ -41,23 +50,78 @@ const logBootSummary = (context: AppContext, port: number): Effect.Effect
Effect.asVoid,
);
-const serve = (
+type ControllerServer = {
+ port: number;
+ stop: () => Promise;
+};
+
+const secureServer = (
+ app: ReturnType,
context: AppContext,
- runtime: ControllerRuntime,
-): Effect.Effect, ControllerStartupError> =>
- Effect.try({
- try: () => {
- const app = createApp(context, runtime);
- return Bun.serve({
- port: context.config.port,
- hostname: context.config.host,
+ workload: WorkloadIdentityConfig,
+): Effect.Effect =>
+ Effect.tryPromise({
+ try: async () => {
+ const source = new X509SvidSource(workload, workload.controller_id);
+ source.start();
+ const snapshot = await source.ready();
+ const server = createAdaptorServer({
fetch: app.fetch,
- idleTimeout: 120,
+ hostname: context.config.host,
+ createServer: createHttpsServer,
+ serverOptions: spiffeServerTlsOptions(snapshot),
+ });
+ const unsubscribe = source.subscribe((next) => {
+ if (!next) {
+ (server as { closeAllConnections?: () => void }).closeAllConnections?.();
+ server.close(() => process.exit(1));
+ return;
+ }
+ if ("setSecureContext" in server) server.setSecureContext(spiffeServerTlsOptions(next));
+ });
+ await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(context.config.port, context.config.host, () => {
+ server.off("error", reject);
+ resolve();
+ });
});
+ const address = server.address();
+ return {
+ port: typeof address === "object" && address ? address.port : context.config.port,
+ stop: () =>
+ new Promise((resolve, reject) => {
+ unsubscribe();
+ source.stop();
+ server.close((error) => (error ? reject(error) : resolve()));
+ }),
+ };
},
- catch: (source) => startupError("server.start", source),
+ catch: (error) => error,
});
+const serve = (
+ context: AppContext,
+ runtime: ControllerRuntime,
+): Effect.Effect =>
+ Effect.suspend(() => {
+ const app = createApp(context, runtime);
+ const workload = loadWorkloadIdentityConfig();
+ if (workload && resolveX509MtlsMode(workload) !== "disabled") {
+ return secureServer(app, context, workload);
+ }
+ const server = Bun.serve({
+ port: context.config.port,
+ hostname: context.config.host,
+ fetch: app.fetch,
+ idleTimeout: 120,
+ });
+ return Effect.succeed({
+ port: server.port ?? context.config.port,
+ stop: () => server.stop(),
+ });
+ }).pipe(Effect.mapError((source) => startupError("server.start", source)));
+
const runtime = createControllerRuntime();
const program = Effect.scoped(
Effect.gen(function* () {
@@ -74,6 +138,9 @@ const program = Effect.scoped(
),
);
}
+ {
+ yield* Effect.forkScoped(startWorkbenchReconciler(context));
+ }
const server = yield* Effect.acquireRelease(serve(context, runtime), (resource) =>
Effect.tryPromise({
try: () => resource.stop(),
@@ -87,7 +154,7 @@ const program = Effect.scoped(
),
);
context.logger.info(`Controller listening on ${context.config.host}:${server.port}`);
- yield* logBootSummary(context, server.port ?? context.config.port);
+ yield* logBootSummary(context, server.port);
return yield* Effect.never;
}),
);
diff --git a/controller/src/modules/environment/configuration.ts b/controller/src/modules/environment/configuration.ts
new file mode 100644
index 000000000..52044fc0a
--- /dev/null
+++ b/controller/src/modules/environment/configuration.ts
@@ -0,0 +1,230 @@
+import { lstatSync, realpathSync, statSync } from "node:fs";
+import { isAbsolute, relative, resolve } from "node:path";
+import type { KubernetesConnectionConfig } from "@local-studio/contracts/environment-commissioning";
+
+type CredentialKind = "token" | "ca";
+
+type CredentialRoot = {
+ id: "controller" | "kubernetes";
+ path: string;
+ allowSymlink: boolean;
+ restrictiveTokenMode: boolean;
+};
+
+export type PreparedKubernetesConnection = {
+ runtime: KubernetesConnectionConfig;
+ persisted: KubernetesConnectionConfig;
+ response: KubernetesConnectionConfig;
+};
+
+const configuredRoot = (dataDirectory: string): CredentialRoot => ({
+ id: "controller",
+ path: resolve(dataDirectory, "credentials"),
+ allowSymlink: false,
+ restrictiveTokenMode: true,
+});
+
+const kubernetesRoot = (): CredentialRoot => ({
+ id: "kubernetes",
+ path: "/var/run/secrets/kubernetes.io/serviceaccount",
+ allowSymlink: true,
+ restrictiveTokenMode: false,
+});
+
+const roots = (dataDirectory: string): CredentialRoot[] => [
+ configuredRoot(dataDirectory),
+ kubernetesRoot(),
+];
+
+const isContained = (root: string, candidate: string): boolean => {
+ const child = relative(root, candidate);
+ return child === "" || (!child.startsWith("..") && !isAbsolute(child));
+};
+
+const containedByRoot = (root: CredentialRoot, candidate: string): boolean => {
+ try {
+ return isContained(realpathSync(root.path), realpathSync(candidate));
+ } catch {
+ return isContained(resolve(root.path), resolve(candidate));
+ }
+};
+
+const publicReference = (
+ path: string,
+ kind: CredentialKind,
+ dataDirectory: string,
+): string => {
+ for (const root of roots(dataDirectory)) {
+ if (!containedByRoot(root, path)) continue;
+ const lexicalChild = relative(resolve(root.path), resolve(path));
+ const child =
+ !lexicalChild.startsWith("..") && !isAbsolute(lexicalChild)
+ ? lexicalChild
+ : relative(realpathSync(root.path), realpathSync(path));
+ if (!child.startsWith("..") && !isAbsolute(child)) return `${root.id}:${child}`;
+ }
+ return `existing:${kind}`;
+};
+
+const pathFromReference = (
+ reference: string,
+ kind: CredentialKind,
+ dataDirectory: string,
+ currentPath: string | undefined,
+): { path: string; root: CredentialRoot } => {
+ const value = reference.trim();
+ if (value === `existing:${kind}` && currentPath) {
+ const matchingRoot = roots(dataDirectory).find((root) => containedByRoot(root, currentPath));
+ if (matchingRoot) return { path: resolve(currentPath), root: matchingRoot };
+ throw new Error(`Existing Kubernetes ${kind} reference cannot be commissioned`);
+ }
+ for (const root of roots(dataDirectory)) {
+ const prefix = `${root.id}:`;
+ if (!value.startsWith(prefix)) continue;
+ const child = value.slice(prefix.length);
+ if (!child || isAbsolute(child)) throw new Error(`Invalid Kubernetes ${kind} reference`);
+ const path = resolve(root.path, child);
+ if (!isContained(resolve(root.path), path)) {
+ throw new Error(`Kubernetes ${kind} reference escapes its credential root`);
+ }
+ return { path, root };
+ }
+ if (!isAbsolute(value)) throw new Error(`Kubernetes ${kind} reference is invalid`);
+ const matchingRoot = roots(dataDirectory).find((root) => containedByRoot(root, value));
+ if (!matchingRoot) {
+ throw new Error(
+ `Kubernetes ${kind} must be stored under the controller credential root or projected service-account root`,
+ );
+ }
+ return { path: resolve(value), root: matchingRoot };
+};
+
+const validateCredentialFile = (
+ path: string,
+ root: CredentialRoot,
+ kind: CredentialKind,
+): string => {
+ let rootPath: string;
+ let link;
+ let canonicalPath: string;
+ try {
+ rootPath = realpathSync(root.path);
+ link = lstatSync(path);
+ canonicalPath = realpathSync(path);
+ } catch {
+ throw new Error(`Kubernetes ${kind} credential reference is unavailable`);
+ }
+ if (!root.allowSymlink && link.isSymbolicLink()) {
+ throw new Error(`Kubernetes ${kind} credential cannot be a symbolic link`);
+ }
+ if (!isContained(rootPath, canonicalPath)) {
+ throw new Error(`Kubernetes ${kind} credential escapes its credential root`);
+ }
+ const file = statSync(canonicalPath);
+ if (!file.isFile()) throw new Error(`Kubernetes ${kind} credential must be a regular file`);
+ if (
+ kind === "token" &&
+ root.restrictiveTokenMode &&
+ process.platform !== "win32" &&
+ (file.mode & 0o077) !== 0
+ ) {
+ throw new Error("Controller-owned Kubernetes token must not be group or world accessible");
+ }
+ return root.allowSymlink ? resolve(path) : canonicalPath;
+};
+
+export const normalizeKubernetesApiUrl = (value: string): string => {
+ let endpoint: URL;
+ try {
+ endpoint = new URL(value.trim());
+ } catch {
+ throw new Error("Kubernetes API URL must be an absolute HTTP or HTTPS URL");
+ }
+ const loopback = ["localhost", "127.0.0.1", "::1"].includes(endpoint.hostname);
+ if (endpoint.protocol !== "https:" && !(endpoint.protocol === "http:" && loopback)) {
+ throw new Error("Kubernetes API URL must use HTTPS unless it targets loopback");
+ }
+ if (endpoint.username || endpoint.password) {
+ throw new Error("Kubernetes API URL must not contain user information");
+ }
+ if (endpoint.search || endpoint.hash) {
+ throw new Error("Kubernetes API URL must not contain a query or fragment");
+ }
+ if (endpoint.pathname !== "/" && endpoint.pathname !== "") {
+ throw new Error("Kubernetes API URL must not contain a base path");
+ }
+ endpoint.pathname = "";
+ return endpoint.toString().replace(/\/+$/u, "");
+};
+
+export const prepareKubernetesConnection = (
+ input: KubernetesConnectionConfig,
+ dataDirectory: string,
+ current?: { apiUrl?: string; tokenFile?: string; caFile?: string },
+): PreparedKubernetesConnection => {
+ if (!input.enabled) {
+ const disabled = { enabled: false, api_url: "", token_file: "", ca_file: null };
+ return { runtime: disabled, persisted: disabled, response: disabled };
+ }
+ const apiUrl = normalizeKubernetesApiUrl(input.api_url);
+ const preservingExistingToken = input.token_file.trim() === "existing:token";
+ const preservingExistingCa = input.ca_file?.trim() === "existing:ca";
+ if (
+ (preservingExistingToken || preservingExistingCa) &&
+ (!current?.apiUrl || normalizeKubernetesApiUrl(current.apiUrl) !== apiUrl)
+ ) {
+ throw new Error("Existing environment credentials cannot be redirected to another endpoint");
+ }
+ const tokenFile =
+ preservingExistingToken && current?.tokenFile
+ ? current.tokenFile
+ : ((): string => {
+ const candidate = pathFromReference(
+ input.token_file,
+ "token",
+ dataDirectory,
+ current?.tokenFile,
+ );
+ return validateCredentialFile(candidate.path, candidate.root, "token");
+ })();
+ const caFile =
+ preservingExistingCa && current?.caFile
+ ? current.caFile
+ : input.ca_file
+ ? ((): string => {
+ const candidate = pathFromReference(
+ input.ca_file,
+ "ca",
+ dataDirectory,
+ current?.caFile,
+ );
+ return validateCredentialFile(candidate.path, candidate.root, "ca");
+ })()
+ : null;
+ const runtime = {
+ enabled: true,
+ api_url: apiUrl,
+ token_file: tokenFile,
+ ca_file: caFile,
+ };
+ const response = {
+ enabled: true,
+ api_url: apiUrl,
+ token_file: publicReference(tokenFile, "token", dataDirectory),
+ ca_file: caFile ? publicReference(caFile, "ca", dataDirectory) : null,
+ };
+ return { runtime, persisted: response, response };
+};
+
+export const responseKubernetesConnection = (
+ configuration: KubernetesConnectionConfig,
+ dataDirectory: string,
+): KubernetesConnectionConfig => ({
+ ...configuration,
+ token_file: configuration.token_file
+ ? publicReference(configuration.token_file, "token", dataDirectory)
+ : "",
+ ca_file: configuration.ca_file
+ ? publicReference(configuration.ca_file, "ca", dataDirectory)
+ : null,
+});
diff --git a/controller/src/modules/environment/routes.ts b/controller/src/modules/environment/routes.ts
new file mode 100644
index 000000000..91a5f6c36
--- /dev/null
+++ b/controller/src/modules/environment/routes.ts
@@ -0,0 +1,173 @@
+import {
+ KubernetesConnectionConfigSchema,
+ type KubernetesConnectionConfig,
+ type KubernetesConnectionProbe,
+ type KubernetesConnectionState,
+} from "@local-studio/contracts/environment-commissioning";
+import { Effect } from "effect";
+import { badRequest, serviceUnavailable } from "../../core/errors";
+import { decodeJsonBody } from "../../core/validation";
+import { savePersistedConfig } from "../../config/persisted-config";
+import { effectHandler } from "../../http/effect-handler";
+import { documentRoute, defineRoutes, mergeRoutes } from "../../http/route-registrar";
+import { KubeRayGateway } from "../workbench/kuberay-gateway";
+import {
+ prepareKubernetesConnection,
+ responseKubernetesConnection,
+} from "./configuration";
+
+const unconfiguredProbe = (): KubernetesConnectionProbe => ({
+ state: "unconfigured",
+ checked_at: null,
+ kubernetes_version: null,
+ ray_api_version: null,
+ detail: "No Kubernetes connection is enabled.",
+});
+
+const configuredProbe = (): KubernetesConnectionProbe => ({
+ state: "claimed",
+ checked_at: null,
+ kubernetes_version: null,
+ ray_api_version: null,
+ detail: "Connection metadata is saved. Run the probe to establish live evidence.",
+});
+
+const configurationFromContext = (context: {
+ config: {
+ data_dir: string;
+ kuberay_api_url?: string;
+ kuberay_token_file?: string;
+ kuberay_ca_file?: string;
+ };
+}): KubernetesConnectionConfig =>
+ responseKubernetesConnection(
+ {
+ enabled: Boolean(context.config.kuberay_api_url && context.config.kuberay_token_file),
+ api_url: context.config.kuberay_api_url ?? "",
+ token_file: context.config.kuberay_token_file ?? "",
+ ca_file: context.config.kuberay_ca_file ?? null,
+ },
+ context.config.data_dir,
+ );
+
+const gatewayFor = (configuration: KubernetesConnectionConfig): KubeRayGateway | null =>
+ configuration.enabled
+ ? new KubeRayGateway({
+ apiUrl: configuration.api_url.replace(/\/+$/u, ""),
+ tokenFile: configuration.token_file,
+ ...(configuration.ca_file ? { caFile: configuration.ca_file } : {}),
+ })
+ : null;
+
+const observedState = (
+ configuration: KubernetesConnectionConfig,
+ result: { kubernetesVersion: string; rayApiVersion: string },
+): KubernetesConnectionState => ({
+ configuration,
+ probe: {
+ state: "observed",
+ checked_at: new Date().toISOString(),
+ kubernetes_version: result.kubernetesVersion,
+ ray_api_version: result.rayApiVersion,
+ detail: "Kubernetes and the RayJob API responded with validated documents.",
+ },
+});
+
+const contradictedState = (
+ configuration: KubernetesConnectionConfig,
+): KubernetesConnectionState => ({
+ configuration,
+ probe: {
+ state: "contradicted",
+ checked_at: new Date().toISOString(),
+ kubernetes_version: null,
+ ray_api_version: null,
+ detail: "Cluster probe failed. Verify endpoint reachability and controller credential references.",
+ },
+});
+
+export const registerEnvironmentRoutes = defineRoutes((app, context) =>
+ mergeRoutes(
+ app.get(
+ "/environment/kubernetes",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.succeed(
+ ctx.json({
+ configuration: configurationFromContext(context),
+ probe: context.kubeRayGateway ? configuredProbe() : unconfiguredProbe(),
+ } satisfies KubernetesConnectionState),
+ ),
+ ),
+ ),
+ app.put(
+ "/environment/kubernetes",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const configuration = yield* decodeJsonBody(ctx, KubernetesConnectionConfigSchema);
+ const prepared = yield* Effect.try({
+ try: () =>
+ prepareKubernetesConnection(configuration, context.config.data_dir, {
+ ...(context.config.kuberay_api_url
+ ? { apiUrl: context.config.kuberay_api_url }
+ : {}),
+ ...(context.config.kuberay_token_file
+ ? { tokenFile: context.config.kuberay_token_file }
+ : {}),
+ ...(context.config.kuberay_ca_file
+ ? { caFile: context.config.kuberay_ca_file }
+ : {}),
+ }),
+ catch: (error) =>
+ badRequest(error instanceof Error ? error.message : "Invalid Kubernetes connection"),
+ });
+ yield* Effect.try({
+ try: () =>
+ savePersistedConfig(context.config.data_dir, {
+ kubernetes_connection: prepared.persisted,
+ }),
+ catch: () => serviceUnavailable("Kubernetes configuration could not be saved"),
+ });
+ context.kubeRayGateway = gatewayFor(prepared.runtime);
+ if (prepared.runtime.enabled) {
+ context.config.kuberay_api_url = prepared.runtime.api_url;
+ context.config.kuberay_token_file = prepared.runtime.token_file;
+ if (prepared.runtime.ca_file) {
+ context.config.kuberay_ca_file = prepared.runtime.ca_file;
+ } else {
+ delete context.config.kuberay_ca_file;
+ }
+ } else {
+ delete context.config.kuberay_api_url;
+ delete context.config.kuberay_token_file;
+ delete context.config.kuberay_ca_file;
+ }
+ return ctx.json({
+ configuration: prepared.response,
+ probe: prepared.runtime.enabled ? configuredProbe() : unconfiguredProbe(),
+ } satisfies KubernetesConnectionState);
+ }),
+ ),
+ ),
+ app.post(
+ "/environment/kubernetes/probe",
+ documentRoute,
+ effectHandler((ctx) => {
+ const configuration = configurationFromContext(context);
+ if (!context.kubeRayGateway) {
+ return Effect.succeed(
+ ctx.json({
+ configuration,
+ probe: unconfiguredProbe(),
+ } satisfies KubernetesConnectionState),
+ );
+ }
+ return context.kubeRayGateway.probe().pipe(
+ Effect.map((result) => ctx.json(observedState(configuration, result))),
+ Effect.catch(() => Effect.succeed(ctx.json(contradictedState(configuration)))),
+ );
+ }),
+ ),
+ ),
+);
diff --git a/controller/src/modules/foundry/adapter.ts b/controller/src/modules/foundry/adapter.ts
new file mode 100644
index 000000000..843720b26
--- /dev/null
+++ b/controller/src/modules/foundry/adapter.ts
@@ -0,0 +1,228 @@
+import { randomUUID } from "node:crypto";
+import type {
+ EnterpriseAuthConfig,
+ EnterpriseEntitlement,
+ NormalizedPrincipal,
+} from "@local-studio/contracts/enterprise-auth";
+import {
+ FoundryCatalogSchema,
+ type FoundryCatalogView,
+ type FoundryUsage,
+} from "@local-studio/contracts/foundry";
+import { Effect, Schema, Stream } from "effect";
+import type { ProviderConfig } from "../../config/persisted-config";
+import { HttpStatus, badRequest, forbidden, notFound, serviceUnavailable } from "../../core/errors";
+import { hasEntitlement } from "../../http/enterprise-auth";
+import { readBoundedRequestBody, RequestBodyTooLargeError } from "../../http/bounded-body";
+
+export const FOUNDRY_REQUEST_LIMIT_BYTES = 1024 * 1024;
+export const FOUNDRY_CATALOG_LIMIT_BYTES = 2 * 1024 * 1024;
+
+const clearanceRank = { open: 0, internal: 1, C1: 2, C2: 3 } as const;
+
+type FoundryRequest = {
+ provider: ProviderConfig;
+ path: string;
+ token: string;
+ method?: string;
+ body?: string;
+ signal?: AbortSignal;
+ correlationId?: string;
+ accept?: string;
+};
+
+export const selectFoundryProvider = (
+ providers: readonly ProviderConfig[],
+ requested?: string,
+): ProviderConfig => {
+ const candidates = providers.filter((provider) => provider.enabled && provider.foundry);
+ const provider = requested
+ ? candidates.find((candidate) => candidate.id === requested)
+ : candidates.length === 1
+ ? candidates[0]
+ : undefined;
+ if (!provider) {
+ throw notFound(
+ requested
+ ? "Microsoft Foundry connection not found"
+ : "Select exactly one configured Microsoft Foundry connection",
+ );
+ }
+ return provider;
+};
+
+export const enforceFoundryPrincipal = (
+ provider: ProviderConfig,
+ principal: NormalizedPrincipal | undefined,
+ enterprise: EnterpriseAuthConfig | undefined,
+ entitlement: EnterpriseEntitlement,
+): NormalizedPrincipal => {
+ if (!principal) throw forbidden("Validated enterprise identity is required for Foundry");
+ if (!hasEntitlement(principal, entitlement)) {
+ throw forbidden(`${entitlement} entitlement is required`);
+ }
+ if (clearanceRank[principal.clearance] < clearanceRank.C2) {
+ throw forbidden("C2 clearance is required for Foundry");
+ }
+ const authentication = provider.foundry?.authentication;
+ if (authentication?.type !== "apim_gateway") {
+ throw forbidden("Foundry connection must use APIM gateway authentication");
+ }
+ if (authentication.issuer_id !== principal.issuer_id) {
+ throw forbidden("Foundry connection does not admit this issuer");
+ }
+ const issuer = enterprise?.issuers.find((candidate) => candidate.id === principal.issuer_id);
+ const expectedTenant = issuer?.tenant ?? issuer?.realm;
+ if (!expectedTenant || principal.tenant !== expectedTenant) {
+ throw forbidden("Foundry connection does not admit this tenant");
+ }
+ return principal;
+};
+
+export const bearerToken = (header: string | undefined): string => {
+ const match = header?.match(/^Bearer\s+(.+)$/iu);
+ if (!match?.[1]) throw forbidden("Enterprise bearer token required");
+ return match[1];
+};
+
+const boundedResponseText = (response: Response, limit: number): Effect.Effect => {
+ if (!response.body) return Effect.succeed("");
+ return Stream.fromReadableStream({
+ evaluate: () => response.body!,
+ onError: (error) => error,
+ }).pipe(
+ Stream.runFoldEffect(
+ () => ({ size: 0, chunks: [] as Uint8Array[] }),
+ (state, chunk) => {
+ const size = state.size + chunk.byteLength;
+ return size > limit
+ ? Effect.fail(serviceUnavailable("APIM catalog exceeded the response limit"))
+ : Effect.succeed({ size, chunks: [...state.chunks, chunk] });
+ },
+ ),
+ Effect.map(({ chunks }) =>
+ new TextDecoder().decode(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)))),
+ ),
+ );
+};
+
+const gatewayFailure = (status: number): HttpStatus => {
+ if (status === 401) return new HttpStatus({ status: 401, detail: "APIM authentication failed" });
+ if (status === 403) return forbidden("APIM authorization denied");
+ if (status === 413) return new HttpStatus({ status: 413, detail: "APIM request limit exceeded" });
+ if (status === 429) return new HttpStatus({ status: 429, detail: "APIM quota exceeded" });
+ return serviceUnavailable(`APIM gateway returned ${status}`);
+};
+
+export const requestFoundryGateway = (
+ request: FoundryRequest,
+): Effect.Effect<{ response: Response; correlationId: string }, unknown> =>
+ Effect.tryPromise({
+ try: async () => {
+ const gateway = request.provider.foundry!.gateway_url.replace(/\/+$/u, "");
+ const correlationId = request.correlationId ?? randomUUID();
+ const response = await fetch(`${gateway}${request.path}`, {
+ method: request.method ?? "GET",
+ headers: {
+ Accept: request.accept ?? "application/json",
+ Authorization: `Bearer ${request.token}`,
+ "Content-Type": "application/json",
+ "X-Correlation-ID": correlationId,
+ },
+ ...(request.body === undefined ? {} : { body: request.body }),
+ redirect: "manual",
+ signal: request.signal ?? AbortSignal.timeout(30_000),
+ });
+ if (response.status >= 300 && response.status < 400) {
+ await response.body?.cancel();
+ throw serviceUnavailable("APIM gateway redirect denied");
+ }
+ if (!response.ok) {
+ await response.body?.cancel();
+ throw gatewayFailure(response.status);
+ }
+ const receivedCorrelationId = response.headers.get("x-correlation-id")?.trim() ?? "";
+ return {
+ response,
+ correlationId: /^[A-Za-z0-9._:-]{1,256}$/u.test(receivedCorrelationId)
+ ? receivedCorrelationId
+ : correlationId,
+ };
+ },
+ catch: (error) => error,
+ });
+
+export const readFoundryRequest = (
+ request: Request,
+ schema: Schema.Codec,
+): Effect.Effect =>
+ readBoundedRequestBody(request, FOUNDRY_REQUEST_LIMIT_BYTES).pipe(
+ Effect.mapError((error) =>
+ error instanceof RequestBodyTooLargeError
+ ? new HttpStatus({ status: 413, detail: error.message })
+ : badRequest("Invalid payload"),
+ ),
+ Effect.flatMap((body) =>
+ Effect.try({
+ try: () => JSON.parse(new TextDecoder().decode(body)) as unknown,
+ catch: () => badRequest("Invalid payload"),
+ }),
+ ),
+ Effect.flatMap(Schema.decodeUnknownEffect(schema)),
+ Effect.mapError((error) =>
+ error instanceof HttpStatus ? error : badRequest("Invalid payload"),
+ ),
+ );
+
+export const fetchFoundryCatalog = (
+ provider: ProviderConfig,
+ token: string,
+ kind: "models" | "agents",
+ signal?: AbortSignal,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const result = yield* requestFoundryGateway({
+ provider,
+ path: `/ai/v1/${kind}`,
+ token,
+ ...(signal ? { signal } : {}),
+ });
+ const text = yield* boundedResponseText(result.response, FOUNDRY_CATALOG_LIMIT_BYTES);
+ const decoded = yield* Schema.decodeUnknownEffect(FoundryCatalogSchema)(
+ yield* Effect.try({
+ try: () => JSON.parse(text) as unknown,
+ catch: () => serviceUnavailable(`APIM ${kind} catalog was not valid JSON`),
+ }),
+ ).pipe(Effect.mapError(() => serviceUnavailable(`APIM ${kind} catalog was malformed`)));
+ const allowed = new Set(
+ kind === "models" ? provider.foundry!.allowed_models : provider.foundry!.allowed_agents,
+ );
+ return {
+ object: "list",
+ data: decoded.data.filter((entry) => allowed.has(entry.id)),
+ provider_id: provider.id,
+ correlation_id: result.correlationId,
+ observed_at: new Date().toISOString(),
+ };
+ });
+
+export const usageFromHeaders = (headers: Headers): FoundryUsage | undefined => {
+ const number = (name: string): number | undefined => {
+ const raw = headers.get(name)?.trim();
+ if (!raw) return undefined;
+ const value = Number(raw);
+ return Number.isFinite(value) && value >= 0 ? value : undefined;
+ };
+ const usage = {
+ input_tokens: number("x-ms-input-tokens"),
+ output_tokens: number("x-ms-output-tokens"),
+ total_tokens: number("x-ms-total-tokens"),
+ };
+ return Object.values(usage).some((value) => value !== undefined)
+ ? {
+ ...(usage.input_tokens === undefined ? {} : { input_tokens: usage.input_tokens }),
+ ...(usage.output_tokens === undefined ? {} : { output_tokens: usage.output_tokens }),
+ ...(usage.total_tokens === undefined ? {} : { total_tokens: usage.total_tokens }),
+ }
+ : undefined;
+};
diff --git a/controller/src/modules/foundry/evidence.ts b/controller/src/modules/foundry/evidence.ts
new file mode 100644
index 000000000..6397026ae
--- /dev/null
+++ b/controller/src/modules/foundry/evidence.ts
@@ -0,0 +1,29 @@
+import type { NormalizedPrincipal } from "@local-studio/contracts/enterprise-auth";
+import type { FoundryUsage } from "@local-studio/contracts/foundry";
+
+type FoundryEvidence = {
+ event: "catalog_observed" | "model_invocation" | "agent_invocation";
+ principal: NormalizedPrincipal;
+ operation: string;
+ correlation_id: string;
+ provider_id: string;
+ resource_id?: string;
+ status: number;
+ usage?: FoundryUsage;
+};
+
+export const emitFoundryEvidence = (entry: FoundryEvidence): void => {
+ const { principal, ...evidence } = entry;
+ console.info(
+ JSON.stringify({
+ schema: "local-studio.foundry-evidence/v1",
+ timestamp: new Date().toISOString(),
+ ...evidence,
+ subject: principal.subject,
+ issuer: principal.issuer,
+ issuer_id: principal.issuer_id,
+ tenant: principal.tenant,
+ clearance: principal.clearance,
+ }),
+ );
+};
diff --git a/controller/src/modules/foundry/routes.ts b/controller/src/modules/foundry/routes.ts
new file mode 100644
index 000000000..4d481b092
--- /dev/null
+++ b/controller/src/modules/foundry/routes.ts
@@ -0,0 +1,379 @@
+import { Effect, Schema } from "effect";
+import type {
+ EnterpriseEntitlement,
+ NormalizedPrincipal,
+} from "@local-studio/contracts/enterprise-auth";
+import type { AppContext } from "../../app-context";
+import type { ProviderConfig } from "../../config/persisted-config";
+import { badRequest, forbidden } from "../../core/errors";
+import { documentRoute, defineRoutes, mergeRoutes } from "../../http/route-registrar";
+import { effectHandler } from "../../http/effect-handler";
+import { resolveProviderHeaders } from "../../services/provider-authentication";
+import {
+ bearerToken,
+ enforceFoundryPrincipal,
+ fetchFoundryCatalog,
+ readFoundryRequest,
+ requestFoundryGateway,
+ selectFoundryProvider,
+ usageFromHeaders,
+} from "./adapter";
+import { emitFoundryEvidence } from "./evidence";
+import {
+ resolveScientificEvidenceLink,
+ saveScientificFoundryEvidence,
+ type FoundryInvocationKind,
+} from "./scientific-evidence";
+
+const AgentInvokeSchema = Schema.Struct({
+ input: Schema.Unknown,
+ conversation_id: Schema.optional(Schema.String),
+});
+
+const ModelRequestSchema = Schema.Record(Schema.String, Schema.Unknown);
+
+type AuthorizedRequest = {
+ provider: ProviderConfig;
+ principal: NormalizedPrincipal;
+ token: string;
+};
+
+const authorize = (
+ context: AppContext,
+ principal: NormalizedPrincipal | undefined,
+ verifiedToken: string | undefined,
+ requested: string | undefined,
+ entitlements: readonly [EnterpriseEntitlement, ...EnterpriseEntitlement[]],
+): Effect.Effect => {
+ const provider = selectFoundryProvider(context.config.providers, requested);
+ let validatedPrincipal = enforceFoundryPrincipal(
+ provider,
+ principal,
+ context.config.enterprise_auth,
+ entitlements[0],
+ );
+ for (const entitlement of entitlements.slice(1)) {
+ validatedPrincipal = enforceFoundryPrincipal(
+ provider,
+ validatedPrincipal,
+ context.config.enterprise_auth,
+ entitlement,
+ );
+ }
+ const token = verifiedToken ?? "";
+ return resolveProviderHeaders(
+ {
+ ...provider,
+ authentication: provider.foundry!.authentication,
+ },
+ {
+ secretStore: context.providerSecretStore,
+ principal: validatedPrincipal,
+ verifiedBearerToken: token,
+ },
+ ).pipe(
+ Effect.mapError(() => forbidden("Foundry token contract denied")),
+ Effect.map((headers) => ({
+ provider,
+ principal: validatedPrincipal,
+ token: bearerToken(headers["Authorization"]),
+ })),
+ );
+};
+
+const responseHeaders = (upstream: Headers, correlationId: string): Headers => {
+ const headers = new Headers({ "X-Correlation-ID": correlationId });
+ for (const name of [
+ "content-type",
+ "cache-control",
+ "retry-after",
+ "x-ratelimit-limit",
+ "x-ratelimit-remaining",
+ "x-ms-input-tokens",
+ "x-ms-output-tokens",
+ "x-ms-total-tokens",
+ ]) {
+ const value = upstream.get(name);
+ if (value) headers.set(name, value);
+ }
+ return headers;
+};
+
+type RelayInput = {
+ path: string;
+ operation: string;
+ body: unknown;
+ signal: AbortSignal;
+ resourceId: string;
+ event: FoundryInvocationKind;
+ scientificSubmissionId?: string | undefined;
+};
+
+const gatewayRequest = (
+ request: AuthorizedRequest,
+ input: RelayInput,
+): Effect.Effect<{ response: Response; correlationId: string }, unknown> =>
+ requestFoundryGateway({
+ provider: request.provider,
+ path: input.path,
+ token: request.token,
+ method: "POST",
+ body: JSON.stringify(input.body),
+ signal: input.signal,
+ accept:
+ input.body &&
+ typeof input.body === "object" &&
+ (input.body as Record)["stream"] === true
+ ? "text/event-stream"
+ : "application/json",
+ });
+
+const relay = (
+ context: AppContext,
+ request: AuthorizedRequest,
+ input: RelayInput,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const linkedSubmissionId = yield* resolveScientificEvidenceLink(
+ context,
+ request.principal,
+ request.provider.id,
+ input.resourceId,
+ input.event,
+ input.scientificSubmissionId,
+ );
+ const result = yield* gatewayRequest(request, input);
+ const usage = usageFromHeaders(result.response.headers);
+ if (linkedSubmissionId) {
+ yield* saveScientificFoundryEvidence(context, {
+ submissionId: linkedSubmissionId,
+ principal: request.principal,
+ providerId: request.provider.id,
+ resourceId: input.resourceId,
+ correlationId: result.correlationId,
+ event: input.event,
+ upstreamBody: result.response.body,
+ });
+ }
+ emitFoundryEvidence({
+ event: input.event,
+ principal: request.principal,
+ operation: input.operation,
+ correlation_id: result.correlationId,
+ provider_id: request.provider.id,
+ resource_id: input.resourceId,
+ status: result.response.status,
+ ...(usage ? { usage } : {}),
+ });
+ return new Response(result.response.body, {
+ status: result.response.status,
+ headers: responseHeaders(result.response.headers, result.correlationId),
+ });
+ });
+
+export const registerFoundryRoutes = defineRoutes((app, context) =>
+ mergeRoutes(
+ app.get(
+ "/ai/v1/health",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const configured = context.config.providers.filter(
+ (provider) => provider.enabled && provider.foundry,
+ );
+ if (configured.length === 0) {
+ return ctx.json({
+ configured: false,
+ required: false,
+ state: "claimed",
+ detail: "Microsoft Foundry is not configured.",
+ correlation_ids: [],
+ model_count: 0,
+ agent_count: 0,
+ });
+ }
+ const request = yield* authorize(
+ context,
+ ctx.get("enterprisePrincipal"),
+ ctx.get("enterpriseBearerToken"),
+ ctx.req.query("provider"),
+ ["model:invoke", "agent:invoke"],
+ );
+ const [models, agents] = yield* Effect.all(
+ [
+ fetchFoundryCatalog(request.provider, request.token, "models", ctx.req.raw.signal),
+ fetchFoundryCatalog(request.provider, request.token, "agents", ctx.req.raw.signal),
+ ],
+ { concurrency: 2 },
+ );
+ emitFoundryEvidence({
+ event: "catalog_observed",
+ principal: request.principal,
+ operation: "health",
+ correlation_id: `${models.correlation_id},${agents.correlation_id}`,
+ provider_id: request.provider.id,
+ status: 200,
+ });
+ return ctx.json({
+ configured: true,
+ required: true,
+ state: "observed",
+ detail: "APIM model and agent catalogs were observed.",
+ provider_id: request.provider.id,
+ correlation_ids: [models.correlation_id, agents.correlation_id],
+ checked_at: new Date().toISOString(),
+ model_count: models.data.length,
+ agent_count: agents.data.length,
+ });
+ }),
+ ),
+ ),
+ app.get(
+ "/ai/v1/models",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const request = yield* authorize(
+ context,
+ ctx.get("enterprisePrincipal"),
+ ctx.get("enterpriseBearerToken"),
+ ctx.req.query("provider"),
+ ["model:invoke"],
+ );
+ const catalog = yield* fetchFoundryCatalog(
+ request.provider,
+ request.token,
+ "models",
+ ctx.req.raw.signal,
+ );
+ emitFoundryEvidence({
+ event: "catalog_observed",
+ principal: request.principal,
+ operation: "models.list",
+ correlation_id: catalog.correlation_id,
+ provider_id: request.provider.id,
+ status: 200,
+ });
+ return ctx.json(catalog);
+ }),
+ ),
+ ),
+ app.post(
+ "/ai/v1/chat/completions",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const request = yield* authorize(
+ context,
+ ctx.get("enterprisePrincipal"),
+ ctx.get("enterpriseBearerToken"),
+ ctx.req.query("provider"),
+ ["model:invoke"],
+ );
+ const body = yield* readFoundryRequest(ctx.req.raw, ModelRequestSchema);
+ const model = typeof body["model"] === "string" ? body["model"] : "";
+ if (!request.provider.foundry!.allowed_models.includes(model)) {
+ return yield* Effect.fail(badRequest(`Model "${model}" is not allowed`));
+ }
+ return yield* relay(context, request, {
+ path: "/ai/v1/chat/completions",
+ operation: "chat.completions",
+ body,
+ signal: ctx.req.raw.signal,
+ resourceId: model,
+ event: "model_invocation",
+ scientificSubmissionId: ctx.req.header("x-local-studio-scientific-submission-id"),
+ });
+ }),
+ ),
+ ),
+ app.post(
+ "/ai/v1/responses",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const request = yield* authorize(
+ context,
+ ctx.get("enterprisePrincipal"),
+ ctx.get("enterpriseBearerToken"),
+ ctx.req.query("provider"),
+ ["model:invoke"],
+ );
+ const body = yield* readFoundryRequest(ctx.req.raw, ModelRequestSchema);
+ const model = typeof body["model"] === "string" ? body["model"] : "";
+ if (!request.provider.foundry!.allowed_models.includes(model)) {
+ return yield* Effect.fail(badRequest(`Model "${model}" is not allowed`));
+ }
+ return yield* relay(context, request, {
+ path: "/ai/v1/responses",
+ operation: "responses.create",
+ body,
+ signal: ctx.req.raw.signal,
+ resourceId: model,
+ event: "model_invocation",
+ scientificSubmissionId: ctx.req.header("x-local-studio-scientific-submission-id"),
+ });
+ }),
+ ),
+ ),
+ app.get(
+ "/ai/v1/agents",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const request = yield* authorize(
+ context,
+ ctx.get("enterprisePrincipal"),
+ ctx.get("enterpriseBearerToken"),
+ ctx.req.query("provider"),
+ ["agent:invoke"],
+ );
+ const catalog = yield* fetchFoundryCatalog(
+ request.provider,
+ request.token,
+ "agents",
+ ctx.req.raw.signal,
+ );
+ emitFoundryEvidence({
+ event: "catalog_observed",
+ principal: request.principal,
+ operation: "agents.list",
+ correlation_id: catalog.correlation_id,
+ provider_id: request.provider.id,
+ status: 200,
+ });
+ return ctx.json(catalog);
+ }),
+ ),
+ ),
+ app.post(
+ "/ai/v1/agents/:agentId/invoke",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const request = yield* authorize(
+ context,
+ ctx.get("enterprisePrincipal"),
+ ctx.get("enterpriseBearerToken"),
+ ctx.req.query("provider"),
+ ["agent:invoke"],
+ );
+ const agentId = ctx.req.param("agentId") ?? "";
+ if (!request.provider.foundry!.allowed_agents.includes(agentId)) {
+ return yield* Effect.fail(badRequest(`Agent "${agentId}" is not allowed`));
+ }
+ const body = yield* readFoundryRequest(ctx.req.raw, AgentInvokeSchema);
+ return yield* relay(context, request, {
+ path: `/ai/v1/agents/${encodeURIComponent(agentId)}/invoke`,
+ operation: "agent.invoke",
+ body,
+ signal: ctx.req.raw.signal,
+ resourceId: agentId,
+ event: "agent_invocation",
+ scientificSubmissionId: ctx.req.header("x-local-studio-scientific-submission-id"),
+ });
+ }),
+ ),
+ ),
+ ),
+);
diff --git a/controller/src/modules/foundry/scientific-evidence.ts b/controller/src/modules/foundry/scientific-evidence.ts
new file mode 100644
index 000000000..363c582ca
--- /dev/null
+++ b/controller/src/modules/foundry/scientific-evidence.ts
@@ -0,0 +1,89 @@
+import { createHash } from "node:crypto";
+import type { NormalizedPrincipal } from "@local-studio/contracts/enterprise-auth";
+import { Effect } from "effect";
+import type { AppContext } from "../../app-context";
+import { badRequest } from "../../core/errors";
+import {
+ requireScientificRayJobAccess,
+ scientificPrincipalScope,
+} from "../workbench/enterprise-identity";
+
+export type FoundryInvocationKind = "model_invocation" | "agent_invocation";
+
+export const resolveScientificEvidenceLink = (
+ context: AppContext,
+ principal: NormalizedPrincipal,
+ providerId: string,
+ resourceId: string,
+ event: FoundryInvocationKind,
+ assertedSubmissionId?: string,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const submissionId = assertedSubmissionId?.trim();
+ if (!submissionId) return undefined;
+ if (!/^[A-Za-z0-9._:-]{1,128}$/u.test(submissionId)) {
+ return yield* Effect.fail(badRequest("Scientific evidence link denied"));
+ }
+ const job = yield* context.stores.scientificWorkbenchStore.getRayJob(submissionId);
+ if (!job) return yield* Effect.fail(badRequest("Scientific evidence link denied"));
+ requireScientificRayJobAccess(principal, job);
+ if (
+ event === "model_invocation" &&
+ !job.submission.models.some(
+ (model) => model.provider_id === providerId && model.model_id === resourceId,
+ )
+ ) {
+ return yield* Effect.fail(badRequest("Scientific evidence link denied"));
+ }
+ return submissionId;
+ });
+
+export const saveScientificFoundryEvidence = (
+ context: AppContext,
+ input: {
+ submissionId: string;
+ principal: NormalizedPrincipal;
+ providerId: string;
+ resourceId: string;
+ correlationId: string;
+ event: FoundryInvocationKind;
+ upstreamBody: ReadableStream | null;
+ },
+): Effect.Effect => {
+ const id = `sha256:${createHash("sha256")
+ .update(
+ [
+ input.submissionId,
+ input.correlationId,
+ input.event,
+ input.providerId,
+ input.resourceId,
+ input.principal.issuer,
+ input.principal.issuer_id,
+ input.principal.tenant,
+ input.principal.subject,
+ ].join("\u0000"),
+ )
+ .digest("hex")}`;
+ return context.stores.scientificWorkbenchStore
+ .saveFoundryInvocationEvidence({
+ id,
+ submission_id: input.submissionId,
+ principal: scientificPrincipalScope(input.principal),
+ kind: input.event === "model_invocation" ? "model" : "agent",
+ provider_id: input.providerId,
+ resource_id: input.resourceId,
+ correlation_id: input.correlationId,
+ observed_at: new Date().toISOString(),
+ })
+ .pipe(
+ Effect.tapError(() =>
+ input.upstreamBody
+ ? Effect.tryPromise({
+ try: () => input.upstreamBody!.cancel(),
+ catch: () => undefined,
+ }).pipe(Effect.ignore)
+ : Effect.void,
+ ),
+ );
+};
diff --git a/controller/src/modules/machines/enrollment-service.ts b/controller/src/modules/machines/enrollment-service.ts
new file mode 100644
index 000000000..4bba91f43
--- /dev/null
+++ b/controller/src/modules/machines/enrollment-service.ts
@@ -0,0 +1,427 @@
+import { createHash, randomUUID } from "node:crypto";
+import {
+ chmodSync,
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ renameSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import { dirname, resolve } from "node:path";
+import {
+ MachineEnrollmentFileSchema,
+ MachineEnrollmentProfileSchema,
+ MachineOwnedResourceSchema,
+ type MachineEnrollmentProfile,
+ type MachineEnrollmentReceipt,
+ type MachineEnrollmentRecord,
+ type MachineLifecycleState,
+ type MachineOwnedResource,
+} from "@local-studio/contracts/machine-enrollment";
+import { Effect, Schema } from "effect";
+
+type StoredFile = {
+ version: 1;
+ machines: MachineEnrollmentRecord[];
+};
+
+const EMPTY_FILE: StoredFile = { version: 1, machines: [] };
+const MACHINE_ID = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/u;
+const CREDENTIAL_REF = /^(?:keyring|vault):[a-zA-Z0-9._:/-]+$/u;
+const DIGEST = /^sha256:[a-f0-9]{64}$/u;
+const SECRET_KEY = /(api.?key|password|secret|token|private.?key|credential(?!_ref))/iu;
+const TRANSITIONS: Record = {
+ draft: ["probed", "failed"],
+ probed: ["admitted", "failed"],
+ admitted: ["configured", "failed"],
+ configured: ["active", "failed"],
+ active: ["draining", "failed"],
+ draining: ["revoked", "failed"],
+ revoked: ["draft"],
+ failed: ["draft", "draining"],
+};
+const activeOffboards = new Set();
+
+const canonical = (value: unknown): string => {
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
+ if (value && typeof value === "object") {
+ return `{${Object.entries(value)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`)
+ .join(",")}}`;
+ }
+ return JSON.stringify(value);
+};
+
+const rejectSecrets = (value: unknown, path = "profile"): void => {
+ if (Array.isArray(value)) {
+ value.forEach((entry, index) => rejectSecrets(entry, `${path}[${index}]`));
+ return;
+ }
+ if (!value || typeof value !== "object") return;
+ for (const [key, entry] of Object.entries(value)) {
+ if (SECRET_KEY.test(key)) throw new Error(`${path}.${key} must not contain secret material`);
+ if (
+ typeof entry === "string" &&
+ (/-----BEGIN [A-Z ]*PRIVATE KEY-----/u.test(entry) ||
+ /^Bearer\s+/iu.test(entry) ||
+ /^[a-z][a-z0-9+.-]*:\/\/[^/\s]+:[^@\s]+@/iu.test(entry))
+ ) {
+ throw new Error(`${path}.${key} must not contain secret material`);
+ }
+ rejectSecrets(entry, `${path}.${key}`);
+ }
+};
+
+export const machinePlanDigest = (profile: MachineEnrollmentProfile): string =>
+ `sha256:${createHash("sha256").update(canonical(profile)).digest("hex")}`;
+
+export const decodeMachineEnrollmentProfile = (input: unknown): MachineEnrollmentProfile => {
+ rejectSecrets(input);
+ const profile = Schema.decodeUnknownSync(MachineEnrollmentProfileSchema, {
+ onExcessProperty: "error",
+ })(input);
+ if (!MACHINE_ID.test(profile.machine_id)) {
+ throw new Error("machine_id must be a stable lowercase identifier");
+ }
+ if (!profile.display_name.trim()) throw new Error("display_name is required");
+ if (profile.appliance_id !== "cortaix-factory") {
+ throw new Error("C2 machine enrollment requires the cortaix-factory appliance");
+ }
+ const referenceIds = new Set();
+ for (const reference of [...profile.runtime_refs, ...profile.agent_refs]) {
+ if (!reference.id.trim() || referenceIds.has(reference.id)) {
+ throw new Error("Runtime and agent reference identifiers must be non-empty and unique");
+ }
+ referenceIds.add(reference.id);
+ }
+ for (const access of profile.access_refs) {
+ if (!access.id.trim() || referenceIds.has(access.id)) {
+ throw new Error("Access reference identifiers must be non-empty and unique");
+ }
+ referenceIds.add(access.id);
+ if (!access.endpoint.trim()) throw new Error(`${access.kind} endpoint is required`);
+ if (access.credential_ref && !CREDENTIAL_REF.test(access.credential_ref)) {
+ throw new Error(`${access.kind} credentials must use an opaque credential-store reference`);
+ }
+ }
+ if (profile.locality === "remote" && profile.access_refs.length === 0) {
+ throw new Error("Remote machine enrollment requires an access reference");
+ }
+ return profile;
+};
+
+export const transitionMachine = (
+ record: MachineEnrollmentRecord,
+ to: MachineLifecycleState,
+ at: string,
+ reason: string,
+): MachineEnrollmentRecord => {
+ if (record.state === to) return record;
+ if (!TRANSITIONS[record.state].includes(to)) {
+ throw new Error(`Invalid machine lifecycle transition: ${record.state} -> ${to}`);
+ }
+ return {
+ ...record,
+ state: to,
+ updated_at: at,
+ events: [...record.events, { from: record.state, to, at, reason }],
+ };
+};
+
+const normalizeResources = (
+ resources: readonly MachineOwnedResource[],
+): MachineOwnedResource[] => {
+ rejectSecrets(resources, "owned_resources");
+ const ids = new Set();
+ return [...resources]
+ .map((resource) =>
+ Schema.decodeUnknownSync(MachineOwnedResourceSchema, {
+ onExcessProperty: "error",
+ })(resource),
+ )
+ .sort((left, right) => left.resource_id.localeCompare(right.resource_id))
+ .map((resource) => {
+ if (!resource.resource_id.trim() || ids.has(resource.resource_id)) {
+ throw new Error("Owned resource identifiers must be non-empty and unique");
+ }
+ if (resource.previous_digest && !DIGEST.test(resource.previous_digest)) {
+ throw new Error("Owned resource previous_digest must be sha256");
+ }
+ ids.add(resource.resource_id);
+ return resource;
+ });
+};
+
+export class MachineEnrollmentService {
+ private readonly filePath: string;
+
+ public constructor(
+ dataDirectory: string,
+ private readonly now: () => string = () => new Date().toISOString(),
+ ) {
+ this.filePath = resolve(dataDirectory, "machine-enrollments.json");
+ }
+
+ public list(): readonly MachineEnrollmentRecord[] {
+ return this.read().machines;
+ }
+
+ public register(input: unknown): MachineEnrollmentRecord {
+ const profile = decodeMachineEnrollmentProfile(input);
+ if (activeOffboards.has(profile.machine_id)) {
+ throw new Error(`Machine "${profile.machine_id}" is currently offboarding`);
+ }
+ return this.mutate((file) => {
+ const existing = file.machines.find(({ profile: value }) => value.machine_id === profile.machine_id);
+ const digest = machinePlanDigest(profile);
+ if (existing && existing.plan_digest === digest && existing.state !== "revoked") return existing;
+ const at = this.now();
+ const base: MachineEnrollmentRecord = existing
+ ? transitionMachine(existing, "draft", at, "profile replaced")
+ : {
+ profile,
+ state: "draft",
+ plan_digest: digest,
+ created_at: at,
+ updated_at: at,
+ events: [],
+ receipt: null,
+ recovery_required: false,
+ };
+ const record = { ...base, profile, plan_digest: digest, receipt: null };
+ return this.replace(file, record);
+ });
+ }
+
+ public transition(
+ machineId: string,
+ to: MachineLifecycleState,
+ reason: string,
+ ): MachineEnrollmentRecord {
+ return this.mutate((file) => {
+ const current = this.require(file, machineId);
+ return this.replace(file, transitionMachine(current, to, this.now(), reason));
+ });
+ }
+
+ public apply(
+ machineId: string,
+ resources: readonly MachineOwnedResource[],
+ ): MachineEnrollmentRecord {
+ const ownedResources = normalizeResources(resources);
+ if (activeOffboards.has(machineId)) {
+ throw new Error(`Machine "${machineId}" is currently offboarding`);
+ }
+ return this.mutate((file) => {
+ const current = this.require(file, machineId);
+ if (current.state === "active" && current.receipt?.plan_digest === current.plan_digest) {
+ if (canonical(current.receipt.owned_resources) !== canonical(ownedResources)) {
+ throw new Error("Applied owned resources differ from the existing receipt");
+ }
+ return current;
+ }
+ if (current.state !== "configured") {
+ throw new Error("Machine must be configured before apply");
+ }
+ const at = this.now();
+ const receipt: MachineEnrollmentReceipt = {
+ receipt_id: randomUUID(),
+ machine_id: machineId,
+ plan_digest: current.plan_digest,
+ applied_at: at,
+ classification: "C2",
+ owned_resources: ownedResources,
+ rollback_journal: ownedResources.map(({ resource_id }) => ({
+ resource_id,
+ status: "pending",
+ })),
+ };
+ const active = transitionMachine({ ...current, receipt }, "active", at, "plan applied");
+ return this.replace(file, active);
+ });
+ }
+
+ public reconcile(machineId: string): MachineEnrollmentRecord {
+ if (activeOffboards.has(machineId)) {
+ throw new Error(`Machine "${machineId}" is currently offboarding`);
+ }
+ return this.mutate((file) => {
+ const current = this.require(file, machineId);
+ if (current.receipt && current.receipt.plan_digest !== current.plan_digest) {
+ const failed = transitionMachine(current, "failed", this.now(), "receipt plan drift");
+ return this.replace(file, { ...failed, recovery_required: true });
+ }
+ return current;
+ });
+ }
+
+ public offboard(
+ machineId: string,
+ rollback: (resource: MachineOwnedResource) => Effect.Effect,
+ ): Effect.Effect {
+ if (activeOffboards.has(machineId)) {
+ return Effect.fail(new Error(`Machine "${machineId}" is already offboarding`));
+ }
+ activeOffboards.add(machineId);
+ return this.runOffboard(machineId, rollback).pipe(
+ Effect.ensuring(Effect.sync(() => activeOffboards.delete(machineId))),
+ );
+ }
+
+ private runOffboard(
+ machineId: string,
+ rollback: (resource: MachineOwnedResource) => Effect.Effect,
+ ): Effect.Effect {
+ const service = this;
+ return Effect.gen(function* () {
+ const existing = service.list().find(({ profile }) => profile.machine_id === machineId);
+ if (!existing) return yield* Effect.fail(new Error(`Machine "${machineId}" is not enrolled`));
+ const current =
+ existing.state === "draining"
+ ? existing
+ : service.transition(machineId, "draining", "offboarding started");
+ for (const resource of [...(current.receipt?.owned_resources ?? [])].reverse()) {
+ const latest = service.list().find(({ profile }) => profile.machine_id === machineId);
+ const journal = latest?.receipt?.rollback_journal ?? [];
+ if (
+ journal.find(({ resource_id }) => resource_id === resource.resource_id)?.status ===
+ "rolled_back"
+ ) {
+ continue;
+ }
+ const result = yield* rollback(resource).pipe(
+ Effect.as({ ok: true as const }),
+ Effect.catch((error) => Effect.succeed({ ok: false as const, error })),
+ );
+ if (result.ok) {
+ service.updateRollback(machineId, resource.resource_id, "rolled_back");
+ continue;
+ }
+ service.updateRollback(machineId, resource.resource_id, "failed");
+ service.mutate((file) => {
+ const value = service.require(file, machineId);
+ const failed = transitionMachine(
+ value,
+ "failed",
+ service.now(),
+ "rollback incomplete",
+ );
+ return service.replace(file, { ...failed, recovery_required: true });
+ });
+ return yield* Effect.fail(result.error);
+ }
+ return service.mutate((file) => {
+ const latest = service.require(file, machineId);
+ const revoked = transitionMachine(
+ latest,
+ "revoked",
+ service.now(),
+ "owned resources rolled back",
+ );
+ return service.replace(file, { ...revoked, recovery_required: false });
+ });
+ });
+ }
+
+ private updateRollback(
+ machineId: string,
+ resourceId: string,
+ status: "rolled_back" | "failed",
+ ): void {
+ this.mutate((file) => {
+ const current = this.require(file, machineId);
+ if (!current.receipt) throw new Error("Machine has no apply receipt");
+ const receipt = {
+ ...current.receipt,
+ rollback_journal: current.receipt.rollback_journal.map((entry) =>
+ entry.resource_id === resourceId
+ ? { ...entry, status, attempted_at: this.now() }
+ : entry,
+ ),
+ };
+ return this.replace(file, { ...current, receipt, updated_at: this.now() });
+ });
+ }
+
+ private require(file: StoredFile, machineId: string): MachineEnrollmentRecord {
+ const record = file.machines.find(({ profile }) => profile.machine_id === machineId);
+ if (!record) throw new Error(`Machine "${machineId}" is not enrolled`);
+ return record;
+ }
+
+ private replace(
+ file: StoredFile,
+ record: MachineEnrollmentRecord,
+ ): MachineEnrollmentRecord {
+ const machines = file.machines.filter(
+ ({ profile }) => profile.machine_id !== record.profile.machine_id,
+ );
+ file.machines = [...machines, record].sort((left, right) =>
+ left.profile.machine_id.localeCompare(right.profile.machine_id),
+ );
+ return record;
+ }
+
+ private read(): StoredFile {
+ try {
+ const input = JSON.parse(readFileSync(this.filePath, "utf8")) as unknown;
+ const decoded = Schema.decodeUnknownSync(MachineEnrollmentFileSchema, {
+ onExcessProperty: "error",
+ })(input);
+ const machines = [...decoded.machines];
+ const ids = new Set();
+ for (const record of machines) {
+ decodeMachineEnrollmentProfile(record.profile);
+ if (ids.has(record.profile.machine_id)) throw new Error("Persisted machine IDs must be unique");
+ ids.add(record.profile.machine_id);
+ if (record.plan_digest !== machinePlanDigest(record.profile)) {
+ throw new Error(`Persisted machine "${record.profile.machine_id}" has plan digest drift`);
+ }
+ if (record.receipt) {
+ if (
+ record.receipt.machine_id !== record.profile.machine_id ||
+ record.receipt.plan_digest !== record.plan_digest
+ ) {
+ throw new Error(`Persisted machine "${record.profile.machine_id}" has receipt drift`);
+ }
+ const resources = normalizeResources(record.receipt.owned_resources);
+ const resourceIds = resources.map(({ resource_id }) => resource_id).sort();
+ const journalIds = record.receipt.rollback_journal
+ .map(({ resource_id }) => resource_id)
+ .sort();
+ if (canonical(resourceIds) !== canonical(journalIds)) {
+ throw new Error(`Persisted machine "${record.profile.machine_id}" has rollback drift`);
+ }
+ }
+ }
+ return { version: 1, machines };
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return structuredClone(EMPTY_FILE);
+ throw error;
+ }
+ }
+
+ private write(file: StoredFile): void {
+ mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 });
+ const temporary = `${this.filePath}.tmp-${process.pid}-${randomUUID()}`;
+ try {
+ writeFileSync(temporary, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
+ renameSync(temporary, this.filePath);
+ chmodSync(dirname(this.filePath), 0o700);
+ chmodSync(this.filePath, 0o600);
+ } finally {
+ if (existsSync(temporary)) rmSync(temporary, { force: true });
+ }
+ }
+
+ private mutate(
+ operation: (file: StoredFile) => MachineEnrollmentRecord,
+ ): MachineEnrollmentRecord {
+ const file = this.read();
+ const result = operation(file);
+ this.write(file);
+ return result;
+ }
+}
diff --git a/controller/src/modules/machines/routes.ts b/controller/src/modules/machines/routes.ts
new file mode 100644
index 000000000..7b5a76d24
--- /dev/null
+++ b/controller/src/modules/machines/routes.ts
@@ -0,0 +1,169 @@
+import {
+ MachineEnrollmentProfileSchema,
+ MachineLifecycleStateSchema,
+ type MachineEnrollmentRecord,
+ type MachineOwnedResource,
+} from "@local-studio/contracts/machine-enrollment";
+import { Effect, Schema } from "effect";
+import { badRequest, notFound } from "../../core/errors";
+import { decodeJsonBody } from "../../core/validation";
+import { effectHandler } from "../../http/effect-handler";
+import { defineRoutes, documentRoute, mergeRoutes } from "../../http/route-registrar";
+
+const MachineTransitionSchema = Schema.Struct({
+ state: MachineLifecycleStateSchema,
+ reason: Schema.String,
+});
+
+const machineEffect = (operation: () => A): Effect.Effect> =>
+ Effect.try({
+ try: operation,
+ catch: (error) => badRequest(error instanceof Error ? error.message : "Machine operation failed"),
+ });
+
+const fixtureResource = (machineId: string): MachineOwnedResource => ({
+ resource_id: `controller-record:${machineId}`,
+ kind: "controller-record",
+ external_ref: `loopback:machine:${machineId}`,
+ ownership: "local-studio",
+ apply_action: "create",
+ rollback_action: "remove",
+});
+
+const rollbackFixture = (machineId: string, resource: MachineOwnedResource): Effect.Effect =>
+ resource.resource_id === `controller-record:${machineId}` &&
+ resource.kind === "controller-record" &&
+ resource.external_ref === `loopback:machine:${machineId}` &&
+ resource.ownership === "local-studio"
+ ? Effect.void
+ : Effect.fail(new Error(`Refused rollback of unowned resource "${resource.resource_id}"`));
+
+const machineById = (
+ records: readonly MachineEnrollmentRecord[],
+ machineId: string,
+): Effect.Effect> => {
+ const record = records.find(({ profile }) => profile.machine_id === machineId);
+ return record
+ ? Effect.succeed(record)
+ : Effect.fail(notFound(`Machine "${machineId}" is not enrolled`));
+};
+
+export const registerMachineRoutes = defineRoutes((app, context) => {
+ const service = context.machineEnrollmentService;
+ const offboard = (machineId: string): Effect.Effect =>
+ service.offboard(machineId, (resource) => rollbackFixture(machineId, resource));
+
+ return mergeRoutes(
+ app.get(
+ "/machines",
+ documentRoute,
+ effectHandler((ctx) => Effect.sync(() => ctx.json({ machines: service.list() }))),
+ ),
+ app.get(
+ "/machines/:machineId",
+ documentRoute,
+ effectHandler((ctx) =>
+ machineById(service.list(), ctx.req.param("machineId") ?? "").pipe(
+ Effect.map((machine) => ctx.json({ machine })),
+ ),
+ ),
+ ),
+ app.post(
+ "/machines",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const profile = yield* decodeJsonBody(ctx, MachineEnrollmentProfileSchema);
+ const machine = yield* machineEffect(() => service.register(profile));
+ return ctx.json({ machine }, 201);
+ }),
+ ),
+ ),
+ app.put(
+ "/machines/:machineId",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const profile = yield* decodeJsonBody(ctx, MachineEnrollmentProfileSchema);
+ if (profile.machine_id !== (ctx.req.param("machineId") ?? "")) {
+ return yield* Effect.fail(badRequest("Path and profile machine IDs must match"));
+ }
+ const machine = yield* machineEffect(() => service.register(profile));
+ return ctx.json({ machine });
+ }),
+ ),
+ ),
+ app.post(
+ "/machines/:machineId/plan",
+ documentRoute,
+ effectHandler((ctx) =>
+ machineById(service.list(), ctx.req.param("machineId") ?? "").pipe(
+ Effect.map((machine) =>
+ ctx.json({
+ plan: {
+ machine_id: machine.profile.machine_id,
+ digest: machine.plan_digest,
+ state: machine.state,
+ runtime_refs: machine.profile.runtime_refs,
+ access_refs: machine.profile.access_refs,
+ agent_refs: machine.profile.agent_refs,
+ },
+ }),
+ ),
+ ),
+ ),
+ ),
+ app.patch(
+ "/machines/:machineId/state",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const body = yield* decodeJsonBody(ctx, MachineTransitionSchema);
+ const machine = yield* machineEffect(() =>
+ service.transition(ctx.req.param("machineId") ?? "", body.state, body.reason),
+ );
+ return ctx.json({ machine });
+ }),
+ ),
+ ),
+ app.post(
+ "/machines/:machineId/apply",
+ documentRoute,
+ effectHandler((ctx) =>
+ machineEffect(() => {
+ const machineId = ctx.req.param("machineId") ?? "";
+ return service.apply(machineId, [fixtureResource(machineId)]);
+ }).pipe(Effect.map((machine) => ctx.json({ machine }))),
+ ),
+ ),
+ app.post(
+ "/machines/:machineId/reconcile",
+ documentRoute,
+ effectHandler((ctx) =>
+ machineEffect(() => service.reconcile(ctx.req.param("machineId") ?? "")).pipe(
+ Effect.map((machine) => ctx.json({ machine })),
+ ),
+ ),
+ ),
+ app.delete(
+ "/machines/:machineId",
+ documentRoute,
+ effectHandler((ctx) =>
+ offboard(ctx.req.param("machineId") ?? "").pipe(
+ Effect.map((machine) => ctx.json({ machine })),
+ Effect.mapError((error) => badRequest(String(error))),
+ ),
+ ),
+ ),
+ app.post(
+ "/machines/:machineId/recovery",
+ documentRoute,
+ effectHandler((ctx) =>
+ offboard(ctx.req.param("machineId") ?? "").pipe(
+ Effect.map((machine) => ctx.json({ machine })),
+ Effect.mapError((error) => badRequest(String(error))),
+ ),
+ ),
+ ),
+ );
+});
diff --git a/controller/src/modules/models/routes.ts b/controller/src/modules/models/routes.ts
index 48418e350..23eae932b 100644
--- a/controller/src/modules/models/routes.ts
+++ b/controller/src/modules/models/routes.ts
@@ -44,6 +44,7 @@ import { notFound } from "../../core/errors";
import { findObservedInferenceProcess } from "../../core/function-observability";
import { parseBooleanFlag } from "../../core/validation";
import { fetchInference } from "../../http/local-fetch";
+import { discoverProviderModels, providerIsDiscoverable } from "../../services/provider-routing";
function isMockInferenceEnabled(): boolean {
return parseBooleanFlag(process.env["LOCAL_STUDIO_MOCK_INFERENCE"]);
@@ -135,6 +136,39 @@ export const registerModelsRoutes = defineRoutes((app, context) => {
});
}
+ const providerCatalogs = yield* Effect.forEach(
+ context.config.providers.filter(providerIsDiscoverable),
+ (provider) => {
+ const bearer = ctx.get("enterpriseBearerToken");
+ return discoverProviderModels(provider, fetch, {
+ secretStore: context.providerSecretStore,
+ principal: ctx.get("enterprisePrincipal"),
+ ...(bearer ? { verifiedBearerToken: bearer } : {}),
+ signal: ctx.req.raw.signal,
+ }).pipe(Effect.option);
+ },
+ { concurrency: 4 },
+ );
+ const knownModelIds = new Set(models.map(({ id }) => id));
+ for (const result of providerCatalogs) {
+ if (result._tag !== "Some") continue;
+ for (const remoteModel of result.value.models) {
+ const modelId = `${result.value.provider}/${remoteModel.id}`;
+ if (knownModelIds.has(modelId)) continue;
+ knownModelIds.add(modelId);
+ models.push({
+ id: modelId,
+ object: "model",
+ created: now,
+ owned_by: result.value.provider,
+ active: true,
+ metadata: {
+ vision: resolveModelVision({ identifiers: [remoteModel.id] }),
+ },
+ });
+ }
+ }
+
const payload: OpenAIModelList = { object: "list", data: models };
return ctx.json(payload);
}),
diff --git a/controller/src/modules/proxy/chat-completions-stream.ts b/controller/src/modules/proxy/chat-completions-stream.ts
index ad7cb49f0..311ecda42 100644
--- a/controller/src/modules/proxy/chat-completions-stream.ts
+++ b/controller/src/modules/proxy/chat-completions-stream.ts
@@ -165,6 +165,7 @@ const upstreamStream = (
headers: parameters.headers,
body: parameters.body,
signal: AbortSignal.any([parameters.clientSignal, signal]),
+ redirect: "error",
}),
catch: (source) =>
new ChatCompletionsStreamError({
diff --git a/controller/src/modules/proxy/openai-routes.ts b/controller/src/modules/proxy/openai-routes.ts
index 776b44769..5b9e98e2a 100644
--- a/controller/src/modules/proxy/openai-routes.ts
+++ b/controller/src/modules/proxy/openai-routes.ts
@@ -8,8 +8,8 @@ import type { Recipe } from "../models/types";
import { buildInferenceUrl } from "../../http/local-fetch";
import {
DEFAULT_CHAT_PROVIDER,
- parseProviderModel,
- resolveProviderConfig,
+ resolveProviderModelRoute,
+ type ProviderRouteConfig,
} from "../../services/provider-routing";
import { normalizeChatMessageContentParts, normalizeToolRequest } from "./content-normalizer";
import {
@@ -27,6 +27,12 @@ import {
type OpenAIUsage,
} from "./chat-request";
import { buildChatCompletionsStreamResponse } from "./chat-completions-stream";
+import { providerChatEndpoint } from "../../../../shared/agent/openai-endpoint";
+import {
+ resolveProviderHeaders,
+ type ProviderAuthenticationContext,
+} from "../../services/provider-authentication";
+import { assertProviderOutboundUrl } from "../../services/provider-boundary";
export interface ModelNotRunningError {
error: { message: string; type: "model_not_running"; code: "model_not_running" };
@@ -105,43 +111,61 @@ export const registerOpenAIRoutes = defineRoutes((app, context) => {
const resolveChatUpstream = (
requestedModel: string | null,
parsed: Record,
- ): {
- upstreamUrl: string;
- headers: Record;
- requestProvider: string;
- providerRouting: ReturnType;
- rewroteModel: boolean;
- } => {
- const providerModel = requestedModel
- ? parseProviderModel(requestedModel)
- : { provider: DEFAULT_CHAT_PROVIDER, modelId: "" };
- const requestProvider = providerModel.provider;
- const providerRouting =
- requestProvider !== DEFAULT_CHAT_PROVIDER
- ? resolveProviderConfig(requestProvider, {
- providers: context.config.providers,
- })
- : null;
- let rewroteModel = false;
- if (providerRouting && requestedModel) {
- parsed["model"] = providerModel.modelId;
- rewroteModel = true;
- }
- const upstreamUrl =
- providerRouting && requestedModel
- ? `${providerRouting.baseUrl.replace(/\/+$/, "")}/v1/chat/completions`
- : buildInferenceUrl(context, "/v1/chat/completions");
- const inferenceKey = process.env["INFERENCE_API_KEY"] ?? "";
- const headers: Record = {
- "Content-Type": "application/json",
- ...(providerRouting
- ? { Authorization: `Bearer ${providerRouting.apiKey}` }
- : inferenceKey
- ? { Authorization: `Bearer ${inferenceKey}` }
- : {}),
- };
- return { upstreamUrl, headers, requestProvider, providerRouting, rewroteModel };
- };
+ matchedRecipe: Recipe | null,
+ authenticationContext: ProviderAuthenticationContext,
+ ): Effect.Effect<
+ {
+ upstreamUrl: string;
+ headers: Record;
+ requestProvider: string;
+ providerRouting: ProviderRouteConfig | null;
+ rewroteModel: boolean;
+ },
+ HttpStatus
+ > =>
+ Effect.gen(function* () {
+ const route = resolveProviderModelRoute(
+ requestedModel ?? "",
+ { providers: context.config.providers },
+ Boolean(matchedRecipe),
+ );
+ if (route.kind === "unavailable") {
+ return yield* Effect.fail(notFound(`Provider unavailable: ${route.provider}`));
+ }
+ const requestProvider = route.provider;
+ const providerRouting = route.kind === "remote" ? route.config : null;
+ let rewroteModel = false;
+ if (providerRouting && requestedModel) {
+ parsed["model"] = route.modelId;
+ rewroteModel = true;
+ }
+ const upstreamUrl =
+ providerRouting && requestedModel
+ ? providerChatEndpoint(
+ yield* assertProviderOutboundUrl(providerRouting.baseUrl).pipe(
+ Effect.mapError(() => notFound(`Provider unavailable: ${requestProvider}`)),
+ ),
+ route.modelId,
+ providerRouting.provider.path_style,
+ providerRouting.provider.api_version,
+ )
+ : buildInferenceUrl(context, "/v1/chat/completions");
+ const providerHeaders = providerRouting
+ ? yield* resolveProviderHeaders(providerRouting.provider, authenticationContext).pipe(
+ Effect.mapError(() => notFound(`Provider unavailable: ${requestProvider}`)),
+ )
+ : {};
+ const inferenceKey = process.env["INFERENCE_API_KEY"] ?? "";
+ const headers: Record = {
+ "Content-Type": "application/json",
+ ...(providerRouting
+ ? providerHeaders
+ : inferenceKey
+ ? { Authorization: `Bearer ${inferenceKey}` }
+ : {}),
+ };
+ return { upstreamUrl, headers, requestProvider, providerRouting, rewroteModel };
+ });
const gateOnRunningModel = (
matchedRecipe: Recipe,
@@ -212,8 +236,14 @@ export const registerOpenAIRoutes = defineRoutes((app, context) => {
const bodyBuffer = bodyRead.value;
const { parsed, requestedModel, matchedRecipe, isStreaming, bodyChanged, sessionId } =
yield* parseChatBody(bodyBuffer, (name) => ctx.req.header(name));
+ const verifiedBearerToken = ctx.get("enterpriseBearerToken");
const { upstreamUrl, headers, requestProvider, providerRouting, rewroteModel } =
- resolveChatUpstream(requestedModel, parsed);
+ yield* resolveChatUpstream(requestedModel, parsed, matchedRecipe, {
+ secretStore: context.providerSecretStore,
+ principal: ctx.get("enterprisePrincipal"),
+ ...(verifiedBearerToken ? { verifiedBearerToken } : {}),
+ signal: ctx.req.raw.signal,
+ });
const sourceHeader =
ctx.req.header("x-vllm-source") ??
ctx.req.header("x-source") ??
@@ -257,6 +287,7 @@ export const registerOpenAIRoutes = defineRoutes((app, context) => {
headers,
body: finalBody,
signal: AbortSignal.any([clientSignal, signal]),
+ redirect: "error",
}),
catch: (source) => source,
}).pipe(
diff --git a/controller/src/modules/studio/configs.ts b/controller/src/modules/studio/configs.ts
index 17e2bb6ba..5679a7af4 100644
--- a/controller/src/modules/studio/configs.ts
+++ b/controller/src/modules/studio/configs.ts
@@ -44,6 +44,51 @@ export const STUDIO_STARTER_PRESETS: StudioStarterPreset[] = [
max_model_len: 32768,
},
},
+ {
+ id: "tensorprime",
+ name: "TensorPrime",
+ description:
+ "Connect the governed vLLM and llm-d endpoint. Commissioning verifies the live model catalog before activation.",
+ kind: "remote",
+ tags: ["remote", "governed", "keyless"],
+ size_gb: null,
+ min_vram_gb: null,
+ remote: {
+ base_url: "http://api.tprime.vlans.ca",
+ model: "qwen3-next-80b-a3b-nvfp4",
+ authentication: "none",
+ },
+ },
+ {
+ id: "tensorprime-gemma4",
+ name: "TensorPrime Gemma 4",
+ description:
+ "Connect the TensorPrime vLLM Gemma 4 endpoint. Models are discovered at setup time through the governed platform API.",
+ kind: "remote",
+ tags: ["remote", "governed", "keyless"],
+ size_gb: null,
+ min_vram_gb: null,
+ remote: {
+ base_url: "http://api.tprime.vlans.ca",
+ model: "",
+ authentication: "none",
+ },
+ },
+ {
+ id: "tensorprime-litellm",
+ name: "TensorPrime LiteLLM Gateway",
+ description:
+ "Connect the TensorPrime LiteLLM multi-model gateway. All routed models are discovered at setup time through the governed platform API.",
+ kind: "remote",
+ tags: ["remote", "governed", "keyless", "multi-model"],
+ size_gb: null,
+ min_vram_gb: null,
+ remote: {
+ base_url: "http://api.tprime.vlans.ca",
+ model: "",
+ authentication: "none",
+ },
+ },
{
id: "deepseek-v4-flash",
name: "DeepSeek V4 Flash",
@@ -56,6 +101,45 @@ export const STUDIO_STARTER_PRESETS: StudioStarterPreset[] = [
remote: {
base_url: "http://pop-os-1.tailadb2c1.ts.net:8080/v1",
model: "deepseek-v4-flash",
+ authentication: "api_key",
+ },
+ },
+ {
+ id: "local-llm-server",
+ name: "Local LLM server",
+ description:
+ "Connect to an OpenAI-compatible server already running on this machine (Ollama, LM Studio, llama-server, vLLM). Models are discovered at setup time.",
+ kind: "remote",
+ tags: ["remote", "local", "keyless"],
+ size_gb: null,
+ min_vram_gb: null,
+ remote: {
+ base_url: "http://localhost:11434/v1",
+ model: "",
+ authentication: "none",
+ },
+ },
+ {
+ id: "trustnest-apim",
+ name: "Thales TrustNest APIM",
+ description:
+ "Connect to the Thales TrustNest AI Models API via Entra ID client credentials. Models are discovered at setup time; a subscription key is required.",
+ kind: "remote",
+ tags: ["remote", "governed", "apim"],
+ size_gb: null,
+ min_vram_gb: null,
+ remote: {
+ base_url: "https://api.thalesdigital.io/ai-models/openai/v1",
+ model: "",
+ authentication: "apim_client",
+ issuer_id: "https://login.microsoftonline.com/common/v2.0",
+ audience: "api://c94dc58f-d839-4fdf-b0a4-22442c7baf50",
+ scopes: ["api://c94dc58f-d839-4fdf-b0a4-22442c7baf50/.default"],
+ token_endpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
+ client_id: "",
+ path_style: "openai",
+ api_version: "2024-06-01",
+ subscription_key_header: "TrustNest-Apim-Subscription-Key",
},
},
];
diff --git a/controller/src/modules/studio/provider-routes.ts b/controller/src/modules/studio/provider-routes.ts
index 30e140217..bcb025fc2 100644
--- a/controller/src/modules/studio/provider-routes.ts
+++ b/controller/src/modules/studio/provider-routes.ts
@@ -1,9 +1,27 @@
import { Effect, Schema } from "effect";
-import { badRequest, notFound } from "../../core/errors";
+import { badRequest, notFound, serviceUnavailable } from "../../core/errors";
import { decodeJsonBody } from "../../core/validation";
import { effectHandler } from "../../http/effect-handler";
import { documentRoute, defineRoutes, mergeRoutes } from "../../http/route-registrar";
import { savePersistedConfig, type ProviderConfig } from "../../config/persisted-config";
+import {
+ FoundryProjectConnectionSchema,
+ ProviderAuthenticationSchema,
+} from "@local-studio/contracts/enterprise-auth";
+import {
+ discoverProviderModels,
+ isReservedProviderId,
+ providerIsDiscoverable,
+} from "../../services/provider-routing";
+import { normalizeProviderAuthentication } from "../../services/provider-authentication";
+import { normalizeAdmittedProviderBaseUrl } from "../../services/provider-boundary";
+import {
+ newProviderApiKeyReference,
+ newProviderClientSecretReference,
+ newProviderSubscriptionKeyReference,
+ type ProviderSecretMutation,
+ type ProviderSecretStore,
+} from "../../services/provider-secret-store";
type ProviderView = {
id: string;
@@ -11,25 +29,57 @@ type ProviderView = {
base_url: string;
enabled: boolean;
has_api_key: boolean;
+ authentication: ProviderConfig["authentication"];
+ subscription_key: ProviderConfig["subscription_key"];
+ foundry: ProviderConfig["foundry"];
+ path_style: ProviderConfig["path_style"];
+ api_version: ProviderConfig["api_version"];
};
+const ProviderSubscriptionKeyPayloadSchema = Schema.Struct({
+ header: Schema.String,
+ value: Schema.String,
+});
+
const ProviderCreateSchema = Schema.Struct({
id: Schema.String,
name: Schema.String,
base_url: Schema.String,
api_key: Schema.optional(Schema.String),
+ client_secret: Schema.optional(Schema.String),
+ foundry_client_secret: Schema.optional(Schema.String),
+ subscription_key: Schema.optional(ProviderSubscriptionKeyPayloadSchema),
enabled: Schema.optional(Schema.Boolean),
+ authentication: Schema.optional(ProviderAuthenticationSchema),
+ foundry: Schema.optional(FoundryProjectConnectionSchema),
+ path_style: Schema.optional(Schema.Literals(["openai", "azure"])),
+ api_version: Schema.optional(Schema.String),
});
const ProviderUpdateSchema = Schema.Struct({
name: Schema.optional(Schema.String),
base_url: Schema.optional(Schema.String),
api_key: Schema.optional(Schema.String),
+ client_secret: Schema.optional(Schema.String),
+ foundry_client_secret: Schema.optional(Schema.String),
+ subscription_key: Schema.optional(ProviderSubscriptionKeyPayloadSchema),
enabled: Schema.optional(Schema.Boolean),
+ authentication: Schema.optional(ProviderAuthenticationSchema),
+ foundry: Schema.optional(FoundryProjectConnectionSchema),
+ path_style: Schema.optional(Schema.Literals(["openai", "azure"])),
+ api_version: Schema.optional(Schema.String),
});
-const ProviderModelsSchema = Schema.Struct({
- data: Schema.optional(Schema.Array(Schema.Struct({ id: Schema.optional(Schema.String) }))),
+const ProviderProbeSchema = Schema.Struct({
+ id: Schema.String,
+ name: Schema.String,
+ base_url: Schema.String,
+ api_key: Schema.optional(Schema.String),
+ client_secret: Schema.optional(Schema.String),
+ subscription_key: Schema.optional(ProviderSubscriptionKeyPayloadSchema),
+ authentication: Schema.optional(ProviderAuthenticationSchema),
+ path_style: Schema.optional(Schema.Literals(["openai", "azure"])),
+ api_version: Schema.optional(Schema.String),
});
class ProviderPersistenceError extends Schema.TaggedErrorClass()(
@@ -37,22 +87,33 @@ class ProviderPersistenceError extends Schema.TaggedErrorClass ({
+const serializeProvider = (provider: ProviderConfig, hasApiKey: boolean): ProviderView => ({
id: provider.id,
name: provider.name,
base_url: provider.base_url,
enabled: provider.enabled,
- has_api_key: Boolean(provider.api_key),
+ has_api_key: hasApiKey,
+ authentication: provider.authentication,
+ subscription_key: provider.subscription_key,
+ foundry: provider.foundry,
+ path_style: provider.path_style,
+ api_version: provider.api_version,
});
const saveProviders = (
- context: { config: { data_dir: string; providers: ProviderConfig[] } },
+ context: {
+ config: { data_dir: string; providers: ProviderConfig[] };
+ providerSecretStore: ProviderSecretStore;
+ },
providers: ProviderConfig[],
+ secretMutations: readonly ProviderSecretMutation[] = [],
): Effect.Effect =>
Effect.try({
try: () => {
- savePersistedConfig(context.config.data_dir, { providers });
- context.config.providers = providers;
+ context.providerSecretStore.mutateSync(secretMutations, () => {
+ savePersistedConfig(context.config.data_dir, { providers }, context.providerSecretStore);
+ context.config.providers = providers;
+ });
},
catch: (source) =>
new ProviderPersistenceError({ message: "Could not save providers", source }),
@@ -66,39 +127,210 @@ const required = (
return trimmed ? Effect.succeed(trimmed) : Effect.fail(badRequest(`${label} is required`));
};
-const providerModels = (
- provider: ProviderConfig,
-): Effect.Effect<{ provider: string; models: Array<{ id: string }> }, unknown> =>
- Effect.gen(function* () {
- const url = `${provider.base_url.replace(/\/+$/, "")}/v1/models`;
- const response = yield* Effect.tryPromise({
- try: () =>
- fetch(url, {
- headers: { Authorization: `Bearer ${provider.api_key}` },
- signal: AbortSignal.timeout(10_000),
- }),
- catch: (source) => source,
- });
- if (!response.ok) return yield* Effect.fail(response.status);
- const payload = yield* Effect.tryPromise({
- try: () => response.json(),
- catch: (source) => source,
- });
- const decoded = yield* Schema.decodeUnknownEffect(ProviderModelsSchema)(payload);
- const models = (decoded.data ?? []).flatMap((model) => {
- const id = model.id?.trim();
- return id ? [{ id }] : [];
- });
- return { provider: provider.id, models };
+const normalizedBaseUrl = (value: string): Effect.Effect> =>
+ Effect.try({
+ try: () => normalizeAdmittedProviderBaseUrl(value),
+ catch: () => badRequest("base_url host must be listed in LOCAL_STUDIO_PROVIDER_HOST_ALLOWLIST"),
});
+const normalizedProviderId = (
+ value: string,
+): Effect.Effect> =>
+ required(value, "id").pipe(
+ Effect.map((id) => id.toLowerCase()),
+ Effect.filterOrFail(
+ (id) => /^[a-z0-9][a-z0-9_-]{0,63}$/u.test(id),
+ () => badRequest("id must use 1-64 lowercase letters, numbers, underscores, or hyphens"),
+ ),
+ Effect.filterOrFail(
+ (id) => !isReservedProviderId(id),
+ () => badRequest('Provider id "openai" is reserved for local inference'),
+ ),
+ );
+
+const normalizedCredentials = (
+ providerId: string,
+ apiKey: string,
+ authentication: ProviderConfig["authentication"],
+ hasStoredApiKey: boolean,
+): Effect.Effect> => {
+ let normalized: ProviderConfig["authentication"];
+ try {
+ normalized = normalizeProviderAuthentication(providerId, authentication);
+ } catch {
+ return Effect.fail(badRequest("Provider authentication configuration is invalid"));
+ }
+ if (normalized.type === "none") {
+ return apiKey
+ ? Effect.fail(badRequest("api_key is not accepted for keyless authentication"))
+ : Effect.succeed(normalized);
+ }
+ if (normalized.type === "api_key") {
+ return apiKey || hasStoredApiKey
+ ? Effect.succeed(normalized)
+ : Effect.fail(badRequest("api_key is required when authentication.type is api_key"));
+ }
+ return apiKey
+ ? Effect.fail(badRequest("api_key is not accepted for this authentication type"))
+ : Effect.succeed(normalized);
+};
+
+const subscriptionKeyCredentialSet = (
+ providerId: string,
+ current: ProviderConfig["subscription_key"],
+ update: { header: string; value: string } | undefined,
+): {
+ subscription_key: ProviderConfig["subscription_key"];
+ mutations: ProviderSecretMutation[];
+} => {
+ if (!update) return { subscription_key: current, mutations: [] };
+ const header = update.header.trim();
+ const value = update.value.trim();
+ if (!header) {
+ if (current) return { subscription_key: current, mutations: [] };
+ return { subscription_key: undefined, mutations: [] };
+ }
+ if (!value) {
+ return { subscription_key: undefined, mutations: [] };
+ }
+ const reference = newProviderSubscriptionKeyReference(providerId);
+ return {
+ subscription_key: { header, secret_ref: reference },
+ mutations: [{ ref: reference, value }],
+ };
+};
+
+const withVersionedCredentialReferences = (
+ providerId: string,
+ authentication: ProviderConfig["authentication"],
+ apiKey: string,
+ clientSecret: string,
+): {
+ authentication: ProviderConfig["authentication"];
+ mutations: ProviderSecretMutation[];
+} => {
+ let next = authentication;
+ const mutations: ProviderSecretMutation[] = [];
+ if (next.type === "api_key" && apiKey) {
+ const reference = newProviderApiKeyReference(providerId);
+ next = { type: "api_key", secret_ref: reference };
+ mutations.push({ ref: reference, value: apiKey });
+ }
+ if (
+ (next.type === "oidc_user" || next.type === "apim_gateway") &&
+ next.token_exchange &&
+ clientSecret
+ ) {
+ const reference = newProviderClientSecretReference(providerId);
+ next = {
+ ...next,
+ token_exchange: { ...next.token_exchange, client_secret_ref: reference },
+ };
+ mutations.push({ ref: reference, value: clientSecret });
+ }
+ if (next.type === "apim_client" && clientSecret) {
+ const reference = newProviderClientSecretReference(providerId);
+ next = { ...next, client_secret_ref: reference };
+ mutations.push({ ref: reference, value: clientSecret });
+ }
+ return { authentication: next, mutations };
+};
+
+const withFoundryClientSecretReference = (
+ providerId: string,
+ foundry: ProviderConfig["foundry"],
+ clientSecret: string,
+): {
+ foundry: ProviderConfig["foundry"];
+ mutations: ProviderSecretMutation[];
+} => {
+ if (!foundry || !clientSecret) return { foundry, mutations: [] };
+ const authentication = foundry.authentication;
+ if (
+ (authentication.type !== "oidc_user" && authentication.type !== "apim_gateway") ||
+ !authentication.token_exchange
+ ) {
+ throw new TypeError("Foundry client secret requires delegated token exchange");
+ }
+ const reference = newProviderClientSecretReference(providerId);
+ return {
+ foundry: {
+ ...foundry,
+ authentication: {
+ ...authentication,
+ token_exchange: {
+ ...authentication.token_exchange,
+ client_secret_ref: reference,
+ },
+ },
+ },
+ mutations: [{ ref: reference, value: clientSecret }],
+ };
+};
+
export const registerStudioProviderRoutes = defineRoutes((app, context) => {
return mergeRoutes(
app.get(
"/studio/providers",
documentRoute,
effectHandler((ctx) =>
- Effect.sync(() => ctx.json({ providers: context.config.providers.map(serializeProvider) })),
+ Effect.sync(() =>
+ ctx.json({
+ providers: context.config.providers.map((provider) =>
+ serializeProvider(
+ provider,
+ provider.authentication.type === "api_key" &&
+ Boolean(
+ provider.authentication.secret_ref &&
+ context.providerSecretStore.readSync(provider.authentication.secret_ref),
+ ),
+ ),
+ ),
+ }),
+ ),
+ ),
+ ),
+
+ app.post(
+ "/studio/providers/probe",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const body = yield* decodeJsonBody(ctx, ProviderProbeSchema);
+ const id = yield* normalizedProviderId(body.id);
+ const name = yield* required(body.name, "name");
+ const baseUrl = yield* normalizedBaseUrl(yield* required(body.base_url, "base_url"));
+ const apiKey = body.api_key?.trim() ?? "";
+ const authentication = yield* normalizedCredentials(
+ id,
+ apiKey,
+ body.authentication ?? (apiKey ? { type: "api_key" } : { type: "none" }),
+ false,
+ );
+ const catalog = yield* discoverProviderModels(
+ {
+ id,
+ name,
+ base_url: baseUrl,
+ enabled: true,
+ authentication,
+ ...(body.path_style ? { path_style: body.path_style } : {}),
+ ...(body.api_version ? { api_version: body.api_version } : {}),
+ },
+ fetch,
+ {
+ directApiKey: apiKey,
+ directClientSecret: body.client_secret,
+ directSubscriptionKey: body.subscription_key,
+ principal: ctx.get("enterprisePrincipal"),
+ verifiedBearerToken: ctx.get("enterpriseBearerToken"),
+ signal: ctx.req.raw.signal,
+ },
+ ).pipe(
+ Effect.mapError(() => serviceUnavailable(`Provider "${id}" model discovery failed`)),
+ );
+ return ctx.json(catalog);
+ }),
),
),
@@ -108,21 +340,74 @@ export const registerStudioProviderRoutes = defineRoutes((app, context) => {
effectHandler((ctx) =>
Effect.gen(function* () {
const body = yield* decodeJsonBody(ctx, ProviderCreateSchema);
- const id = (yield* required(body.id, "id")).toLowerCase();
+ const id = yield* normalizedProviderId(body.id);
const name = yield* required(body.name, "name");
- const baseUrl = yield* required(body.base_url, "base_url");
- if (context.config.providers.some((provider) => provider.id === id)) {
+ const baseUrl = yield* normalizedBaseUrl(yield* required(body.base_url, "base_url"));
+ if (context.config.providers.some((provider) => provider.id.toLowerCase() === id)) {
return yield* Effect.fail(badRequest(`Provider "${id}" already exists`));
}
+ const apiKey = body.api_key?.trim() ?? "";
+ const clientSecret = body.client_secret?.trim() ?? "";
+ const foundryClientSecret = body.foundry_client_secret?.trim() ?? "";
+ const authentication = yield* normalizedCredentials(
+ id,
+ apiKey,
+ body.authentication ?? (apiKey ? { type: "api_key" } : { type: "none" }),
+ false,
+ );
+ if (
+ clientSecret &&
+ !(
+ ((authentication.type === "oidc_user" || authentication.type === "apim_gateway") &&
+ authentication.token_exchange) ||
+ authentication.type === "apim_client"
+ )
+ ) {
+ return yield* Effect.fail(
+ badRequest("client_secret requires delegated token exchange or apim_client"),
+ );
+ }
+ const credentialSet = withVersionedCredentialReferences(
+ id,
+ authentication,
+ apiKey,
+ clientSecret,
+ );
+ const subscriptionKeySet = subscriptionKeyCredentialSet(
+ id,
+ undefined,
+ body.subscription_key,
+ );
+ const foundryCredentialSet = yield* Effect.try({
+ try: () => withFoundryClientSecretReference(id, body.foundry, foundryClientSecret),
+ catch: () => badRequest("foundry_client_secret requires delegated token exchange"),
+ });
const provider: ProviderConfig = {
id,
name,
base_url: baseUrl,
- api_key: body.api_key?.trim() ?? "",
enabled: body.enabled ?? true,
+ authentication: credentialSet.authentication,
+ ...(subscriptionKeySet.subscription_key
+ ? { subscription_key: subscriptionKeySet.subscription_key }
+ : {}),
+ ...(foundryCredentialSet.foundry ? { foundry: foundryCredentialSet.foundry } : {}),
+ ...(body.path_style ? { path_style: body.path_style } : {}),
+ ...(body.api_version ? { api_version: body.api_version } : {}),
};
- yield* saveProviders(context, [...context.config.providers, provider]);
- return ctx.json({ success: true, provider: serializeProvider(provider) });
+ yield* saveProviders(
+ context,
+ [...context.config.providers, provider],
+ [
+ ...credentialSet.mutations,
+ ...subscriptionKeySet.mutations,
+ ...foundryCredentialSet.mutations,
+ ],
+ );
+ return ctx.json({
+ success: true,
+ provider: serializeProvider(provider, credentialSet.authentication.type === "api_key"),
+ });
}),
),
),
@@ -133,6 +418,11 @@ export const registerStudioProviderRoutes = defineRoutes((app, context) => {
effectHandler((ctx) =>
Effect.gen(function* () {
const providerId = ctx.req.param("id") ?? "";
+ if (isReservedProviderId(providerId)) {
+ return yield* Effect.fail(
+ badRequest('Provider id "openai" is reserved for local inference'),
+ );
+ }
const body = yield* decodeJsonBody(ctx, ProviderUpdateSchema);
const index = context.config.providers.findIndex(
(provider) => provider.id === providerId,
@@ -143,18 +433,97 @@ export const registerStudioProviderRoutes = defineRoutes((app, context) => {
const baseUrl =
body.base_url === undefined
? current.base_url
- : yield* required(body.base_url, "base_url");
+ : yield* normalizedBaseUrl(yield* required(body.base_url, "base_url"));
+ const apiKey = body.api_key?.trim() ?? "";
+ const clientSecret = body.client_secret?.trim() ?? "";
+ const foundryClientSecret = body.foundry_client_secret?.trim() ?? "";
+ const requestedAuthentication =
+ body.authentication ??
+ (body.api_key === undefined
+ ? current.authentication
+ : apiKey
+ ? { type: "api_key" }
+ : { type: "none" });
+ const authentication =
+ requestedAuthentication.type === "api_key" &&
+ !requestedAuthentication.secret_ref &&
+ current.authentication.type === "api_key" &&
+ current.authentication.secret_ref
+ ? {
+ ...requestedAuthentication,
+ secret_ref: current.authentication.secret_ref,
+ }
+ : requestedAuthentication;
+ const hasStoredApiKey =
+ current.authentication.type === "api_key" &&
+ Boolean(
+ current.authentication.secret_ref &&
+ context.providerSecretStore.readSync(current.authentication.secret_ref),
+ );
+ const normalizedAuthentication = yield* normalizedCredentials(
+ providerId,
+ apiKey,
+ authentication,
+ hasStoredApiKey,
+ );
+ if (
+ clientSecret &&
+ !(
+ ((normalizedAuthentication.type === "oidc_user" ||
+ normalizedAuthentication.type === "apim_gateway") &&
+ normalizedAuthentication.token_exchange) ||
+ normalizedAuthentication.type === "apim_client"
+ )
+ ) {
+ return yield* Effect.fail(
+ badRequest("client_secret requires delegated token exchange or apim_client"),
+ );
+ }
+ const credentialSet = withVersionedCredentialReferences(
+ providerId,
+ normalizedAuthentication,
+ apiKey,
+ clientSecret,
+ );
+ const subscriptionKeySet = subscriptionKeyCredentialSet(
+ providerId,
+ current.subscription_key,
+ body.subscription_key,
+ );
+ const foundryCredentialSet = yield* Effect.try({
+ try: () =>
+ withFoundryClientSecretReference(
+ providerId,
+ body.foundry ?? current.foundry,
+ foundryClientSecret,
+ ),
+ catch: () => badRequest("foundry_client_secret requires delegated token exchange"),
+ });
const updated: ProviderConfig = {
id: providerId,
name,
base_url: baseUrl,
- api_key: body.api_key?.trim() ?? current.api_key,
enabled: body.enabled ?? current.enabled,
+ authentication: credentialSet.authentication,
+ ...(subscriptionKeySet.subscription_key
+ ? { subscription_key: subscriptionKeySet.subscription_key }
+ : {}),
+ ...(foundryCredentialSet.foundry ? { foundry: foundryCredentialSet.foundry } : {}),
+ ...(body.path_style ? { path_style: body.path_style } : {}),
+ ...(body.api_version ? { api_version: body.api_version } : {}),
};
const providers = [...context.config.providers];
providers[index] = updated;
- yield* saveProviders(context, providers);
- return ctx.json({ success: true, provider: serializeProvider(updated) });
+ const secretMutations: ProviderSecretMutation[] = [
+ ...credentialSet.mutations,
+ ...subscriptionKeySet.mutations,
+ ...foundryCredentialSet.mutations,
+ ];
+ yield* saveProviders(context, providers, secretMutations);
+ return ctx.json({
+ success: true,
+ provider: serializeProvider(updated, credentialSet.authentication.type === "api_key"),
+ });
}),
),
),
@@ -182,8 +551,14 @@ export const registerStudioProviderRoutes = defineRoutes((app, context) => {
documentRoute,
effectHandler((ctx) =>
Effect.forEach(
- context.config.providers.filter((provider) => provider.enabled && provider.api_key),
- (provider) => providerModels(provider).pipe(Effect.option),
+ context.config.providers.filter(providerIsDiscoverable),
+ (provider) =>
+ discoverProviderModels(provider, fetch, {
+ secretStore: context.providerSecretStore,
+ principal: ctx.get("enterprisePrincipal"),
+ verifiedBearerToken: ctx.get("enterpriseBearerToken"),
+ signal: ctx.req.raw.signal,
+ }).pipe(Effect.option),
{ concurrency: "unbounded" },
).pipe(
Effect.map((results) =>
diff --git a/controller/src/modules/studio/routes.ts b/controller/src/modules/studio/routes.ts
index a692c44a0..705f7bb86 100644
--- a/controller/src/modules/studio/routes.ts
+++ b/controller/src/modules/studio/routes.ts
@@ -1,4 +1,5 @@
import { cp, mkdir, rename, rm, statfs } from "node:fs/promises";
+import { writeFileSync } from "node:fs";
import { cpus, freemem, totalmem, platform, arch, release } from "node:os";
import { basename, resolve, sep } from "node:path";
import { Effect, Schema } from "effect";
@@ -9,6 +10,15 @@ import { documentRoute, defineRoutes, mergeRoutes } from "../../http/route-regis
import { registerStudioModelIndexRoutes } from "./model-index";
import { registerStudioProviderRoutes } from "./provider-routes";
import { registerStudioRigRoutes } from "./rig-routes";
+import {
+ ScientistProfileCreateSchema,
+ type ScientistProfile,
+} from "@local-studio/contracts/scientist-profile";
+import {
+ PROJECT_TEMPLATES,
+ type ProjectTemplate,
+ type ProjectTemplateCell,
+} from "@local-studio/contracts/project-templates";
import { getGpuInfo } from "../system/platform/gpu";
import type { GpuInfo } from "../models/types";
import { discoverModelDirectories, estimateWeightsSizeBytes } from "../models/model-browser";
@@ -62,6 +72,25 @@ const diskInfo = (path: string): Effect.Effect =>
),
);
+const buildNotebookFromTemplate = (
+ cells: ReadonlyArray,
+): Record => ({
+ nbformat: 4,
+ nbformat_minor: 5,
+ metadata: {
+ kernelspec: { display_name: "Python 3", language: "python", name: "python3" },
+ language_info: { name: "python" },
+ },
+ cells: cells.map((cell) => ({
+ cell_type: cell.cell_type,
+ metadata: cell.metadata ?? {},
+ source: cell.source.split("\n").map((line, index, array) =>
+ index < array.length - 1 ? `${line}\n` : line,
+ ),
+ ...(cell.cell_type === "code" ? { execution_count: null, outputs: [] } : {}),
+ })),
+});
+
const insideModelsRoot = (
modelsDirectory: string,
target: string,
@@ -119,6 +148,7 @@ export const registerStudioRoutes = defineRoutes((app, context) => {
config_path: getPersistedConfigPath(context.config.data_dir),
persisted: { models_dir: persisted.models_dir, ui_preferences: uiPreferences },
effective: { models_dir: context.config.models_dir },
+ notebook_root: context.config.notebook_root,
};
});
@@ -352,6 +382,224 @@ export const registerStudioRoutes = defineRoutes((app, context) => {
),
),
+ app.get(
+ "/studio/scientist-profile",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const persisted = yield* Effect.try({
+ try: () => loadPersistedConfig(context.config.data_dir),
+ catch: (source) =>
+ new StudioOperationError({
+ operation: "settings",
+ message: "Could not load settings",
+ source,
+ }),
+ });
+ return ctx.json({ profile: persisted.scientist_profile ?? null });
+ }),
+ ),
+ ),
+
+ app.put(
+ "/studio/scientist-profile",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const body = yield* decodeJsonBody(ctx, ScientistProfileCreateSchema);
+ if (body.data_types.length === 0) {
+ return yield* Effect.fail(badRequest("At least one data type is required"));
+ }
+ if (body.goals.length === 0) {
+ return yield* Effect.fail(badRequest("At least one goal is required"));
+ }
+ const now = new Date().toISOString();
+ const profile: ScientistProfile = {
+ ...body,
+ created_at: now,
+ updated_at: now,
+ };
+ yield* Effect.try({
+ try: () =>
+ savePersistedConfig(context.config.data_dir, { scientist_profile: profile }),
+ catch: (source) =>
+ new StudioOperationError({
+ operation: "settings",
+ message: "Could not save scientist profile",
+ source,
+ }),
+ });
+ return ctx.json({ profile });
+ }),
+ ),
+ ),
+
+ app.get(
+ "/studio/project-templates",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const profile = yield* Effect.try({
+ try: () => loadPersistedConfig(context.config.data_dir),
+ catch: () => null,
+ });
+ const scientistProfile = profile?.scientist_profile ?? null;
+ const templates: Array = PROJECT_TEMPLATES;
+ if (scientistProfile) {
+ for (const template of templates) {
+ const goalOverlap = template.recommended_goals.filter((g) =>
+ scientistProfile.goals.includes(g),
+ ).length;
+ const dataOverlap = template.recommended_data_types.filter((d) =>
+ scientistProfile.data_types.includes(d),
+ ).length;
+ template.match_score = goalOverlap + dataOverlap;
+ }
+ }
+ return ctx.json({ templates });
+ }),
+ ),
+ ),
+
+ app.get(
+ "/studio/project-templates/:templateId",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const templateId = ctx.req.param("templateId") ?? "";
+ const template = PROJECT_TEMPLATES.find((t) => t.id === templateId);
+ if (!template) return yield* Effect.fail(notFound("Template not found"));
+ return ctx.json({ template });
+ }),
+ ),
+ ),
+
+ app.post(
+ "/studio/project-templates/:templateId/materialize",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const templateId = ctx.req.param("templateId") ?? "";
+ const template = PROJECT_TEMPLATES.find((t) => t.id === templateId);
+ if (!template) return yield* Effect.fail(notFound("Template not found"));
+ const body = yield* decodeJsonBody(
+ ctx,
+ Schema.Struct({
+ project_path: Schema.optional(Schema.String),
+ project_name: Schema.optional(Schema.String),
+ }),
+ );
+ const projectPath = resolve(
+ body.project_path?.trim() ||
+ resolve(context.config.notebook_root, body.project_name?.trim() || template.name),
+ );
+ yield* Effect.tryPromise({
+ try: () => mkdir(projectPath, { recursive: true }),
+ catch: (source) =>
+ new StudioOperationError({
+ operation: "disk",
+ message: "Could not create project directory",
+ source,
+ }),
+ });
+ const notebookPath = resolve(projectPath, "notebook.ipynb");
+ const notebookDocument = buildNotebookFromTemplate(template.notebook_cells);
+ yield* Effect.try({
+ try: () => writeFileSync(notebookPath, JSON.stringify(notebookDocument, null, 2), { mode: 0o600 }),
+ catch: (source) =>
+ new StudioOperationError({
+ operation: "disk",
+ message: "Could not write notebook file",
+ source,
+ }),
+ });
+ const agentContextPath = resolve(projectPath, ".agent-context.md");
+ yield* Effect.try({
+ try: () => writeFileSync(agentContextPath, template.agent_prompt, { mode: 0o600 }),
+ catch: (source) =>
+ new StudioOperationError({
+ operation: "disk",
+ message: "Could not write agent context file",
+ source,
+ }),
+ });
+ return ctx.json({
+ project_path: projectPath,
+ notebook_path: notebookPath,
+ agent_context_path: agentContextPath,
+ template_id: template.id,
+ template_name: template.name,
+ });
+ }),
+ ),
+ ),
+
+ app.post(
+ "/studio/projects/custom",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const body = yield* decodeJsonBody(
+ ctx,
+ Schema.Struct({
+ project_name: Schema.String,
+ project_path: Schema.optional(Schema.String),
+ notebook_cells: Schema.Array(
+ Schema.Struct({
+ cell_type: Schema.Literals(["code", "markdown"]),
+ source: Schema.String,
+ }),
+ ),
+ agent_prompt: Schema.String,
+ }),
+ );
+ if (body.notebook_cells.length === 0) {
+ return yield* Effect.fail(badRequest("At least one notebook cell is required"));
+ }
+ const projectPath = resolve(
+ body.project_path?.trim() || resolve(context.config.notebook_root, body.project_name.trim()),
+ );
+ yield* Effect.tryPromise({
+ try: () => mkdir(projectPath, { recursive: true }),
+ catch: (source) =>
+ new StudioOperationError({
+ operation: "disk",
+ message: "Could not create project directory",
+ source,
+ }),
+ });
+ const notebookPath = resolve(projectPath, "notebook.ipynb");
+ const notebookDocument = buildNotebookFromTemplate(body.notebook_cells);
+ yield* Effect.try({
+ try: () => writeFileSync(notebookPath, JSON.stringify(notebookDocument, null, 2), { mode: 0o600 }),
+ catch: (source) =>
+ new StudioOperationError({
+ operation: "disk",
+ message: "Could not write notebook file",
+ source,
+ }),
+ });
+ const agentContextPath = resolve(projectPath, ".agent-context.md");
+ yield* Effect.try({
+ try: () => writeFileSync(agentContextPath, body.agent_prompt, { mode: 0o600 }),
+ catch: (source) =>
+ new StudioOperationError({
+ operation: "disk",
+ message: "Could not write agent context file",
+ source,
+ }),
+ });
+ return ctx.json({
+ project_path: projectPath,
+ notebook_path: notebookPath,
+ agent_context_path: agentContextPath,
+ template_id: "custom",
+ template_name: body.project_name.trim(),
+ });
+ }),
+ ),
+ ),
+
registerStudioModelIndexRoutes(app, context),
registerStudioProviderRoutes(app, context),
registerStudioRigRoutes(app, context),
diff --git a/controller/src/modules/studio/types.ts b/controller/src/modules/studio/types.ts
index 546fca169..d99a4c300 100644
--- a/controller/src/modules/studio/types.ts
+++ b/controller/src/modules/studio/types.ts
@@ -18,5 +18,17 @@ export interface StudioStarterPreset {
gguf_file?: string;
/** Extra recipe fields merged over the starter recipe defaults. */
recipe_overrides?: Record;
- remote?: { base_url: string; model: string };
+ remote?: {
+ base_url: string;
+ model: string;
+ authentication: "none" | "api_key" | "apim_client";
+ issuer_id?: string;
+ audience?: string;
+ scopes?: string[];
+ token_endpoint?: string;
+ client_id?: string;
+ path_style?: "openai" | "azure";
+ api_version?: string;
+ subscription_key_header?: string;
+ };
}
diff --git a/controller/src/modules/workbench/enterprise-identity.ts b/controller/src/modules/workbench/enterprise-identity.ts
new file mode 100644
index 000000000..6529b7aa3
--- /dev/null
+++ b/controller/src/modules/workbench/enterprise-identity.ts
@@ -0,0 +1,144 @@
+import type {
+ EnterprisePrincipalScope,
+ NormalizedPrincipal,
+} from "@local-studio/contracts/enterprise-auth";
+import type {
+ ScientificExperimentReceipt,
+ ScientificNotebookSession,
+} from "@local-studio/contracts/scientific-workbench";
+import { forbidden, notFound } from "../../core/errors";
+import type { ScientificRayJobRecord } from "./types";
+
+const normalizedIssuer = (value: string): string => value.replace(/\/+$/u, "");
+
+export const scientificPrincipalScope = (
+ principal: NormalizedPrincipal,
+): EnterprisePrincipalScope => ({
+ subject: principal.subject,
+ issuer: principal.issuer,
+ issuer_id: principal.issuer_id,
+ tenant: principal.tenant,
+ clearance: principal.clearance,
+});
+
+const sameAuthorityDomain = (
+ principal: NormalizedPrincipal,
+ scope: EnterprisePrincipalScope,
+): boolean =>
+ normalizedIssuer(principal.issuer) === normalizedIssuer(scope.issuer) &&
+ principal.issuer_id === scope.issuer_id &&
+ principal.tenant === scope.tenant;
+
+const canAccessScope = (principal: NormalizedPrincipal, scope: EnterprisePrincipalScope): boolean =>
+ sameAuthorityDomain(principal, scope) &&
+ (principal.subject === scope.subject || principal.roles.includes("platform_admin"));
+
+export const canAccessScientificNotebook = (
+ principal: NormalizedPrincipal | undefined,
+ notebook: ScientificNotebookSession,
+): boolean => {
+ if (!principal) return true;
+ if (notebook.owner_principal) return canAccessScope(principal, notebook.owner_principal);
+ return principal.subject === notebook.owner_id;
+};
+
+export const canAccessScientificRayJob = (
+ principal: NormalizedPrincipal | undefined,
+ job: ScientificRayJobRecord,
+): boolean => {
+ if (!principal) return true;
+ if (job.admission_principal) return canAccessScope(principal, job.admission_principal);
+ return principal.subject === job.submission.requested_by;
+};
+
+export const canAccessScientificReceipt = (
+ principal: NormalizedPrincipal | undefined,
+ receipt: ScientificExperimentReceipt,
+): boolean => {
+ if (!principal) return true;
+ if (!receipt.principal) return false;
+ if (receipt.principal.issuer) {
+ return canAccessScope(principal, {
+ ...receipt.principal,
+ issuer: receipt.principal.issuer,
+ });
+ }
+ return (
+ principal.issuer_id === receipt.principal.issuer_id &&
+ principal.tenant === receipt.principal.tenant &&
+ (principal.subject === receipt.principal.subject || principal.roles.includes("platform_admin"))
+ );
+};
+
+export const scientificActorId = (
+ principal: NormalizedPrincipal | undefined,
+ assertedActorId: string | undefined,
+): string => principal?.subject ?? assertedActorId?.trim() ?? "";
+
+export const bindScientificNotebookOwner = (
+ principal: NormalizedPrincipal | undefined,
+ assertedOwnerId: string,
+): string => {
+ const ownerId = assertedOwnerId.trim();
+ if (principal && ownerId && ownerId !== principal.subject) {
+ throw forbidden("Notebook owner must match the authenticated enterprise subject");
+ }
+ return principal?.subject ?? ownerId;
+};
+
+export const requireScientificNotebookMutationOwner = (
+ principal: NormalizedPrincipal | undefined,
+ notebook: ScientificNotebookSession,
+): ScientificNotebookSession => {
+ if (!canAccessScientificNotebook(principal, notebook)) {
+ throw notFound("Notebook not found");
+ }
+ return notebook;
+};
+
+export const requireScientificNotebookAccess = (
+ principal: NormalizedPrincipal | undefined,
+ notebook: ScientificNotebookSession,
+): ScientificNotebookSession => {
+ if (!canAccessScientificNotebook(principal, notebook)) {
+ throw notFound("Notebook not found");
+ }
+ return notebook;
+};
+
+export const requireScientificRayJobAccess = (
+ principal: NormalizedPrincipal | undefined,
+ job: ScientificRayJobRecord,
+): ScientificRayJobRecord => {
+ if (!canAccessScientificRayJob(principal, job)) {
+ throw notFound("RayJob not found");
+ }
+ return job;
+};
+
+export const requireScientificReceiptAccess = (
+ principal: NormalizedPrincipal | undefined,
+ receipt: ScientificExperimentReceipt,
+): ScientificExperimentReceipt => {
+ if (!canAccessScientificReceipt(principal, receipt)) {
+ throw notFound("Experiment receipt not found");
+ }
+ return receipt;
+};
+
+export const requireScientificSubmissionOwner = (
+ principal: NormalizedPrincipal | undefined,
+ requestedBy: string,
+): void => {
+ if (principal && requestedBy.trim() !== principal.subject) {
+ throw forbidden("Ray submission requester must match the authenticated enterprise subject");
+ }
+};
+
+export const scientificNotebookIdentity = (
+ notebook: ScientificNotebookSession,
+): { notebook_id: string; project_id: string; actor_id: string } => ({
+ notebook_id: notebook.id,
+ project_id: notebook.project_id,
+ actor_id: notebook.owner_id,
+});
diff --git a/controller/src/modules/workbench/experiment-routes.ts b/controller/src/modules/workbench/experiment-routes.ts
new file mode 100644
index 000000000..b6cb19b12
--- /dev/null
+++ b/controller/src/modules/workbench/experiment-routes.ts
@@ -0,0 +1,112 @@
+import { randomUUID } from "node:crypto";
+import {
+ ExperimentRecordCreateSchema,
+ ExperimentRecordUpdateSchema,
+ type ExperimentRecord,
+} from "@local-studio/contracts/experiment-tracking";
+import { Effect } from "effect";
+import { badRequest, notFound } from "../../core/errors";
+import { decodeJsonBody } from "../../core/validation";
+import { effectHandler } from "../../http/effect-handler";
+import { documentRoute, defineRoutes } from "../../http/route-registrar";
+
+export const registerExperimentTrackingRoutes = defineRoutes((app, context) => {
+ const store = context.stores.experimentTrackingStore;
+
+ return app
+ .get(
+ "/experiments",
+ documentRoute,
+ effectHandler((ctx) =>
+ store
+ .listExperiments(ctx.req.query("project_id") || undefined)
+ .pipe(Effect.map((experiments) => ctx.json({ experiments }))),
+ ),
+ )
+ .get(
+ "/experiments/:experimentId",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const experiment = yield* store.getExperiment(ctx.req.param("experimentId") ?? "");
+ if (!experiment) return yield* Effect.fail(notFound("Experiment not found"));
+ return ctx.json({ experiment });
+ }),
+ ),
+ )
+ .get(
+ "/experiments/:experimentId/lineage",
+ documentRoute,
+ effectHandler((ctx) =>
+ store
+ .listExperimentLineage(ctx.req.param("experimentId") ?? "")
+ .pipe(Effect.map((lineage) => ctx.json({ lineage }))),
+ ),
+ )
+ .post(
+ "/experiments",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const body = yield* decodeJsonBody(ctx, ExperimentRecordCreateSchema);
+ if (!body.name.trim()) {
+ return yield* Effect.fail(badRequest("Experiment name is required"));
+ }
+ const now = new Date().toISOString();
+ const experiment: ExperimentRecord = {
+ id: randomUUID(),
+ project_id: body.project_id,
+ name: body.name,
+ parameters: body.parameters ?? {},
+ metrics: {},
+ notes: body.notes,
+ artifacts: [],
+ parent_experiment_id: body.parent_experiment_id,
+ status: "running",
+ created_at: now,
+ updated_at: now,
+ };
+ yield* store.saveExperiment(experiment);
+ return ctx.json({ experiment }, 201);
+ }),
+ ),
+ )
+ .patch(
+ "/experiments/:experimentId",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const experimentId = ctx.req.param("experimentId") ?? "";
+ const existing = yield* store.getExperiment(experimentId);
+ if (!existing) return yield* Effect.fail(notFound("Experiment not found"));
+ const body = yield* decodeJsonBody(ctx, ExperimentRecordUpdateSchema);
+ const updated: ExperimentRecord = {
+ ...existing,
+ ...(body.name !== undefined ? { name: body.name } : {}),
+ ...(body.parameters !== undefined ? { parameters: body.parameters } : {}),
+ ...(body.metrics !== undefined ? { metrics: body.metrics } : {}),
+ ...(body.notes !== undefined ? { notes: body.notes } : {}),
+ ...(body.artifacts !== undefined ? { artifacts: body.artifacts } : {}),
+ ...(body.status !== undefined ? { status: body.status } : {}),
+ ...(body.completed_at !== undefined ? { completed_at: body.completed_at } : {}),
+ updated_at: new Date().toISOString(),
+ };
+ yield* store.saveExperiment(updated);
+ return ctx.json({ experiment: updated });
+ }),
+ ),
+ )
+ .delete(
+ "/experiments/:experimentId",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const experimentId = ctx.req.param("experimentId") ?? "";
+ const existing = yield* store.getExperiment(experimentId);
+ if (!existing) return yield* Effect.fail(notFound("Experiment not found"));
+ yield* store.deleteExperiment(experimentId);
+ return ctx.json({ success: true });
+ }),
+ ),
+ );
+});
diff --git a/controller/src/modules/workbench/experiment-store.ts b/controller/src/modules/workbench/experiment-store.ts
new file mode 100644
index 000000000..0e8b118d3
--- /dev/null
+++ b/controller/src/modules/workbench/experiment-store.ts
@@ -0,0 +1,120 @@
+import type { Database } from "bun:sqlite";
+import type { Effect } from "effect";
+import type { ExperimentRecord } from "@local-studio/contracts/experiment-tracking";
+import {
+ makeDatabaseCloser,
+ openInitializedDatabase,
+ repositoryEffect,
+ type RepositoryError,
+} from "../../stores/sqlite";
+
+type DataRow = { data: string };
+
+const decodeRow = (row: DataRow | null): A | null => {
+ if (!row) return null;
+ try {
+ return JSON.parse(row.data) as A;
+ } catch {
+ return null;
+ }
+};
+
+export class ExperimentTrackingStore {
+ private readonly db: Database;
+ private readonly closeDatabase: () => Effect.Effect;
+
+ public constructor(dbPath: string) {
+ this.db = openInitializedDatabase(dbPath, (db) => {
+ db.run(`
+ CREATE TABLE IF NOT EXISTS experiments (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ data TEXT NOT NULL,
+ created_at TEXT NOT NULL
+ )
+ `);
+ db.run("CREATE INDEX IF NOT EXISTS idx_experiments_project ON experiments(project_id)");
+ });
+ this.closeDatabase = makeDatabaseCloser(this.db, "experiment-tracking-store.close");
+ }
+
+ public close(): Effect.Effect {
+ return this.closeDatabase();
+ }
+
+ public listExperiments(
+ projectId?: string,
+ ): Effect.Effect {
+ return repositoryEffect("experiments.list", () => {
+ const rows = projectId
+ ? (this.db
+ .query("SELECT data FROM experiments WHERE project_id = ? ORDER BY created_at DESC")
+ .all(projectId) as DataRow[])
+ : (this.db
+ .query("SELECT data FROM experiments ORDER BY created_at DESC")
+ .all() as DataRow[]);
+ return rows.flatMap((row) => {
+ const value = decodeRow(row);
+ return value ? [value] : [];
+ });
+ });
+ }
+
+ public getExperiment(
+ experimentId: string,
+ ): Effect.Effect {
+ return repositoryEffect("experiments.get", () =>
+ decodeRow(
+ this.db.query("SELECT data FROM experiments WHERE id = ?").get(experimentId) as
+ | DataRow
+ | null,
+ ),
+ );
+ }
+
+ public saveExperiment(
+ experiment: ExperimentRecord,
+ ): Effect.Effect {
+ return repositoryEffect("experiments.save", () => {
+ this.db
+ .query(
+ `INSERT INTO experiments (id, project_id, data, created_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET project_id = excluded.project_id, data = excluded.data`,
+ )
+ .run(
+ experiment.id,
+ experiment.project_id,
+ JSON.stringify(experiment),
+ experiment.created_at,
+ );
+ });
+ }
+
+ public deleteExperiment(experimentId: string): Effect.Effect {
+ return repositoryEffect("experiments.delete", () => {
+ this.db.query("DELETE FROM experiments WHERE id = ?").run(experimentId);
+ });
+ }
+
+ public listExperimentLineage(
+ experimentId: string,
+ ): Effect.Effect {
+ return repositoryEffect("experiments.lineage", () => {
+ const visited = new Set();
+ const lineage: ExperimentRecord[] = [];
+ let currentId: string | undefined = experimentId;
+ while (currentId && !visited.has(currentId)) {
+ visited.add(currentId);
+ const row = this.db
+ .query("SELECT data FROM experiments WHERE id = ?")
+ .get(currentId) as DataRow | null;
+ const record = decodeRow(row);
+ if (!record) break;
+ lineage.unshift(record);
+ currentId = record.parent_experiment_id;
+ }
+ return lineage;
+ });
+ }
+}
diff --git a/controller/src/modules/workbench/kuberay-gateway.ts b/controller/src/modules/workbench/kuberay-gateway.ts
new file mode 100644
index 000000000..04c82e818
--- /dev/null
+++ b/controller/src/modules/workbench/kuberay-gateway.ts
@@ -0,0 +1,260 @@
+import { readFileSync } from "node:fs";
+import { Effect, Schema } from "effect";
+import { isHttpStatus, serviceUnavailable } from "../../core/errors";
+import type { ScientificRayJobRecord, ScientificRayJobResource } from "./types";
+
+type FetchEffect = (
+ input: string | URL | Request,
+ init?: RequestInit,
+) => Effect.Effect;
+
+export type KubeRayGatewayConfig = {
+ apiUrl: string;
+ tokenFile: string;
+ caFile?: string;
+};
+
+export type KubeRayGatewayProbe = {
+ kubernetesVersion: string;
+ rayApiVersion: string;
+};
+
+const KubernetesRayJobSchema = Schema.Struct({
+ metadata: Schema.Struct({
+ uid: Schema.optional(Schema.String),
+ resourceVersion: Schema.optional(Schema.String),
+ }),
+ status: Schema.optional(
+ Schema.Struct({
+ jobStatus: Schema.optional(Schema.String),
+ jobDeploymentStatus: Schema.optional(Schema.String),
+ message: Schema.optional(Schema.String),
+ startTime: Schema.optional(Schema.String),
+ endTime: Schema.optional(Schema.String),
+ }),
+ ),
+});
+
+const KubernetesVersionSchema = Schema.Struct({
+ gitVersion: Schema.String,
+});
+
+const KubernetesApiResourceListSchema = Schema.Struct({
+ groupVersion: Schema.String,
+ resources: Schema.Array(
+ Schema.Struct({
+ name: Schema.String,
+ verbs: Schema.Array(Schema.String),
+ }),
+ ),
+});
+
+type KubernetesRayJob = Schema.Schema.Type;
+
+const resourcePath = (resource: ScientificRayJobResource): string =>
+ `/apis/ray.io/v1/namespaces/${encodeURIComponent(resource.metadata.namespace)}/rayjobs/${encodeURIComponent(resource.metadata.name)}`;
+
+const gatewayState = (
+ jobStatus: string | null,
+ deploymentStatus: string | null,
+): ScientificRayJobRecord["state"] => {
+ if (jobStatus === "SUCCEEDED" || deploymentStatus === "Complete") return "succeeded";
+ if (jobStatus === "FAILED" || deploymentStatus === "Failed") return "failed";
+ if (deploymentStatus === "Suspended" || deploymentStatus === "Suspending") return "suspended";
+ if (deploymentStatus === "Running") return "running";
+ return "submitted";
+};
+
+const clusterStatus = (
+ value: KubernetesRayJob,
+): NonNullable => ({
+ uid: value.metadata.uid ?? null,
+ resource_version: value.metadata.resourceVersion ?? null,
+ job_status: value.status?.jobStatus ?? null,
+ deployment_status: value.status?.jobDeploymentStatus ?? null,
+ message: value.status?.message ?? null,
+ started_at: value.status?.startTime ?? null,
+ ended_at: value.status?.endTime ?? null,
+});
+
+const runtimeFetch: FetchEffect = (input, init) =>
+ Effect.tryPromise({
+ try: () => fetch(input, init),
+ catch: (error) => error,
+ });
+
+export class KubeRayGateway {
+ public constructor(
+ private readonly config: KubeRayGatewayConfig,
+ private readonly fetcher: FetchEffect = runtimeFetch,
+ private readonly readCredential: (path: string) => string = (path) =>
+ readFileSync(path, "utf8"),
+ ) {}
+
+ private headers(contentType?: string): Headers {
+ const token = this.readCredential(this.config.tokenFile).trim();
+ if (!token) throw serviceUnavailable("KubeRay workload token is empty");
+ const headers = new Headers({
+ Accept: "application/json",
+ Authorization: `Bearer ${token}`,
+ });
+ if (contentType) headers.set("Content-Type", contentType);
+ return headers;
+ }
+
+ private requestInit(method: string, body?: string): RequestInit {
+ const init: RequestInit & { tls?: { ca: string } } = {
+ method,
+ headers: this.headers(body ? "application/apply-patch+yaml" : undefined),
+ signal: AbortSignal.timeout(15_000),
+ ...(body ? { body } : {}),
+ };
+ if (this.config.caFile) {
+ init.tls = { ca: this.readCredential(this.config.caFile) };
+ }
+ return init;
+ }
+
+ private request(url: string, method: string, body?: string): Effect.Effect {
+ return Effect.try({
+ try: () => this.requestInit(method, body),
+ catch: () => serviceUnavailable("KubeRay credential material is unavailable"),
+ }).pipe(
+ Effect.flatMap((init) =>
+ Effect.suspend(() => {
+ try {
+ return this.fetcher(url, init);
+ } catch {
+ return Effect.fail(serviceUnavailable("KubeRay API request failed"));
+ }
+ }),
+ ),
+ Effect.mapError((error) =>
+ isHttpStatus(error) ? error : serviceUnavailable("KubeRay API request failed"),
+ ),
+ );
+ }
+
+ private decode(response: Response): Effect.Effect {
+ if (!response.ok) {
+ return Effect.fail(
+ serviceUnavailable(`KubeRay API returned HTTP ${response.status}`),
+ );
+ }
+ return Effect.tryPromise({
+ try: () => response.json(),
+ catch: (error) => error,
+ }).pipe(
+ Effect.flatMap(Schema.decodeUnknownEffect(KubernetesRayJobSchema)),
+ Effect.mapError(() => serviceUnavailable("KubeRay API returned an invalid RayJob document")),
+ );
+ }
+
+ private decodeProbe(
+ response: Response,
+ decode: (value: unknown) => A,
+ target: string,
+ ): Effect.Effect {
+ if (!response.ok) {
+ return Effect.fail(serviceUnavailable(`${target} returned HTTP ${response.status}`));
+ }
+ return Effect.tryPromise({
+ try: async () => decode(await response.json()),
+ catch: () => serviceUnavailable(`${target} returned an invalid document`),
+ });
+ }
+
+ public probe(): Effect.Effect {
+ return Effect.all(
+ [
+ this.request(`${this.config.apiUrl}/version`, "GET").pipe(
+ Effect.flatMap((response) =>
+ this.decodeProbe(
+ response,
+ Schema.decodeUnknownSync(KubernetesVersionSchema),
+ "Kubernetes version endpoint",
+ ),
+ ),
+ ),
+ this.request(`${this.config.apiUrl}/apis/ray.io/v1`, "GET").pipe(
+ Effect.flatMap((response) =>
+ this.decodeProbe(
+ response,
+ Schema.decodeUnknownSync(KubernetesApiResourceListSchema),
+ "RayJob API",
+ ),
+ ),
+ ),
+ ],
+ { concurrency: 2 },
+ ).pipe(
+ Effect.flatMap(([kubernetes, ray]) => {
+ const rayJobs = ray.resources.find((resource) => resource.name === "rayjobs");
+ if (!rayJobs || !["get", "patch"].every((verb) => rayJobs.verbs.includes(verb))) {
+ return Effect.fail(
+ serviceUnavailable("RayJob API does not advertise required get and patch operations"),
+ );
+ }
+ return Effect.succeed({
+ kubernetesVersion: kubernetes.gitVersion,
+ rayApiVersion: ray.groupVersion,
+ });
+ }),
+ );
+ }
+
+ public apply(resource: ScientificRayJobResource): Effect.Effect {
+ const query = new URLSearchParams({ fieldManager: "local-studio-workbench" });
+ const url = `${this.config.apiUrl}${resourcePath(resource)}?${query.toString()}`;
+ return this.request(url, "PATCH", JSON.stringify(resource)).pipe(
+ Effect.flatMap((response) => this.decode(response)),
+ );
+ }
+
+ public get(resource: ScientificRayJobResource): Effect.Effect {
+ return this.request(`${this.config.apiUrl}${resourcePath(resource)}`, "GET").pipe(
+ Effect.flatMap((response) => this.decode(response)),
+ );
+ }
+
+ public submit(
+ record: ScientificRayJobRecord,
+ now: string,
+ ): Effect.Effect {
+ if (record.state !== "queued") {
+ return Effect.fail(serviceUnavailable(`RayJob cannot submit from ${record.state}`));
+ }
+ return this.apply(record.resource).pipe(
+ Effect.map((observed) => {
+ const cluster = clusterStatus(observed);
+ return {
+ ...record,
+ state: gatewayState(cluster.job_status, cluster.deployment_status),
+ submitted_at: record.submitted_at ?? now,
+ reconciled_at: now,
+ cluster,
+ };
+ }),
+ );
+ }
+
+ public reconcile(
+ record: ScientificRayJobRecord,
+ now: string,
+ ): Effect.Effect {
+ if (!["submitted", "running", "suspended"].includes(record.state)) {
+ return Effect.fail(serviceUnavailable(`RayJob cannot reconcile from ${record.state}`));
+ }
+ return this.get(record.resource).pipe(
+ Effect.map((observed) => {
+ const cluster = clusterStatus(observed);
+ return {
+ ...record,
+ state: gatewayState(cluster.job_status, cluster.deployment_status),
+ reconciled_at: now,
+ cluster,
+ };
+ }),
+ );
+ }
+}
diff --git a/controller/src/modules/workbench/notebook-gateway.ts b/controller/src/modules/workbench/notebook-gateway.ts
new file mode 100644
index 000000000..f99b204ac
--- /dev/null
+++ b/controller/src/modules/workbench/notebook-gateway.ts
@@ -0,0 +1,499 @@
+import { createHash, randomUUID } from "node:crypto";
+import {
+ chmod,
+ copyFile,
+ mkdtemp,
+ readFile,
+ realpath,
+ rename,
+ rm,
+ writeFile,
+} from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { basename, dirname, join, relative, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ type NotebookApproval,
+ type NotebookCellExecute,
+ type NotebookCellPatch,
+ type NotebookCellStructure,
+ type NotebookDocument,
+ type NotebookInteractionEvent,
+} from "@local-studio/contracts/notebook-agent";
+import { Effect, Schema } from "effect";
+import { badRequest, serviceUnavailable } from "../../core/errors";
+import { NotebookGovernance } from "./notebook-governance";
+import {
+ BridgeDocumentSchema,
+ createProcessBridge,
+ type NotebookBridge,
+ type NotebookBridgeDocument,
+ type NotebookBridgeRequest,
+} from "./notebook-process-bridge";
+import {
+ runNotebookVm,
+ verifyNotebookImage,
+ withNotebookCommitLock,
+} from "./notebook-smolvm-runtime";
+
+export type { NotebookBridge } from "./notebook-process-bridge";
+
+const bridgePath = resolve(
+ dirname(fileURLToPath(import.meta.url)),
+ "../../../scripts/notebook_bridge.py",
+);
+const nodeBridgePath = resolve(
+ dirname(fileURLToPath(import.meta.url)),
+ "../../../scripts/node_notebook_bridge.mjs",
+);
+
+const revision = (content: Buffer): string =>
+ `sha256:${createHash("sha256").update(content).digest("hex")}`;
+
+const isContained = (root: string, candidate: string): boolean => {
+ const path = relative(root, candidate);
+ return path === "" || (!path.startsWith("..") && !path.startsWith("/"));
+};
+
+type SmolvmStage = {
+ scratch: string;
+ notebookFile: string;
+ requestFile: string;
+ scriptFile: string;
+};
+
+const stageSmolvm = (
+ request: Extract,
+ scratch: string,
+ script: string,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const notebookFile = join(scratch, basename(request.path));
+ const requestFile = join(scratch, "request.json");
+ const scriptFile = join(scratch, basename(script));
+ yield* Effect.all([
+ Effect.tryPromise({
+ try: () => chmod(scratch, 0o705),
+ catch: (error) => serviceUnavailable(`SmolVM scratch permission failed: ${String(error)}`),
+ }),
+ Effect.tryPromise({
+ try: async () => {
+ await copyFile(request.path, notebookFile);
+ await chmod(notebookFile, 0o606);
+ },
+ catch: (error) => serviceUnavailable(`SmolVM notebook staging failed: ${String(error)}`),
+ }),
+ Effect.tryPromise({
+ try: async () => {
+ await copyFile(script, scriptFile);
+ await chmod(scriptFile, 0o604);
+ },
+ catch: (error) => serviceUnavailable(`SmolVM bridge staging failed: ${String(error)}`),
+ }),
+ Effect.tryPromise({
+ try: async () => {
+ await writeFile(
+ requestFile,
+ JSON.stringify({
+ ...request,
+ path: `/workspace/${basename(notebookFile)}`,
+ }),
+ "utf8",
+ );
+ await chmod(requestFile, 0o604);
+ },
+ catch: (error) => serviceUnavailable(`SmolVM request staging failed: ${String(error)}`),
+ }),
+ ]);
+ return { scratch, notebookFile, requestFile, scriptFile };
+ });
+
+const smolvmArguments = (
+ image: string,
+ timeoutSeconds: number,
+ stage: SmolvmStage,
+ command: string,
+): string[] => [
+ "machine",
+ "run",
+ "--image",
+ image,
+ "--unprivileged",
+ "--cpus",
+ "1",
+ "--mem",
+ "512",
+ "--storage",
+ "2",
+ "--overlay",
+ "1",
+ "--timeout",
+ `${timeoutSeconds}s`,
+ "--volume",
+ `${stage.scratch}:/workspace`,
+ "--workdir",
+ "/workspace",
+ "--",
+ command,
+ basename(stage.scriptFile),
+ basename(stage.requestFile),
+];
+
+const commitSmolvmResult = (
+ request: Extract,
+ stage: SmolvmStage,
+ output: string,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const parsed = yield* Effect.try({
+ try: () => JSON.parse(output),
+ catch: (error) => serviceUnavailable(`SmolVM returned invalid JSON: ${String(error)}`),
+ });
+ const value = yield* Schema.decodeUnknownEffect(BridgeDocumentSchema)(parsed).pipe(
+ Effect.mapError(() => serviceUnavailable("SmolVM returned an invalid notebook document")),
+ );
+ yield* withNotebookCommitLock(
+ Effect.tryPromise({
+ try: async () => {
+ const current = await readFile(request.path);
+ if (revision(current) !== request.expected_revision) {
+ throw badRequest("Notebook changed during sandboxed execution");
+ }
+ const commitPath = `${request.path}.local-studio-${process.pid}-${randomUUID()}`;
+ try {
+ await copyFile(stage.notebookFile, commitPath);
+ await rename(commitPath, request.path);
+ } finally {
+ await rm(commitPath, { force: true });
+ }
+ },
+ catch: (error) =>
+ error instanceof Error && "status" in error
+ ? error
+ : serviceUnavailable(`Notebook result commit failed: ${String(error)}`),
+ }),
+ );
+ return value;
+ });
+
+const processSmolvmBridge =
+ (
+ smolvm: string,
+ image: string,
+ script: string,
+ command: string,
+ prefix: string,
+ ): NotebookBridge =>
+ (request) => {
+ if (request.operation !== "execute") {
+ return Effect.fail(badRequest("SmolVM notebook bridge only supports execution"));
+ }
+ return Effect.acquireUseRelease(
+ Effect.tryPromise({
+ try: () => mkdtemp(join(tmpdir(), prefix)),
+ catch: (error) => serviceUnavailable(`SmolVM scratch creation failed: ${String(error)}`),
+ }),
+ (scratch) =>
+ Effect.gen(function* () {
+ const stage = yield* stageSmolvm(request, scratch, script);
+ const output = yield* runNotebookVm(
+ smolvm,
+ smolvmArguments(image, request.timeout_seconds, stage, command),
+ request.timeout_seconds,
+ );
+ return yield* commitSmolvmResult(request, stage, output);
+ }),
+ (scratch) =>
+ Effect.tryPromise({
+ try: () => rm(scratch, { recursive: true, force: true }),
+ catch: () => undefined,
+ }).pipe(Effect.ignore),
+ );
+ };
+
+export class NotebookGateway {
+ private readonly bridge: NotebookBridge;
+ private readonly nodeBridge: NotebookBridge;
+ private readonly pythonBridge: NotebookBridge;
+ private readonly governance: NotebookGovernance;
+
+ public constructor(
+ private readonly root: string,
+ python = "python3",
+ bridge?: NotebookBridge,
+ smolvm = "smolvm",
+ nodeImage = "node-notebook-image.tar",
+ nodeBridge?: NotebookBridge,
+ pythonImage = "python-notebook-image.tar",
+ pythonBridge?: NotebookBridge,
+ ) {
+ this.bridge = bridge ?? createProcessBridge(python, bridgePath, "Jupyter");
+ this.nodeBridge =
+ nodeBridge ??
+ ((request): Effect.Effect =>
+ verifyNotebookImage(nodeImage, "Node", false).pipe(
+ Effect.flatMap((verified) =>
+ processSmolvmBridge(
+ smolvm,
+ verified,
+ nodeBridgePath,
+ "node",
+ "local-studio-node-notebook-",
+ )(request),
+ ),
+ ));
+ this.pythonBridge =
+ pythonBridge ??
+ ((request): Effect.Effect =>
+ verifyNotebookImage(pythonImage, "Python", true).pipe(
+ Effect.flatMap((verified) =>
+ processSmolvmBridge(
+ smolvm,
+ verified,
+ bridgePath,
+ "python3",
+ "local-studio-python-notebook-",
+ )(request),
+ ),
+ ));
+ this.governance = new NotebookGovernance(root);
+ }
+
+ public issueApproval(input: Omit): NotebookApproval {
+ return this.governance.issueApproval(input);
+ }
+
+ private consumeApproval(
+ approvalId: string,
+ expected: Omit,
+ ): Effect.Effect {
+ return this.governance.consumeApproval(approvalId, expected);
+ }
+
+ private recordEvent(
+ event: Omit,
+ ): Effect.Effect {
+ return this.governance.recordEvent(event);
+ }
+
+ public listEvents(notebookId: string): Effect.Effect {
+ return this.governance.listEvents(notebookId);
+ }
+
+ private resolvePath(path: string): Effect.Effect {
+ const requested = path.trim();
+ if (!requested || !requested.endsWith(".ipynb")) {
+ return Effect.fail(badRequest("Notebook path must identify an .ipynb document"));
+ }
+ const governedRoot = this.root;
+ return Effect.all([
+ Effect.tryPromise(() => realpath(governedRoot)),
+ Effect.tryPromise(() => realpath(resolve(governedRoot, requested))),
+ ]).pipe(
+ Effect.flatMap(([root, candidate]) =>
+ isContained(root, candidate)
+ ? Effect.succeed(candidate)
+ : Effect.fail(badRequest("Notebook path leaves the governed root")),
+ ),
+ Effect.mapError((error) =>
+ error instanceof Error && "status" in error
+ ? error
+ : badRequest("Notebook document was not found"),
+ ),
+ );
+ }
+
+ private document(
+ path: string,
+ value: NotebookBridgeDocument,
+ ): Effect.Effect {
+ return Effect.tryPromise({
+ try: async () => ({
+ path: relative(await realpath(this.root), path),
+ revision: revision(await readFile(path)),
+ runtime: value.kernel_name === "nodejs" ? "node" : "python",
+ kernel_name: value.kernel_name,
+ cells: value.cells,
+ }),
+ catch: (error) => serviceUnavailable(`Notebook revision failed: ${String(error)}`),
+ });
+ }
+
+ private verifyRevision(path: string, expected: string): Effect.Effect {
+ return Effect.tryPromise({
+ try: async () => revision(await readFile(path)),
+ catch: (error) => serviceUnavailable(`Notebook revision failed: ${String(error)}`),
+ }).pipe(
+ Effect.flatMap((current) =>
+ current === expected
+ ? Effect.void
+ : Effect.fail(badRequest("Notebook changed after the agent inspected it")),
+ ),
+ );
+ }
+
+ public inspect(
+ notebookPath: string,
+ identity?: { notebook_id: string; project_id: string; actor_id: string },
+ ): Effect.Effect {
+ return this.resolvePath(notebookPath).pipe(
+ Effect.flatMap((path) =>
+ this.bridge({ operation: "inspect", path }).pipe(
+ Effect.flatMap((value) => this.document(path, value)),
+ Effect.tap((document) =>
+ identity
+ ? this.recordEvent({
+ ...identity,
+ operation: "inspect",
+ revision_before: document.revision,
+ revision_after: document.revision,
+ cell_index: null,
+ approval_id: null,
+ })
+ : Effect.void,
+ ),
+ ),
+ ),
+ );
+ }
+
+ public patch(
+ notebookPath: string,
+ request: NotebookCellPatch,
+ identity: { notebook_id: string; project_id: string; actor_id: string },
+ ): Effect.Effect {
+ return this.resolvePath(notebookPath).pipe(
+ Effect.tap((path) => this.verifyRevision(path, request.expected_revision)),
+ Effect.tap(() =>
+ this.consumeApproval(request.approval_id, {
+ ...identity,
+ expected_revision: request.expected_revision,
+ operation: "patch",
+ cell_index: request.cell_index,
+ }),
+ ),
+ Effect.flatMap((path) =>
+ this.bridge({
+ operation: "patch",
+ path,
+ cell_index: request.cell_index,
+ source: request.source,
+ }).pipe(
+ Effect.flatMap((value) => this.document(path, value)),
+ Effect.tap((document) =>
+ this.recordEvent({
+ ...identity,
+ operation: "patch",
+ revision_before: request.expected_revision,
+ revision_after: document.revision,
+ cell_index: request.cell_index,
+ approval_id: request.approval_id,
+ }),
+ ),
+ ),
+ ),
+ );
+ }
+
+ public structure(
+ notebookPath: string,
+ request: NotebookCellStructure,
+ identity: { notebook_id: string; project_id: string; actor_id: string },
+ ): Effect.Effect {
+ if (request.operation === "insert" && !request.cell_type) {
+ return Effect.fail(badRequest("cell_type is required to insert a notebook cell"));
+ }
+ if (request.operation === "move" && !request.direction) {
+ return Effect.fail(badRequest("direction is required to move a notebook cell"));
+ }
+ return this.resolvePath(notebookPath).pipe(
+ Effect.tap((path) => this.verifyRevision(path, request.expected_revision)),
+ Effect.tap(() =>
+ this.consumeApproval(request.approval_id, {
+ ...identity,
+ expected_revision: request.expected_revision,
+ operation: "structure",
+ cell_index: request.cell_index,
+ }),
+ ),
+ Effect.flatMap((path) =>
+ this.bridge({
+ operation: "structure",
+ path,
+ cell_index: request.cell_index,
+ action: request.operation,
+ ...(request.cell_type ? { cell_type: request.cell_type } : {}),
+ ...(request.direction ? { direction: request.direction } : {}),
+ }).pipe(
+ Effect.flatMap((value) => this.document(path, value)),
+ Effect.tap((document) =>
+ this.recordEvent({
+ ...identity,
+ operation: "structure",
+ revision_before: request.expected_revision,
+ revision_after: document.revision,
+ cell_index: request.cell_index,
+ approval_id: request.approval_id,
+ }),
+ ),
+ ),
+ ),
+ );
+ }
+
+ public execute(
+ notebookPath: string,
+ request: NotebookCellExecute,
+ identity: { notebook_id: string; project_id: string; actor_id: string },
+ ): Effect.Effect {
+ const timeout = request.timeout_seconds ?? 60;
+ if (timeout < 1 || timeout > 120) {
+ return Effect.fail(
+ badRequest("Notebook execution timeout must be between 1 and 120 seconds"),
+ );
+ }
+ return this.resolvePath(notebookPath).pipe(
+ Effect.tap((path) => this.verifyRevision(path, request.expected_revision)),
+ Effect.tap(() =>
+ this.consumeApproval(request.approval_id, {
+ ...identity,
+ expected_revision: request.expected_revision,
+ operation: "execute",
+ cell_index: request.cell_index,
+ }),
+ ),
+ Effect.flatMap((path) =>
+ this.bridge({ operation: "inspect", path }).pipe(
+ Effect.flatMap((current) =>
+ current.kernel_name === "nodejs"
+ ? this.nodeBridge({
+ operation: "execute",
+ path,
+ cell_index: request.cell_index,
+ timeout_seconds: timeout,
+ expected_revision: request.expected_revision,
+ })
+ : this.pythonBridge({
+ operation: "execute",
+ path,
+ cell_index: request.cell_index,
+ timeout_seconds: timeout,
+ expected_revision: request.expected_revision,
+ }),
+ ),
+ Effect.flatMap((value) => this.document(path, value)),
+ Effect.tap((document) =>
+ this.recordEvent({
+ ...identity,
+ operation: "execute",
+ revision_before: request.expected_revision,
+ revision_after: document.revision,
+ cell_index: request.cell_index,
+ approval_id: request.approval_id,
+ }),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/controller/src/modules/workbench/notebook-governance.ts b/controller/src/modules/workbench/notebook-governance.ts
new file mode 100644
index 000000000..733ca7ef6
--- /dev/null
+++ b/controller/src/modules/workbench/notebook-governance.ts
@@ -0,0 +1,96 @@
+import { randomUUID } from "node:crypto";
+import { appendFile, readFile } from "node:fs/promises";
+import { join } from "node:path";
+import type {
+ NotebookApproval,
+ NotebookInteractionEvent,
+} from "@local-studio/contracts/notebook-agent";
+import { Effect } from "effect";
+import { badRequest, serviceUnavailable } from "../../core/errors";
+
+export class NotebookGovernance {
+ private readonly approvals = new Map();
+ private readonly eventFile: string;
+
+ public constructor(
+ root: string,
+ private readonly now: () => number = Date.now,
+ ) {
+ this.eventFile = join(root, "notebook-interactions.jsonl");
+ }
+
+ public issueApproval(input: Omit): NotebookApproval {
+ const approval = {
+ ...input,
+ id: randomUUID(),
+ expires_at: new Date(this.now() + 5 * 60_000).toISOString(),
+ };
+ this.approvals.set(approval.id, approval);
+ return approval;
+ }
+
+ public consumeApproval(
+ approvalId: string,
+ expected: Omit,
+ ): Effect.Effect {
+ const approval = this.approvals.get(approvalId);
+ this.approvals.delete(approvalId);
+ if (
+ !approval ||
+ Date.parse(approval.expires_at) <= this.now() ||
+ approval.actor_id !== expected.actor_id ||
+ approval.project_id !== expected.project_id ||
+ approval.notebook_id !== expected.notebook_id ||
+ approval.expected_revision !== expected.expected_revision ||
+ approval.operation !== expected.operation ||
+ approval.cell_index !== expected.cell_index
+ ) {
+ return Effect.fail(
+ badRequest("Notebook approval is missing, expired, used, or out of scope"),
+ );
+ }
+ return Effect.void;
+ }
+
+ public recordEvent(
+ event: Omit,
+ ): Effect.Effect {
+ const value = {
+ ...event,
+ id: randomUUID(),
+ occurred_at: new Date(this.now()).toISOString(),
+ };
+ return Effect.tryPromise({
+ try: () =>
+ appendFile(this.eventFile, `${JSON.stringify(value)}\n`, {
+ encoding: "utf8",
+ mode: 0o600,
+ }),
+ catch: (error) =>
+ serviceUnavailable(`Notebook evidence persistence failed: ${String(error)}`),
+ });
+ }
+
+ public listEvents(notebookId: string): Effect.Effect {
+ return Effect.tryPromise(() => readFile(this.eventFile, "utf8")).pipe(
+ Effect.catchIf(
+ (error) => {
+ const cause = error.cause;
+ return cause instanceof Error && "code" in cause && cause.code === "ENOENT";
+ },
+ () => Effect.succeed(""),
+ ),
+ Effect.map((content) =>
+ content
+ .split("\n")
+ .filter(Boolean)
+ .map((line) => JSON.parse(line) as NotebookInteractionEvent)
+ .filter((event) => event.notebook_id === notebookId)
+ .slice(-500),
+ ),
+ Effect.mapError((error) =>
+ serviceUnavailable(`Notebook evidence read failed: ${String(error)}`),
+ ),
+ );
+ }
+}
diff --git a/controller/src/modules/workbench/notebook-process-bridge.ts b/controller/src/modules/workbench/notebook-process-bridge.ts
new file mode 100644
index 000000000..f8244e3d4
--- /dev/null
+++ b/controller/src/modules/workbench/notebook-process-bridge.ts
@@ -0,0 +1,88 @@
+import { spawn } from "node:child_process";
+import { NotebookDocumentSchema } from "@local-studio/contracts/notebook-agent";
+import { Effect, Schema } from "effect";
+import { serviceUnavailable } from "../../core/errors";
+
+export const BridgeDocumentSchema = Schema.Struct({
+ kernel_name: Schema.String,
+ cells: NotebookDocumentSchema.fields.cells,
+});
+
+export type NotebookBridgeDocument = Schema.Schema.Type;
+
+export type NotebookBridgeRequest =
+ | { operation: "inspect"; path: string }
+ | { operation: "patch"; path: string; cell_index: number; source: string }
+ | {
+ operation: "structure";
+ path: string;
+ cell_index: number;
+ action: "insert" | "delete" | "move";
+ cell_type?: "code" | "markdown" | "raw";
+ direction?: "up" | "down";
+ }
+ | {
+ operation: "execute";
+ path: string;
+ cell_index: number;
+ timeout_seconds: number;
+ expected_revision: string;
+ };
+
+export type NotebookBridge = (
+ request: NotebookBridgeRequest,
+) => Effect.Effect;
+
+export const createProcessBridge =
+ (executable: string, script: string, runtime: string): NotebookBridge =>
+ (request) =>
+ Effect.callback((resume, signal) => {
+ const child = spawn(executable, [script], { stdio: ["pipe", "pipe", "pipe"] });
+ const output: Buffer[] = [];
+ const errors: Buffer[] = [];
+ let settled = false;
+ const finish = (effect: Effect.Effect): void => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ resume(effect);
+ };
+ const timer = setTimeout(() => {
+ child.kill("SIGKILL");
+ finish(Effect.fail(serviceUnavailable(`${runtime} operation timed out`)));
+ }, 130_000);
+ child.stdout.on("data", (chunk: Buffer) => output.push(chunk));
+ child.stderr.on("data", (chunk: Buffer) => errors.push(chunk));
+ child.on("error", (error) =>
+ finish(Effect.fail(serviceUnavailable(`${runtime} operation failed: ${String(error)}`))),
+ );
+ child.on("close", (code) => {
+ if (code !== 0) {
+ finish(
+ Effect.fail(
+ serviceUnavailable(
+ `${runtime} operation failed: ${Buffer.concat(errors).toString("utf8").slice(-2000)}`,
+ ),
+ ),
+ );
+ return;
+ }
+ try {
+ finish(Effect.succeed(JSON.parse(Buffer.concat(output).toString("utf8"))));
+ } catch (error) {
+ finish(Effect.fail(serviceUnavailable(`${runtime} operation failed: ${String(error)}`)));
+ }
+ });
+ child.stdin.end(JSON.stringify(request));
+ signal.addEventListener("abort", () => {
+ clearTimeout(timer);
+ child.kill("SIGTERM");
+ });
+ }).pipe(
+ Effect.flatMap(Schema.decodeUnknownEffect(BridgeDocumentSchema)),
+ Effect.mapError((error) =>
+ "status" in Object(error)
+ ? error
+ : serviceUnavailable(`${runtime} returned an invalid notebook document`),
+ ),
+ );
diff --git a/controller/src/modules/workbench/notebook-smolvm-runtime.ts b/controller/src/modules/workbench/notebook-smolvm-runtime.ts
new file mode 100644
index 000000000..35b58027c
--- /dev/null
+++ b/controller/src/modules/workbench/notebook-smolvm-runtime.ts
@@ -0,0 +1,107 @@
+import { createHash } from "node:crypto";
+import { spawn } from "node:child_process";
+import { readFile } from "node:fs/promises";
+import { Effect, Semaphore } from "effect";
+import { serviceUnavailable } from "../../core/errors";
+
+const notebookCommitLock = Semaphore.makeUnsafe(1);
+
+export const withNotebookCommitLock = (
+ operation: Effect.Effect,
+): Effect.Effect => notebookCommitLock.withPermit(operation);
+
+export const verifyNotebookImage = (
+ value: string,
+ runtime: "Node" | "Python",
+ localOnly: boolean,
+): Effect.Effect => {
+ const match = /^(.*)@sha256:([a-f0-9]{64})$/u.exec(value);
+ if (!match?.[1] || !match[2]) {
+ return Effect.fail(
+ serviceUnavailable(`${runtime} notebook image must be pinned by sha256 digest`),
+ );
+ }
+ const imagePath = match[1];
+ if (!imagePath.endsWith(".tar")) {
+ return localOnly
+ ? Effect.fail(serviceUnavailable(`${runtime} notebook image must be a local tar archive`))
+ : Effect.succeed(value);
+ }
+ return Effect.tryPromise({
+ try: () => readFile(imagePath),
+ catch: (error) =>
+ serviceUnavailable(`${runtime} notebook image verification failed: ${String(error)}`),
+ }).pipe(
+ Effect.flatMap((content) =>
+ createHash("sha256").update(content).digest("hex") === match[2]
+ ? Effect.succeed(imagePath)
+ : Effect.fail(serviceUnavailable(`${runtime} notebook image digest does not match`)),
+ ),
+ );
+};
+
+export const runNotebookVm = (
+ executable: string,
+ args: string[],
+ timeoutSeconds: number,
+): Effect.Effect =>
+ Effect.callback((resume, signal) => {
+ const child = spawn(executable, args, { stdio: ["ignore", "pipe", "pipe"] });
+ const chunks: Buffer[] = [];
+ const errors: Buffer[] = [];
+ let settled = false;
+ let outputBytes = 0;
+ let terminationFailure: ReturnType | undefined;
+ const abort = (): void => {
+ child.kill("SIGTERM");
+ };
+ const finish = (effect: Effect.Effect): void => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ signal.removeEventListener("abort", abort);
+ resume(effect);
+ };
+ const capture =
+ (target: Buffer[]) =>
+ (chunk: Buffer): void => {
+ outputBytes += chunk.byteLength;
+ if (outputBytes > 1_048_576) {
+ if (terminationFailure) return;
+ terminationFailure = serviceUnavailable("SmolVM notebook output exceeded 1 MiB");
+ child.kill("SIGKILL");
+ return;
+ }
+ target.push(chunk);
+ };
+ const timer = setTimeout(
+ () => {
+ terminationFailure = serviceUnavailable("SmolVM notebook operation timed out");
+ child.kill("SIGKILL");
+ },
+ (timeoutSeconds + 5) * 1000,
+ );
+ child.stdout.on("data", capture(chunks));
+ child.stderr.on("data", capture(errors));
+ child.on("error", (error) =>
+ finish(Effect.fail(serviceUnavailable(`SmolVM notebook failed: ${String(error)}`))),
+ );
+ child.on("close", (code) => {
+ if (terminationFailure) {
+ finish(Effect.fail(terminationFailure));
+ return;
+ }
+ if (code === 0) {
+ finish(Effect.succeed(Buffer.concat(chunks).toString("utf8")));
+ return;
+ }
+ finish(
+ Effect.fail(
+ serviceUnavailable(
+ `SmolVM notebook failed: ${Buffer.concat(errors).toString("utf8").slice(-2000)}`,
+ ),
+ ),
+ );
+ });
+ signal.addEventListener("abort", abort, { once: true });
+ });
diff --git a/controller/src/modules/workbench/receipt-foundry-evidence.ts b/controller/src/modules/workbench/receipt-foundry-evidence.ts
new file mode 100644
index 000000000..08855e025
--- /dev/null
+++ b/controller/src/modules/workbench/receipt-foundry-evidence.ts
@@ -0,0 +1,97 @@
+import type { NormalizedPrincipal } from "@local-studio/contracts/enterprise-auth";
+import type { ScientificExperimentReceipt } from "@local-studio/contracts/scientific-workbench";
+import { badRequest } from "../../core/errors";
+import { scientificPrincipalScope } from "./enterprise-identity";
+import type {
+ ScientificFoundryInvocationEvidence,
+ ScientificRayJobRecord,
+} from "./types";
+
+const assertFoundryEvidence = (
+ job: ScientificRayJobRecord,
+ receiptPrincipal: ScientificExperimentReceipt["principal"],
+ evidence: readonly ScientificFoundryInvocationEvidence[],
+): void => {
+ if (evidence.some(({ submission_id }) => submission_id !== job.submission.id)) {
+ throw badRequest("Foundry evidence does not match the RayJob submission");
+ }
+ if (
+ evidence.some(
+ ({ correlation_id, provider_id, resource_id }) =>
+ !/^[A-Za-z0-9._:-]{1,256}$/u.test(correlation_id) ||
+ !provider_id.trim() ||
+ !resource_id.trim(),
+ )
+ ) {
+ throw badRequest("Foundry evidence identity is invalid");
+ }
+ if (
+ receiptPrincipal &&
+ evidence.some(
+ ({ principal }) =>
+ principal.issuer.replace(/\/+$/u, "") !== receiptPrincipal.issuer?.replace(/\/+$/u, "") ||
+ principal.issuer_id !== receiptPrincipal.issuer_id ||
+ principal.tenant !== receiptPrincipal.tenant,
+ )
+ ) {
+ throw badRequest("Foundry evidence leaves the experiment authority domain");
+ }
+};
+
+const receiptAgents = (
+ job: ScientificRayJobRecord,
+ invocations: NonNullable,
+): NonNullable => {
+ const clusterAgents = (job.cluster?.agent_ids ?? []).map((qualifiedId) => {
+ const separator = qualifiedId.indexOf("/");
+ return separator > 0
+ ? {
+ provider_id: qualifiedId.slice(0, separator),
+ agent_id: qualifiedId.slice(separator + 1),
+ }
+ : { agent_id: qualifiedId };
+ });
+ const invokedAgents = invocations
+ .filter(({ kind }) => kind === "agent")
+ .map(({ provider_id, resource_id }) => ({ provider_id, agent_id: resource_id }));
+ return [
+ ...new Map(
+ [...clusterAgents, ...invokedAgents].map((agent) => [
+ `${agent.provider_id ?? ""}/${agent.agent_id}`,
+ agent,
+ ]),
+ ).values(),
+ ];
+};
+
+export const assembleFoundryReceiptEvidence = (
+ job: ScientificRayJobRecord,
+ principal: NormalizedPrincipal | undefined,
+ evidence: readonly ScientificFoundryInvocationEvidence[],
+): {
+ receiptPrincipal: ScientificExperimentReceipt["principal"];
+ invocations: NonNullable;
+ agents: NonNullable;
+ correlationIds: string[];
+} => {
+ const receiptPrincipal =
+ job.admission_principal ?? (principal ? scientificPrincipalScope(principal) : undefined);
+ assertFoundryEvidence(job, receiptPrincipal, evidence);
+ const invocations = evidence.map(
+ ({ kind, provider_id, resource_id, correlation_id, principal: invocationPrincipal }) => ({
+ kind,
+ provider_id,
+ resource_id,
+ correlation_id,
+ principal: invocationPrincipal,
+ }),
+ );
+ const agents = receiptAgents(job, invocations);
+ const correlationIds = [
+ ...new Set([
+ ...(job.cluster?.apim_correlation_ids ?? []),
+ ...invocations.map(({ correlation_id }) => correlation_id),
+ ]),
+ ];
+ return { receiptPrincipal, invocations, agents, correlationIds };
+};
diff --git a/controller/src/modules/workbench/reconciler.ts b/controller/src/modules/workbench/reconciler.ts
new file mode 100644
index 000000000..2a70a4681
--- /dev/null
+++ b/controller/src/modules/workbench/reconciler.ts
@@ -0,0 +1,113 @@
+import { Effect, Schedule } from "effect";
+import type { AppContext } from "../../app-context";
+import type { ScientificRayJobRecord } from "./types";
+
+const RECONCILE_INTERVAL_MS = 10_000;
+const RECONCILE_RETRY_BASE_MS = 1_000;
+const RECONCILE_RETRY_MAX = 3;
+const RECONCILE_CONCURRENCY = 4;
+
+const RECONCILABLE_STATES: readonly ScientificRayJobRecord["state"][] = [
+ "submitted",
+ "running",
+ "suspended",
+];
+
+export type ReconcileOptions = {
+ retryBaseMs?: number;
+ retryMax?: number;
+};
+
+const isReconcilable = (job: ScientificRayJobRecord): boolean =>
+ RECONCILABLE_STATES.includes(job.state);
+
+const isTerminal = (state: ScientificRayJobRecord["state"]): boolean =>
+ state === "succeeded" || state === "failed";
+
+const reconcileJob = (
+ context: AppContext,
+ job: ScientificRayJobRecord,
+ options: Required,
+): Effect.Effect => {
+ const gateway = context.kubeRayGateway;
+ if (!gateway) return Effect.fail(new Error("KubeRay gateway is unavailable"));
+ return Effect.suspend(() => gateway.reconcile(job, new Date().toISOString())).pipe(
+ Effect.retry(
+ Schedule.exponential(options.retryBaseMs).pipe(
+ Schedule.take(options.retryMax),
+ ),
+ ),
+ );
+};
+
+const resolveOptions = (options?: ReconcileOptions): Required => ({
+ retryBaseMs: options?.retryBaseMs ?? RECONCILE_RETRY_BASE_MS,
+ retryMax: options?.retryMax ?? RECONCILE_RETRY_MAX,
+});
+
+export const reconcilePass = (
+ context: AppContext,
+ options?: ReconcileOptions,
+): Effect.Effect => {
+ const resolved = resolveOptions(options);
+ return context.stores.scientificWorkbenchStore
+ .listRayJobs()
+ .pipe(
+ Effect.map((jobs) => jobs.filter(isReconcilable)),
+ Effect.flatMap((jobs) => {
+ if (jobs.length === 0) return Effect.void;
+ return Effect.forEach(
+ jobs,
+ (job) =>
+ reconcileJob(context, job, resolved).pipe(
+ Effect.tap((updated) => {
+ if (isTerminal(updated.state) && updated.state !== job.state) {
+ return Effect.sync(() =>
+ context.logger.info("Workbench RayJob reached terminal state", {
+ job_id: updated.id,
+ state: updated.state,
+ }),
+ );
+ }
+ return Effect.void;
+ }),
+ Effect.flatMap((updated) =>
+ context.stores.scientificWorkbenchStore.saveRayJob(
+ updated.submission,
+ updated,
+ ),
+ ),
+ Effect.catch((error: unknown) =>
+ Effect.sync(() =>
+ context.logger.warn("Workbench reconcile failed for RayJob", {
+ job_id: job.id,
+ error: String(error),
+ }),
+ ),
+ ),
+ Effect.asVoid,
+ ),
+ { concurrency: RECONCILE_CONCURRENCY },
+ );
+ }),
+ Effect.asVoid,
+ );
+};
+
+export const startWorkbenchReconciler = (
+ context: AppContext,
+): Effect.Effect =>
+ Effect.suspend(() => {
+ if (!context.kubeRayGateway) return Effect.never;
+ return reconcilePass(context).pipe(
+ Effect.catchCause((cause) =>
+ Effect.sync(() =>
+ context.logger.error("Workbench reconcile pass failed", {
+ error: String(cause),
+ }),
+ ),
+ ),
+ Effect.repeat(Schedule.spaced(RECONCILE_INTERVAL_MS)),
+ Effect.andThen(Effect.never),
+ );
+ });
diff --git a/controller/src/modules/workbench/route-input.ts b/controller/src/modules/workbench/route-input.ts
new file mode 100644
index 000000000..2961d8dcd
--- /dev/null
+++ b/controller/src/modules/workbench/route-input.ts
@@ -0,0 +1,25 @@
+import { isAbsolute } from "node:path";
+import { badRequest } from "../../core/errors";
+
+export const required = (value: string, field: string): string => {
+ const normalized = value.trim();
+ if (!normalized) throw badRequest(`${field} is required`);
+ return normalized;
+};
+
+export const projectQuery = (value: string | undefined): string | undefined => {
+ const normalized = value?.trim();
+ return normalized ? normalized : undefined;
+};
+
+export const notebookDocumentPath = (value: string): string => {
+ const normalized = required(value, "document_path");
+ if (
+ isAbsolute(normalized) ||
+ normalized.split(/[\\/]/u).includes("..") ||
+ !normalized.endsWith(".ipynb")
+ ) {
+ throw badRequest("document_path must be a relative .ipynb path without traversal");
+ }
+ return normalized;
+};
diff --git a/controller/src/modules/workbench/routes.ts b/controller/src/modules/workbench/routes.ts
new file mode 100644
index 000000000..723e1ce87
--- /dev/null
+++ b/controller/src/modules/workbench/routes.ts
@@ -0,0 +1,498 @@
+import { randomUUID } from "node:crypto";
+import {
+ ScientificComputeLeaseIssueSchema,
+ ScientificDatasetAttachmentIssueSchema,
+ ScientificExperimentReceiptFinalizeSchema,
+ ScientificNotebookCreateSchema,
+ ScientificNotebookStateUpdateSchema,
+ ScientificRayJobSubmissionSchema,
+ type ScientificNotebookSession,
+} from "@local-studio/contracts/scientific-workbench";
+import {
+ NotebookApprovalRequestSchema,
+ NotebookCellExecuteSchema,
+ NotebookCellPatchSchema,
+ NotebookCellStructureSchema,
+} from "@local-studio/contracts/notebook-agent";
+import { Effect } from "effect";
+import { badRequest, notFound, serviceUnavailable } from "../../core/errors";
+import { decodeJsonBody } from "../../core/validation";
+import { effectHandler } from "../../http/effect-handler";
+import { documentRoute, defineRoutes, mergeRoutes } from "../../http/route-registrar";
+import {
+ admitScientificRayJob,
+ createScientificExperimentReceipt,
+ createScientificRayJobRecord,
+ discoverScientificModelCatalog,
+ issueScientificComputeLease,
+ issueScientificDatasetAttachment,
+ transitionScientificNotebook,
+} from "./service";
+import type { KubeRayGateway } from "./kuberay-gateway";
+import type { ScientificRayJobRecord } from "./types";
+import {
+ bindScientificNotebookOwner,
+ canAccessScientificNotebook,
+ canAccessScientificRayJob,
+ canAccessScientificReceipt,
+ requireScientificNotebookAccess,
+ requireScientificNotebookMutationOwner,
+ requireScientificRayJobAccess,
+ requireScientificReceiptAccess,
+ requireScientificSubmissionOwner,
+ scientificActorId,
+ scientificNotebookIdentity,
+ scientificPrincipalScope,
+} from "./enterprise-identity";
+import { notebookDocumentPath, projectQuery, required } from "./route-input";
+
+export const registerScientificWorkbenchRoutes = defineRoutes((app, context) => {
+ const store = context.stores.scientificWorkbenchStore;
+ const requireRayJob = (jobId: string): Effect.Effect =>
+ store
+ .getRayJob(jobId)
+ .pipe(
+ Effect.flatMap((job) =>
+ job ? Effect.succeed(job) : Effect.fail(notFound("RayJob not found")),
+ ),
+ );
+ const requireGateway = (): Effect.Effect =>
+ context.kubeRayGateway
+ ? Effect.succeed(context.kubeRayGateway)
+ : Effect.fail(
+ serviceUnavailable(
+ "KubeRay gateway is not configured; set its API URL and workload token file",
+ ),
+ );
+ const requireNotebook = (notebookId: string): Effect.Effect =>
+ store
+ .getNotebook(notebookId)
+ .pipe(
+ Effect.flatMap((notebook) =>
+ notebook?.document_path
+ ? Effect.succeed(notebook)
+ : Effect.fail(notFound("Governed notebook document not found")),
+ ),
+ );
+ return mergeRoutes(
+ app.get(
+ "/workbench/notebooks",
+ documentRoute,
+ effectHandler((ctx) =>
+ store.listNotebooks(projectQuery(ctx.req.query("project_id"))).pipe(
+ Effect.map((notebooks) =>
+ ctx.json({
+ notebooks: notebooks.filter((notebook) =>
+ canAccessScientificNotebook(ctx.get("enterprisePrincipal"), notebook),
+ ),
+ }),
+ ),
+ ),
+ ),
+ ),
+ app.post(
+ "/workbench/notebooks",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const body = yield* decodeJsonBody(ctx, ScientificNotebookCreateSchema);
+ const now = new Date().toISOString();
+ const notebook: ScientificNotebookSession = {
+ id: randomUUID(),
+ project_id: required(body.project_id, "project_id"),
+ owner_id: required(
+ bindScientificNotebookOwner(ctx.get("enterprisePrincipal"), body.owner_id),
+ "owner_id",
+ ),
+ ...(ctx.get("enterprisePrincipal")
+ ? { owner_principal: scientificPrincipalScope(ctx.get("enterprisePrincipal")!) }
+ : {}),
+ runtime: body.runtime,
+ document_path: notebookDocumentPath(body.document_path),
+ state: "requested",
+ classification: body.classification,
+ compute_profile_id: required(body.compute_profile_id, "compute_profile_id"),
+ image_digest: required(body.image_digest, "image_digest"),
+ created_at: now,
+ updated_at: now,
+ expires_at: required(body.expires_at, "expires_at"),
+ };
+ yield* store.saveNotebook(notebook);
+ return ctx.json({ notebook }, 201);
+ }),
+ ),
+ ),
+ app.get(
+ "/workbench/notebooks/:notebookId",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const notebook = yield* store.getNotebook(ctx.req.param("notebookId") ?? "");
+ if (!notebook) return yield* Effect.fail(notFound("Notebook not found"));
+ requireScientificNotebookAccess(ctx.get("enterprisePrincipal"), notebook);
+ return ctx.json({ notebook });
+ }),
+ ),
+ ),
+ app.patch(
+ "/workbench/notebooks/:notebookId/state",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const notebookId = ctx.req.param("notebookId") ?? "";
+ const current = yield* store.getNotebook(notebookId);
+ if (!current) return yield* Effect.fail(notFound("Notebook not found"));
+ requireScientificNotebookMutationOwner(ctx.get("enterprisePrincipal"), current);
+ const body = yield* decodeJsonBody(ctx, ScientificNotebookStateUpdateSchema);
+ const notebook = yield* Effect.try({
+ try: () => transitionScientificNotebook(current, body.state, new Date().toISOString()),
+ catch: (error) => error,
+ });
+ yield* store.saveNotebook(notebook);
+ return ctx.json({ notebook });
+ }),
+ ),
+ ),
+ app.get(
+ "/workbench/ray-jobs",
+ documentRoute,
+ effectHandler((ctx) =>
+ store.listRayJobs(projectQuery(ctx.req.query("project_id"))).pipe(
+ Effect.map((jobs) =>
+ ctx.json({
+ jobs: jobs.filter((job) =>
+ canAccessScientificRayJob(ctx.get("enterprisePrincipal"), job),
+ ),
+ }),
+ ),
+ ),
+ ),
+ ),
+ app.post(
+ "/workbench/compute-leases",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const body = yield* decodeJsonBody(ctx, ScientificComputeLeaseIssueSchema);
+ const notebook = yield* store.getNotebook(body.notebook_id);
+ if (!notebook) return yield* Effect.fail(notFound("Notebook not found"));
+ requireScientificNotebookMutationOwner(ctx.get("enterprisePrincipal"), notebook);
+ const lease = yield* Effect.try({
+ try: () => issueScientificComputeLease(body, notebook, new Date().toISOString()),
+ catch: (error) => error,
+ });
+ yield* store.saveComputeLease(lease);
+ return ctx.json({ lease }, 201);
+ }),
+ ),
+ ),
+ app.post(
+ "/workbench/dataset-attachments",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const body = yield* decodeJsonBody(ctx, ScientificDatasetAttachmentIssueSchema);
+ const actorId = required(
+ scientificActorId(
+ ctx.get("enterprisePrincipal"),
+ ctx.req.header("x-local-studio-actor-id"),
+ ),
+ "actor identity",
+ );
+ const projectId = required(
+ ctx.req.header("x-local-studio-project-id") ?? "",
+ "project identity",
+ );
+ if (!actorId || projectId !== body.project_id) {
+ return yield* Effect.fail(notFound("Project not found"));
+ }
+ const attachment = yield* Effect.try({
+ try: () => issueScientificDatasetAttachment(body, new Date().toISOString()),
+ catch: (error) => error,
+ });
+ yield* store.saveDatasetAttachment(attachment);
+ return ctx.json({ attachment }, 201);
+ }),
+ ),
+ ),
+ app.post(
+ "/workbench/ray-jobs",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const submission = yield* decodeJsonBody(ctx, ScientificRayJobSubmissionSchema);
+ requireScientificSubmissionOwner(ctx.get("enterprisePrincipal"), submission.requested_by);
+ const existing = yield* store.getRayJob(submission.id);
+ if (existing) {
+ requireScientificRayJobAccess(ctx.get("enterprisePrincipal"), existing);
+ if (JSON.stringify(existing.submission) !== JSON.stringify(submission)) {
+ return yield* Effect.fail(
+ badRequest(`RayJob submission "${submission.id}" already exists`),
+ );
+ }
+ return ctx.json({ job: existing }, 200);
+ }
+ const notebook = yield* store.getNotebook(submission.notebook_id);
+ if (notebook) {
+ requireScientificNotebookMutationOwner(ctx.get("enterprisePrincipal"), notebook);
+ }
+ const computeLease = yield* store.getComputeLease(submission.compute_lease_id);
+ const attachments = yield* Effect.forEach(submission.datasets, ({ attachment_id }) =>
+ store.getDatasetAttachment(attachment_id),
+ );
+ if (attachments.some((attachment) => attachment === null)) {
+ return yield* Effect.fail(badRequest("Dataset attachment does not exist"));
+ }
+ const bearer = ctx.get("enterpriseBearerToken");
+ const modelCatalog = yield* discoverScientificModelCatalog(
+ context.config.providers,
+ fetch,
+ {
+ secretStore: context.providerSecretStore,
+ principal: ctx.get("enterprisePrincipal"),
+ ...(bearer ? { verifiedBearerToken: bearer } : {}),
+ signal: ctx.req.raw.signal,
+ },
+ );
+ yield* Effect.try({
+ try: () =>
+ admitScientificRayJob(
+ submission,
+ notebook,
+ new Set(
+ context.config.providers.filter(({ enabled }) => enabled).map(({ id }) => id),
+ ),
+ {
+ computeLease,
+ datasetAttachments: new Map(
+ attachments.map((attachment) => [attachment!.attachment_id, attachment!]),
+ ),
+ modelCatalog,
+ now: new Date().toISOString(),
+ },
+ ),
+ catch: (error) => error,
+ });
+ const record = createScientificRayJobRecord(
+ submission,
+ new Date().toISOString(),
+ ctx.get("enterprisePrincipal"),
+ );
+ yield* store.saveRayJob(submission, record);
+ return ctx.json({ job: record }, 202);
+ }),
+ ),
+ ),
+ app.post(
+ "/workbench/ray-jobs/:jobId/submit",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const gateway = yield* requireGateway();
+ const job = yield* requireRayJob(ctx.req.param("jobId") ?? "");
+ const notebook = yield* store.getNotebook(job.submission.notebook_id);
+ if (!notebook) return yield* Effect.fail(notFound("Notebook not found"));
+ requireScientificNotebookMutationOwner(ctx.get("enterprisePrincipal"), notebook);
+ const updated = yield* gateway.submit(job, new Date().toISOString());
+ yield* store.saveRayJob(updated.submission, updated);
+ return ctx.json({ job: updated }, 202);
+ }),
+ ),
+ ),
+ app.post(
+ "/workbench/ray-jobs/:jobId/reconcile",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const gateway = yield* requireGateway();
+ const job = yield* requireRayJob(ctx.req.param("jobId") ?? "");
+ const notebook = yield* store.getNotebook(job.submission.notebook_id);
+ if (!notebook) return yield* Effect.fail(notFound("Notebook not found"));
+ requireScientificNotebookMutationOwner(ctx.get("enterprisePrincipal"), notebook);
+ if (job.state === "queued") {
+ return yield* Effect.fail(badRequest("RayJob has not been submitted"));
+ }
+ const updated = yield* gateway.reconcile(job, new Date().toISOString());
+ yield* store.saveRayJob(updated.submission, updated);
+ return ctx.json({ job: updated });
+ }),
+ ),
+ ),
+ app.get(
+ "/workbench/receipts",
+ documentRoute,
+ effectHandler((ctx) =>
+ store.listReceipts(projectQuery(ctx.req.query("project_id"))).pipe(
+ Effect.map((receipts) =>
+ ctx.json({
+ receipts: receipts.filter((receipt) =>
+ canAccessScientificReceipt(ctx.get("enterprisePrincipal"), receipt),
+ ),
+ }),
+ ),
+ ),
+ ),
+ ),
+ app.get(
+ "/workbench/receipts/:receiptId",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const receipt = yield* store.getReceipt(ctx.req.param("receiptId") ?? "");
+ if (!receipt) return yield* Effect.fail(notFound("Experiment receipt not found"));
+ requireScientificReceiptAccess(ctx.get("enterprisePrincipal"), receipt);
+ return ctx.json({ receipt });
+ }),
+ ),
+ ),
+ app.post(
+ "/workbench/ray-jobs/:jobId/receipt",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const job = yield* requireRayJob(ctx.req.param("jobId") ?? "");
+ requireScientificRayJobAccess(ctx.get("enterprisePrincipal"), job);
+ const existing = yield* store.getReceiptBySubmission(job.submission.id);
+ if (existing) {
+ requireScientificReceiptAccess(ctx.get("enterprisePrincipal"), existing);
+ return ctx.json({ receipt: existing });
+ }
+ const notebook = yield* store.getNotebook(job.submission.notebook_id);
+ if (!notebook) {
+ return yield* Effect.fail(notFound("Notebook not found"));
+ }
+ requireScientificNotebookMutationOwner(ctx.get("enterprisePrincipal"), notebook);
+ if (!notebook.document_path) {
+ return yield* Effect.fail(notFound("Governed notebook document not found"));
+ }
+ const document = yield* context.notebookGateway.inspect(
+ notebook.document_path,
+ scientificNotebookIdentity(notebook),
+ );
+ const interactions = yield* context.notebookGateway.listEvents(notebook.id);
+ const foundryEvidence = yield* store.listFoundryInvocationEvidence(job.submission.id);
+ const body = yield* decodeJsonBody(ctx, ScientificExperimentReceiptFinalizeSchema);
+ const receipt = yield* Effect.try({
+ try: () =>
+ createScientificExperimentReceipt(
+ job,
+ notebook,
+ document.revision,
+ interactions,
+ body,
+ context.config.scientific_receipt_signing_key ?? "",
+ ctx.get("enterprisePrincipal"),
+ foundryEvidence,
+ ),
+ catch: (error) => error,
+ });
+ yield* store.saveReceipt(job.submission.project_id, receipt);
+ return ctx.json({ receipt }, 201);
+ }),
+ ),
+ ),
+ app.get(
+ "/workbench/notebooks/:notebookId/document",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const session = yield* requireNotebook(ctx.req.param("notebookId") ?? "");
+ requireScientificNotebookAccess(ctx.get("enterprisePrincipal"), session);
+ const identity = scientificNotebookIdentity(session);
+ const notebook = yield* context.notebookGateway.inspect(session.document_path!, identity);
+ return ctx.json({ notebook });
+ }),
+ ),
+ ),
+ app.patch(
+ "/workbench/notebooks/:notebookId/document",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const session = yield* requireNotebook(ctx.req.param("notebookId") ?? "");
+ requireScientificNotebookMutationOwner(ctx.get("enterprisePrincipal"), session);
+ const identity = scientificNotebookIdentity(session);
+ const body = yield* decodeJsonBody(ctx, NotebookCellPatchSchema);
+ const notebook = yield* context.notebookGateway.patch(
+ session.document_path!,
+ body,
+ identity,
+ );
+ return ctx.json({ notebook });
+ }),
+ ),
+ ),
+ app.post(
+ "/workbench/notebooks/:notebookId/document/execute",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const session = yield* requireNotebook(ctx.req.param("notebookId") ?? "");
+ requireScientificNotebookMutationOwner(ctx.get("enterprisePrincipal"), session);
+ const identity = scientificNotebookIdentity(session);
+ const body = yield* decodeJsonBody(ctx, NotebookCellExecuteSchema);
+ const notebook = yield* context.notebookGateway.execute(
+ session.document_path!,
+ body,
+ identity,
+ );
+ return ctx.json({ notebook });
+ }),
+ ),
+ ),
+ app.post(
+ "/workbench/notebooks/:notebookId/document/structure",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const session = yield* requireNotebook(ctx.req.param("notebookId") ?? "");
+ requireScientificNotebookMutationOwner(ctx.get("enterprisePrincipal"), session);
+ const identity = scientificNotebookIdentity(session);
+ const body = yield* decodeJsonBody(ctx, NotebookCellStructureSchema);
+ const notebook = yield* context.notebookGateway.structure(
+ session.document_path!,
+ body,
+ identity,
+ );
+ return ctx.json({ notebook });
+ }),
+ ),
+ ),
+ app.post(
+ "/workbench/notebooks/:notebookId/approvals",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const session = yield* requireNotebook(ctx.req.param("notebookId") ?? "");
+ requireScientificNotebookMutationOwner(ctx.get("enterprisePrincipal"), session);
+ const identity = scientificNotebookIdentity(session);
+ const body = yield* decodeJsonBody(ctx, NotebookApprovalRequestSchema);
+ const current = yield* context.notebookGateway.inspect(session.document_path!);
+ if (current.revision !== body.expected_revision) {
+ return yield* Effect.fail(badRequest("Notebook changed before approval"));
+ }
+ const approval = context.notebookGateway.issueApproval({
+ ...identity,
+ expected_revision: body.expected_revision,
+ operation: body.operation,
+ cell_index: body.cell_index,
+ });
+ return ctx.json({ approval }, 201);
+ }),
+ ),
+ ),
+ app.get(
+ "/workbench/notebooks/:notebookId/interactions",
+ documentRoute,
+ effectHandler((ctx) =>
+ Effect.gen(function* () {
+ const session = yield* requireNotebook(ctx.req.param("notebookId") ?? "");
+ requireScientificNotebookAccess(ctx.get("enterprisePrincipal"), session);
+ scientificNotebookIdentity(session);
+ const events = yield* context.notebookGateway.listEvents(session.id);
+ return ctx.json({ events });
+ }),
+ ),
+ ),
+ );
+});
diff --git a/controller/src/modules/workbench/service.ts b/controller/src/modules/workbench/service.ts
new file mode 100644
index 000000000..450ef75d5
--- /dev/null
+++ b/controller/src/modules/workbench/service.ts
@@ -0,0 +1,472 @@
+import { createHash, createHmac, randomUUID } from "node:crypto";
+import type {
+ ScientificComputeLease,
+ ScientificComputeLeaseIssue,
+ ScientificDatasetAttachment,
+ ScientificDatasetAttachmentIssue,
+ ScientificExperimentReceipt,
+ ScientificExperimentReceiptFinalize,
+ ScientificNotebookSession,
+ ScientificRayJobSubmission,
+} from "@local-studio/contracts/scientific-workbench";
+import type { NormalizedPrincipal } from "@local-studio/contracts/enterprise-auth";
+import { validateScientificRayJobSubmission } from "@local-studio/contracts/scientific-workbench";
+import type { NotebookInteractionEvent } from "@local-studio/contracts/notebook-agent";
+import { Effect, Schema } from "effect";
+import { badRequest, serviceUnavailable } from "../../core/errors";
+import type {
+ ScientificFoundryInvocationEvidence,
+ ScientificRayJobRecord,
+ ScientificRayJobResource,
+} from "./types";
+import { providerModelsEndpoint } from "../../../../shared/agent/openai-endpoint";
+import type { ProviderConfig } from "../../config/persisted-config";
+import { scientificPrincipalScope } from "./enterprise-identity";
+import { assembleFoundryReceiptEvidence } from "./receipt-foundry-evidence";
+import {
+ resolveProviderHeaders,
+ type ProviderAuthenticationContext,
+} from "../../services/provider-authentication";
+import { assertProviderOutboundUrl } from "../../services/provider-boundary";
+
+type ScientificNotebookState = ScientificNotebookSession["state"];
+
+export type ScientificAdmissionContext = {
+ computeLease: ScientificComputeLease | null;
+ datasetAttachments: ReadonlyMap;
+ modelCatalog: ReadonlyMap>;
+ now: string;
+};
+
+type ScientificCatalogFetch = (
+ input: string | URL | Request,
+ init?: RequestInit,
+) => ReturnType;
+
+const ProviderModelsSchema = Schema.Struct({
+ data: Schema.Array(Schema.Struct({ id: Schema.String })),
+});
+
+export const discoverScientificModelCatalog = (
+ providers: readonly ProviderConfig[],
+ fetcher: ScientificCatalogFetch = fetch,
+ authenticationContext: ProviderAuthenticationContext = {},
+): Effect.Effect>, unknown> =>
+ Effect.forEach(
+ providers.filter(({ enabled }) => enabled),
+ (provider) =>
+ Effect.gen(function* () {
+ const authorization = yield* resolveProviderHeaders(provider, authenticationContext);
+ const baseUrl =
+ fetcher === fetch
+ ? yield* assertProviderOutboundUrl(provider.base_url)
+ : provider.base_url;
+ const response = yield* Effect.tryPromise({
+ try: (signal) =>
+ fetcher(providerModelsEndpoint(baseUrl, provider.path_style, provider.api_version), {
+ headers: {
+ Accept: "application/json",
+ ...authorization,
+ },
+ signal: authenticationContext.signal
+ ? AbortSignal.any([
+ authenticationContext.signal,
+ signal,
+ AbortSignal.timeout(10_000),
+ ])
+ : AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
+ redirect: "error",
+ }),
+ catch: (error) => error,
+ });
+ if (!response.ok) {
+ return yield* Effect.fail(
+ serviceUnavailable(`Model catalog for "${provider.id}" returned ${response.status}`),
+ );
+ }
+ const payload = yield* Effect.tryPromise({
+ try: () => response.json(),
+ catch: (error) => error,
+ });
+ const decoded = yield* Schema.decodeUnknownEffect(ProviderModelsSchema)(payload);
+ return [provider.id, new Set(decoded.data.map(({ id }) => id))] as const;
+ }),
+ { concurrency: 4 },
+ ).pipe(Effect.map((entries) => new Map(entries)));
+
+const NOTEBOOK_TRANSITIONS: Record = {
+ requested: ["provisioning", "failed"],
+ provisioning: ["ready", "failed"],
+ ready: ["active", "suspended", "archived", "failed"],
+ active: ["idle", "suspended", "archived", "failed"],
+ idle: ["active", "suspended", "archived", "failed"],
+ suspended: ["provisioning", "archived", "failed"],
+ archived: [],
+ failed: ["provisioning", "archived"],
+};
+
+const expiredAt = (value: string, now: string): boolean => {
+ const expiry = Date.parse(value);
+ const reference = Date.parse(now);
+ return !Number.isFinite(expiry) || !Number.isFinite(reference) || expiry <= reference;
+};
+
+export const issueScientificComputeLease = (
+ input: ScientificComputeLeaseIssue,
+ notebook: ScientificNotebookSession,
+ now: string,
+): ScientificComputeLease => {
+ if (
+ notebook.id !== input.notebook_id ||
+ notebook.project_id !== input.project_id ||
+ notebook.classification !== input.classification ||
+ notebook.compute_profile_id !== input.profile.id
+ ) {
+ throw badRequest("Compute lease request does not match the governed notebook");
+ }
+ if (!["ready", "active", "idle"].includes(notebook.state)) {
+ throw badRequest("Compute lease requires an execution-ready notebook");
+ }
+ if (expiredAt(input.expires_at, now)) {
+ throw badRequest("Compute lease expiry must be in the future");
+ }
+ return {
+ id: randomUUID(),
+ project_id: input.project_id,
+ notebook_id: input.notebook_id,
+ profile_id: input.profile.id,
+ profile: input.profile,
+ classification: input.classification,
+ state: "admitted",
+ requested_at: now,
+ expires_at: input.expires_at,
+ };
+};
+
+export const issueScientificDatasetAttachment = (
+ input: ScientificDatasetAttachmentIssue,
+ now: string,
+): ScientificDatasetAttachment => {
+ if (!input.purpose.trim()) {
+ throw badRequest("Dataset attachment purpose is required");
+ }
+ if (!/^[a-z0-9]+:[a-f0-9]{32,}$/u.test(input.digest)) {
+ throw badRequest("Dataset attachment digest must include an algorithm prefix");
+ }
+ if (expiredAt(input.lease_expires_at, now)) {
+ throw badRequest("Dataset attachment expiry must be in the future");
+ }
+ return {
+ attachment_id: randomUUID(),
+ project_id: input.project_id,
+ dataset_id: input.dataset_id,
+ version: input.version,
+ digest: input.digest,
+ classification: input.classification,
+ access: "read-only",
+ purpose: input.purpose.trim(),
+ issued_at: now,
+ lease_expires_at: input.lease_expires_at,
+ };
+};
+
+const dnsLabel = (value: string): string =>
+ value
+ .toLowerCase()
+ .replace(/[^a-z0-9-]/gu, "-")
+ .replace(/^-+|-+$/gu, "")
+ .slice(0, 63);
+
+const rayResources = (
+ submission: ScientificRayJobSubmission,
+): { requests: Record; limits: Record } => {
+ const profile = submission.compute_profile;
+ const values: Record = {
+ cpu: String(profile.cpu_cores),
+ memory: `${profile.memory_gb}Gi`,
+ };
+ if (profile.gpu_count > 0 && profile.gpu_resource) {
+ values[profile.gpu_resource] = String(profile.gpu_count);
+ }
+ return { requests: values, limits: values };
+};
+
+const rayEnvironment = (
+ submission: ScientificRayJobSubmission,
+ principal?: NormalizedPrincipal,
+): Array<{ name: string; value: string }> => [
+ { name: "LOCAL_STUDIO_CLASSIFICATION", value: submission.classification },
+ {
+ name: "LOCAL_STUDIO_DATASET_REFS",
+ value: JSON.stringify(
+ submission.datasets.map(({ dataset_id, version, digest }) => ({
+ dataset_id,
+ version,
+ digest,
+ })),
+ ),
+ },
+ {
+ name: "LOCAL_STUDIO_MODEL_REFS",
+ value: JSON.stringify(submission.models.map(({ qualified_id }) => qualified_id)),
+ },
+ ...(principal
+ ? [
+ { name: "LOCAL_STUDIO_ENTERPRISE_SUBJECT", value: principal.subject },
+ { name: "LOCAL_STUDIO_ENTERPRISE_ISSUER_ID", value: principal.issuer_id },
+ { name: "LOCAL_STUDIO_ENTERPRISE_TENANT", value: principal.tenant },
+ { name: "LOCAL_STUDIO_ENTERPRISE_CLEARANCE", value: principal.clearance },
+ ]
+ : []),
+];
+
+export const admitScientificRayJob = (
+ submission: ScientificRayJobSubmission,
+ notebook: ScientificNotebookSession | null,
+ configuredProviderIds: ReadonlySet,
+ governance?: ScientificAdmissionContext,
+): void => {
+ const violations = validateScientificRayJobSubmission(submission);
+ if (violations.length > 0) {
+ throw badRequest(violations.map(({ field, reason }) => `${field} ${reason}`).join("; "));
+ }
+ if (!notebook) throw badRequest(`Notebook "${submission.notebook_id}" does not exist`);
+ if (!["ready", "active", "idle"].includes(notebook.state)) {
+ throw badRequest(`Notebook "${submission.notebook_id}" is not ready for job submission`);
+ }
+ if (notebook.project_id !== submission.project_id) {
+ throw badRequest("Notebook and submission must belong to the same project");
+ }
+ if (notebook.classification !== submission.classification) {
+ throw badRequest("Notebook and submission classification must match");
+ }
+ if (!governance) {
+ throw badRequest("Scientific governance context is required");
+ }
+ const lease = governance.computeLease;
+ if (!lease || lease.id !== submission.compute_lease_id) {
+ throw badRequest("Controller-issued compute lease does not exist");
+ }
+ if (
+ lease.project_id !== submission.project_id ||
+ lease.notebook_id !== submission.notebook_id ||
+ lease.profile_id !== submission.compute_profile.id ||
+ JSON.stringify(lease.profile) !== JSON.stringify(submission.compute_profile) ||
+ lease.classification !== submission.classification
+ ) {
+ throw badRequest("Compute lease does not match the governed submission");
+ }
+ if (!["admitted", "provisioning", "running"].includes(lease.state)) {
+ throw badRequest(`Compute lease is not admitted: ${lease.state}`);
+ }
+ if (expiredAt(lease.expires_at, governance.now)) {
+ throw badRequest("Compute lease has expired");
+ }
+ for (const dataset of submission.datasets) {
+ const issued = governance.datasetAttachments.get(dataset.attachment_id);
+ if (!issued || JSON.stringify(issued) !== JSON.stringify(dataset)) {
+ throw badRequest(`Dataset attachment "${dataset.attachment_id}" is not controller-issued`);
+ }
+ if (
+ dataset.project_id !== submission.project_id ||
+ dataset.classification !== submission.classification ||
+ dataset.access !== "read-only" ||
+ !dataset.purpose.trim()
+ ) {
+ throw badRequest(`Dataset attachment "${dataset.attachment_id}" violates submission policy`);
+ }
+ if (expiredAt(dataset.lease_expires_at, governance.now)) {
+ throw badRequest(`Dataset attachment "${dataset.attachment_id}" has expired`);
+ }
+ }
+ for (const model of submission.models) {
+ if (!configuredProviderIds.has(model.provider_id)) {
+ throw badRequest(`Model provider "${model.provider_id}" is not configured`);
+ }
+ if (!governance.modelCatalog.get(model.provider_id)?.has(model.model_id)) {
+ throw badRequest(
+ `Model "${model.qualified_id}" is not present in the authoritative provider catalog`,
+ );
+ }
+ }
+};
+
+export const transitionScientificNotebook = (
+ notebook: ScientificNotebookSession,
+ nextState: ScientificNotebookState,
+ updatedAt: string,
+): ScientificNotebookSession => {
+ if (notebook.state === nextState) return notebook;
+ if (!NOTEBOOK_TRANSITIONS[notebook.state].includes(nextState)) {
+ throw badRequest(`Notebook cannot transition from ${notebook.state} to ${nextState}`);
+ }
+ return { ...notebook, state: nextState, updated_at: updatedAt };
+};
+
+export const generateScientificRayJobResource = (
+ submission: ScientificRayJobSubmission,
+ principal?: NormalizedPrincipal,
+): ScientificRayJobResource => {
+ const profile = submission.compute_profile;
+ const resourceName = dnsLabel(`experiment-${submission.experiment_id}`);
+ const namespace = dnsLabel(`workbench-${submission.project_id}`);
+ const resources = rayResources(submission);
+ const env = rayEnvironment(submission, principal);
+ const container = {
+ name: "ray" as const,
+ image: submission.environment_image,
+ resources,
+ env,
+ };
+ return {
+ apiVersion: "ray.io/v1",
+ kind: "RayJob",
+ metadata: {
+ name: resourceName,
+ namespace,
+ labels: {
+ "app.kubernetes.io/managed-by": "local-studio",
+ "local-studio/classification": submission.classification,
+ "local-studio/project": dnsLabel(submission.project_id),
+ },
+ annotations: {
+ "local-studio/submission-id": submission.id,
+ "local-studio/notebook-id": submission.notebook_id,
+ },
+ },
+ spec: {
+ entrypoint: submission.entrypoint,
+ shutdownAfterJobFinishes: true,
+ ttlSecondsAfterFinished: 3600,
+ rayClusterSpec: {
+ headGroupSpec: {
+ rayStartParams: { "dashboard-host": "0.0.0.0" },
+ template: {
+ spec: { automountServiceAccountToken: false, containers: [container] },
+ },
+ },
+ workerGroupSpecs: [
+ {
+ groupName: "workers",
+ replicas: profile.min_workers,
+ minReplicas: profile.min_workers,
+ maxReplicas: profile.max_workers,
+ rayStartParams: {},
+ template: {
+ spec: { automountServiceAccountToken: false, containers: [container] },
+ },
+ },
+ ],
+ },
+ },
+ };
+};
+
+export const createScientificRayJobRecord = (
+ submission: ScientificRayJobSubmission,
+ admittedAt: string,
+ principal?: NormalizedPrincipal,
+): ScientificRayJobRecord => ({
+ id: submission.id,
+ state: "queued",
+ submission,
+ ...(principal ? { admission_principal: scientificPrincipalScope(principal) } : {}),
+ resource: generateScientificRayJobResource(submission, principal),
+ admitted_at: admittedAt,
+});
+
+export const createScientificExperimentReceipt = (
+ job: ScientificRayJobRecord,
+ notebook: ScientificNotebookSession,
+ notebookRevision: string,
+ notebookInteractions: readonly NotebookInteractionEvent[],
+ _finalization: ScientificExperimentReceiptFinalize,
+ signingKey: string,
+ principal?: NormalizedPrincipal,
+ foundryEvidence: readonly ScientificFoundryInvocationEvidence[] = [],
+): ScientificExperimentReceipt => {
+ if (job.state !== "succeeded" && job.state !== "failed") {
+ throw badRequest("Experiment receipt requires a terminal RayJob");
+ }
+ if (job.submission.notebook_id !== notebook.id) {
+ throw badRequest("RayJob and notebook identity do not match");
+ }
+ const policyDecisionIds = job.cluster?.policy_decision_ids;
+ const artifactDigests = job.cluster?.artifact_digests;
+ const resourceUsage = job.cluster?.resource_usage;
+ if (!policyDecisionIds || !artifactDigests || !resourceUsage) {
+ throw badRequest("Experiment receipt requires controller-measured evidence");
+ }
+ if (policyDecisionIds.length === 0) {
+ throw badRequest("Experiment receipt requires at least one policy decision");
+ }
+ if (
+ resourceUsage.cpu_seconds < 0 ||
+ resourceUsage.gpu_seconds < 0 ||
+ resourceUsage.peak_memory_gb < 0
+ ) {
+ throw badRequest("Experiment receipt resource usage must not be negative");
+ }
+ if (artifactDigests.some((digest) => !/^[a-z0-9]+:[a-f0-9]{32,}$/u.test(digest))) {
+ throw badRequest("Experiment receipt artifact digests must include an algorithm prefix");
+ }
+ const issuedAt = job.reconciled_at;
+ if (!issuedAt) {
+ throw badRequest("Experiment receipt requires reconciliation evidence");
+ }
+ if (signingKey.length < 32) {
+ throw badRequest("Experiment receipt signing key is not configured");
+ }
+ if (!/^sha256:[a-f0-9]{64}$/u.test(notebookRevision)) {
+ throw badRequest("Experiment receipt requires a governed notebook revision");
+ }
+ if (
+ notebookInteractions.some(
+ (event) => event.notebook_id !== notebook.id || event.project_id !== notebook.project_id,
+ )
+ ) {
+ throw badRequest("Experiment receipt notebook interactions are out of scope");
+ }
+ const notebookInteractionDigest = `sha256:${createHash("sha256")
+ .update(JSON.stringify(notebookInteractions))
+ .digest("hex")}`;
+ const { receiptPrincipal, invocations, agents, correlationIds } =
+ assembleFoundryReceiptEvidence(job, principal, foundryEvidence);
+ const evidence = {
+ submission_id: job.submission.id,
+ ray_job_id:
+ job.cluster?.uid ?? `${job.resource.metadata.namespace}/${job.resource.metadata.name}`,
+ state: job.state,
+ classification: job.submission.classification,
+ notebook_digest: notebook.image_digest,
+ notebook_revision: notebookRevision,
+ notebook_interaction_digest: notebookInteractionDigest,
+ notebook_interaction_count: notebookInteractions.length,
+ environment_digest: job.submission.environment_digest,
+ datasets: job.submission.datasets,
+ models: job.submission.models,
+ artifact_digests: [...artifactDigests],
+ policy_decision_ids: [...policyDecisionIds],
+ apim_correlation_ids: correlationIds,
+ ...(receiptPrincipal ? { principal: receiptPrincipal } : {}),
+ ...(agents.length > 0 ? { agents } : {}),
+ ...(invocations.length > 0 ? { foundry_invocations: invocations } : {}),
+ approval_ids: [...job.submission.approval_ids],
+ resource_usage: resourceUsage,
+ started_at: job.cluster?.started_at ?? job.submitted_at ?? job.admitted_at,
+ completed_at: job.cluster?.ended_at ?? job.reconciled_at ?? null,
+ };
+ const receiptDigest = `sha256:${createHash("sha256")
+ .update(JSON.stringify(evidence))
+ .digest("hex")}`;
+ return {
+ id: `receipt-${job.id}`,
+ receipt_digest: receiptDigest,
+ receipt_signature: `hmac-sha256:${createHmac("sha256", signingKey)
+ .update(receiptDigest)
+ .digest("hex")}`,
+ evidence_source: "controller-reconciled",
+ ...evidence,
+ issued_at: issuedAt,
+ };
+};
diff --git a/controller/src/modules/workbench/store.ts b/controller/src/modules/workbench/store.ts
new file mode 100644
index 000000000..a77cb4ea4
--- /dev/null
+++ b/controller/src/modules/workbench/store.ts
@@ -0,0 +1,344 @@
+import type { Database } from "bun:sqlite";
+import type {
+ ScientificExperimentReceipt,
+ ScientificComputeLease,
+ ScientificDatasetAttachment,
+ ScientificNotebookSession,
+ ScientificRayJobSubmission,
+} from "@local-studio/contracts/scientific-workbench";
+import type { Effect } from "effect";
+import {
+ makeDatabaseCloser,
+ openInitializedDatabase,
+ repositoryEffect,
+ type RepositoryError,
+} from "../../stores/sqlite";
+import type { ScientificFoundryInvocationEvidence, ScientificRayJobRecord } from "./types";
+
+type DataRow = { data: string };
+
+const decodeRow = (row: DataRow | null): A | null => {
+ if (!row) return null;
+ try {
+ return JSON.parse(row.data) as A;
+ } catch {
+ return null;
+ }
+};
+
+export class ScientificWorkbenchStore {
+ private readonly db: Database;
+ private readonly closeDatabase: () => Effect.Effect;
+
+ public constructor(dbPath: string) {
+ this.db = openInitializedDatabase(dbPath, (db) => {
+ db.run(`
+ CREATE TABLE IF NOT EXISTS scientific_notebooks (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ data TEXT NOT NULL,
+ created_at TEXT NOT NULL
+ )
+ `);
+ db.run(`
+ CREATE TABLE IF NOT EXISTS scientific_compute_leases (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ notebook_id TEXT NOT NULL,
+ data TEXT NOT NULL,
+ created_at TEXT NOT NULL
+ )
+ `);
+ db.run(`
+ CREATE TABLE IF NOT EXISTS scientific_dataset_attachments (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ data TEXT NOT NULL,
+ created_at TEXT NOT NULL
+ )
+ `);
+ db.run(`
+ CREATE TABLE IF NOT EXISTS scientific_ray_jobs (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ notebook_id TEXT NOT NULL,
+ data TEXT NOT NULL,
+ created_at TEXT NOT NULL
+ )
+ `);
+ db.run(`
+ CREATE TABLE IF NOT EXISTS scientific_experiment_receipts (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ submission_id TEXT NOT NULL UNIQUE,
+ data TEXT NOT NULL,
+ created_at TEXT NOT NULL
+ )
+ `);
+ db.run(`
+ CREATE TABLE IF NOT EXISTS scientific_foundry_invocation_evidence (
+ id TEXT PRIMARY KEY,
+ submission_id TEXT NOT NULL,
+ data TEXT NOT NULL,
+ created_at TEXT NOT NULL
+ )
+ `);
+ });
+ this.closeDatabase = makeDatabaseCloser(this.db, "scientific-workbench.close");
+ }
+
+ public listNotebooks(
+ projectId?: string,
+ ): Effect.Effect {
+ return repositoryEffect("scientific-workbench.notebooks.list", () => {
+ const rows = projectId
+ ? (this.db
+ .query(
+ "SELECT data FROM scientific_notebooks WHERE project_id = ? ORDER BY created_at DESC",
+ )
+ .all(projectId) as DataRow[])
+ : (this.db
+ .query("SELECT data FROM scientific_notebooks ORDER BY created_at DESC")
+ .all() as DataRow[]);
+ return rows.flatMap((row) => {
+ const value = decodeRow(row);
+ return value ? [value] : [];
+ });
+ });
+ }
+
+ public getNotebook(
+ notebookId: string,
+ ): Effect.Effect {
+ return repositoryEffect("scientific-workbench.notebooks.get", () =>
+ decodeRow(
+ this.db
+ .query("SELECT data FROM scientific_notebooks WHERE id = ?")
+ .get(notebookId) as DataRow | null,
+ ),
+ );
+ }
+
+ public saveNotebook(notebook: ScientificNotebookSession): Effect.Effect {
+ return repositoryEffect("scientific-workbench.notebooks.save", () => {
+ this.db
+ .query(
+ `INSERT INTO scientific_notebooks (id, project_id, data, created_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET project_id = excluded.project_id, data = excluded.data`,
+ )
+ .run(notebook.id, notebook.project_id, JSON.stringify(notebook), notebook.created_at);
+ });
+ }
+
+ public getComputeLease(
+ leaseId: string,
+ ): Effect.Effect {
+ return repositoryEffect("scientific-workbench.compute-leases.get", () =>
+ decodeRow(
+ this.db
+ .query("SELECT data FROM scientific_compute_leases WHERE id = ?")
+ .get(leaseId) as DataRow | null,
+ ),
+ );
+ }
+
+ public saveComputeLease(lease: ScientificComputeLease): Effect.Effect {
+ return repositoryEffect("scientific-workbench.compute-leases.save", () => {
+ this.db
+ .query(
+ `INSERT INTO scientific_compute_leases (id, project_id, notebook_id, data, created_at)
+ VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
+ )
+ .run(
+ lease.id,
+ lease.project_id,
+ lease.notebook_id,
+ JSON.stringify(lease),
+ lease.requested_at,
+ );
+ });
+ }
+
+ public getDatasetAttachment(
+ attachmentId: string,
+ ): Effect.Effect {
+ return repositoryEffect("scientific-workbench.dataset-attachments.get", () =>
+ decodeRow(
+ this.db
+ .query("SELECT data FROM scientific_dataset_attachments WHERE id = ?")
+ .get(attachmentId) as DataRow | null,
+ ),
+ );
+ }
+
+ public saveDatasetAttachment(
+ attachment: ScientificDatasetAttachment,
+ ): Effect.Effect {
+ return repositoryEffect("scientific-workbench.dataset-attachments.save", () => {
+ this.db
+ .query(
+ `INSERT INTO scientific_dataset_attachments (id, project_id, data, created_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
+ )
+ .run(
+ attachment.attachment_id,
+ attachment.project_id,
+ JSON.stringify(attachment),
+ attachment.issued_at,
+ );
+ });
+ }
+
+ public listRayJobs(projectId?: string): Effect.Effect {
+ return repositoryEffect("scientific-workbench.ray-jobs.list", () => {
+ const rows = projectId
+ ? (this.db
+ .query(
+ "SELECT data FROM scientific_ray_jobs WHERE project_id = ? ORDER BY created_at DESC",
+ )
+ .all(projectId) as DataRow[])
+ : (this.db
+ .query("SELECT data FROM scientific_ray_jobs ORDER BY created_at DESC")
+ .all() as DataRow[]);
+ return rows.flatMap((row) => {
+ const value = decodeRow(row);
+ return value ? [value] : [];
+ });
+ });
+ }
+
+ public getRayJob(jobId: string): Effect.Effect {
+ return repositoryEffect("scientific-workbench.ray-jobs.get", () =>
+ decodeRow(
+ this.db
+ .query("SELECT data FROM scientific_ray_jobs WHERE id = ?")
+ .get(jobId) as DataRow | null,
+ ),
+ );
+ }
+
+ public saveRayJob(
+ submission: ScientificRayJobSubmission,
+ record: ScientificRayJobRecord,
+ ): Effect.Effect {
+ return repositoryEffect("scientific-workbench.ray-jobs.save", () => {
+ this.db
+ .query(
+ `INSERT INTO scientific_ray_jobs (id, project_id, notebook_id, data, created_at)
+ VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
+ )
+ .run(
+ record.id,
+ submission.project_id,
+ submission.notebook_id,
+ JSON.stringify(record),
+ submission.requested_at,
+ );
+ });
+ }
+
+ public listReceipts(
+ projectId?: string,
+ ): Effect.Effect {
+ return repositoryEffect("scientific-workbench.receipts.list", () => {
+ const rows = projectId
+ ? (this.db
+ .query(
+ "SELECT data FROM scientific_experiment_receipts WHERE project_id = ? ORDER BY created_at DESC",
+ )
+ .all(projectId) as DataRow[])
+ : (this.db
+ .query("SELECT data FROM scientific_experiment_receipts ORDER BY created_at DESC")
+ .all() as DataRow[]);
+ return rows.flatMap((row) => {
+ const value = decodeRow(row);
+ return value ? [value] : [];
+ });
+ });
+ }
+
+ public getReceipt(
+ receiptId: string,
+ ): Effect.Effect {
+ return repositoryEffect("scientific-workbench.receipts.get", () =>
+ decodeRow(
+ this.db
+ .query("SELECT data FROM scientific_experiment_receipts WHERE id = ?")
+ .get(receiptId) as DataRow | null,
+ ),
+ );
+ }
+
+ public getReceiptBySubmission(
+ submissionId: string,
+ ): Effect.Effect {
+ return repositoryEffect("scientific-workbench.receipts.get-by-submission", () =>
+ decodeRow(
+ this.db
+ .query("SELECT data FROM scientific_experiment_receipts WHERE submission_id = ?")
+ .get(submissionId) as DataRow | null,
+ ),
+ );
+ }
+
+ public saveReceipt(
+ projectId: string,
+ receipt: ScientificExperimentReceipt,
+ ): Effect.Effect {
+ return repositoryEffect("scientific-workbench.receipts.save", () => {
+ this.db
+ .query(
+ `INSERT INTO scientific_experiment_receipts
+ (id, project_id, submission_id, data, created_at)
+ VALUES (?, ?, ?, ?, ?)`,
+ )
+ .run(
+ receipt.id,
+ projectId,
+ receipt.submission_id,
+ JSON.stringify(receipt),
+ receipt.completed_at ?? receipt.started_at,
+ );
+ });
+ }
+
+ public listFoundryInvocationEvidence(
+ submissionId: string,
+ ): Effect.Effect {
+ return repositoryEffect("scientific-workbench.foundry-evidence.list", () => {
+ const rows = this.db
+ .query(
+ `SELECT data FROM scientific_foundry_invocation_evidence
+ WHERE submission_id = ? ORDER BY created_at ASC`,
+ )
+ .all(submissionId) as DataRow[];
+ return rows.flatMap((row) => {
+ const value = decodeRow(row);
+ return value ? [value] : [];
+ });
+ });
+ }
+
+ public saveFoundryInvocationEvidence(
+ evidence: ScientificFoundryInvocationEvidence,
+ ): Effect.Effect {
+ return repositoryEffect("scientific-workbench.foundry-evidence.save", () => {
+ this.db
+ .query(
+ `INSERT INTO scientific_foundry_invocation_evidence
+ (id, submission_id, data, created_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(id) DO NOTHING`,
+ )
+ .run(evidence.id, evidence.submission_id, JSON.stringify(evidence), evidence.observed_at);
+ });
+ }
+
+ public close(): Effect.Effect {
+ return this.closeDatabase();
+ }
+}
diff --git a/controller/src/modules/workbench/types.ts b/controller/src/modules/workbench/types.ts
new file mode 100644
index 000000000..3c6932943
--- /dev/null
+++ b/controller/src/modules/workbench/types.ts
@@ -0,0 +1,87 @@
+import type { ScientificRayJobSubmission } from "@local-studio/contracts/scientific-workbench";
+import type { EnterprisePrincipalScope } from "@local-studio/contracts/enterprise-auth";
+
+export type ScientificRayJobResource = {
+ apiVersion: "ray.io/v1";
+ kind: "RayJob";
+ metadata: {
+ name: string;
+ namespace: string;
+ labels: Record;
+ annotations: Record;
+ };
+ spec: {
+ entrypoint: string;
+ shutdownAfterJobFinishes: true;
+ ttlSecondsAfterFinished: number;
+ rayClusterSpec: {
+ headGroupSpec: ScientificRayPodGroup;
+ workerGroupSpecs: Array<
+ ScientificRayPodGroup & {
+ groupName: string;
+ replicas: number;
+ minReplicas: number;
+ maxReplicas: number;
+ }
+ >;
+ };
+ };
+};
+
+export type ScientificRayPodGroup = {
+ rayStartParams: Record;
+ template: {
+ spec: {
+ automountServiceAccountToken: false;
+ containers: Array<{
+ name: "ray";
+ image: string;
+ resources: {
+ requests: Record;
+ limits: Record;
+ };
+ env: Array<{ name: string; value: string }>;
+ }>;
+ };
+ };
+};
+
+export type ScientificRayJobRecord = {
+ id: string;
+ state: "queued" | "submitted" | "running" | "succeeded" | "failed" | "suspended";
+ submission: ScientificRayJobSubmission;
+ admission_principal?: EnterprisePrincipalScope;
+ resource: ScientificRayJobResource;
+ admitted_at: string;
+ submitted_at?: string;
+ reconciled_at?: string;
+ cluster?: {
+ uid: string | null;
+ resource_version: string | null;
+ job_status: string | null;
+ deployment_status: string | null;
+ message: string | null;
+ started_at: string | null;
+ ended_at: string | null;
+ resource_usage?: {
+ cpu_seconds: number;
+ gpu_seconds: number;
+ peak_memory_gb: number;
+ };
+ artifact_digests?: string[];
+ policy_decision_ids?: string[];
+ apim_correlation_ids?: string[];
+ agent_ids?: string[];
+ };
+};
+
+export type ScientificFoundryInvocationEvidence = {
+ id: string;
+ submission_id: string;
+ principal: EnterprisePrincipalScope;
+ kind: "model" | "agent";
+ provider_id: string;
+ resource_id: string;
+ correlation_id: string;
+ observed_at: string;
+};
diff --git a/controller/src/services/provider-authentication.ts b/controller/src/services/provider-authentication.ts
new file mode 100644
index 000000000..370efccc1
--- /dev/null
+++ b/controller/src/services/provider-authentication.ts
@@ -0,0 +1,570 @@
+import type {
+ NormalizedPrincipal,
+ ProviderAuthentication,
+} from "@local-studio/contracts/enterprise-auth";
+import { Deferred, Effect, Schema } from "effect";
+import { decodeJwt } from "jose";
+import type { ProviderConfig } from "../config/persisted-config";
+import { providerApiKeyReference, type ProviderSecretStore } from "./provider-secret-store";
+import { providerSecretReferenceMatches } from "./provider-secret-store";
+import { clientCredentialsToken, exchangeProviderToken } from "./provider-token-exchange";
+
+export class ProviderAuthenticationError extends Schema.TaggedErrorClass()(
+ "ProviderAuthenticationError",
+ {
+ provider: Schema.String,
+ reason: Schema.Literals([
+ "credential_unavailable",
+ "identity_unavailable",
+ "identity_mismatch",
+ "token_unavailable",
+ "token_invalid",
+ "audience_mismatch",
+ "scope_mismatch",
+ ]),
+ },
+) {}
+
+export type ProviderAuthenticationContext = {
+ secretStore?: ProviderSecretStore | undefined;
+ principal?: NormalizedPrincipal | undefined;
+ verifiedBearerToken?: string | undefined;
+ directApiKey?: string | undefined;
+ directClientSecret?: string | undefined;
+ directSubscriptionKey?: { header: string; value: string } | undefined;
+ signal?: AbortSignal | undefined;
+};
+
+const ManagedIdentityResponseSchema = Schema.Struct({
+ access_token: Schema.String,
+ expires_on: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
+ expires_in: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
+});
+
+type CachedToken = { token: string; expiresAt: number };
+const managedIdentityCache = new Map();
+const managedIdentityPending = new Map<
+ string,
+ Deferred.Deferred
+>();
+
+const normalizedRequiredValue = (value: string, label: string): string => {
+ const normalized = value.trim();
+ if (!normalized || normalized.length > 2_048) {
+ throw new TypeError(`${label} is invalid`);
+ }
+ return normalized;
+};
+
+export const normalizeProviderAuthentication = (
+ providerId: string,
+ authentication: ProviderAuthentication,
+): ProviderAuthentication => {
+ if (authentication.type === "none") return authentication;
+ if (authentication.type === "api_key") {
+ return {
+ type: "api_key",
+ secret_ref: providerSecretReferenceMatches(providerId, authentication.secret_ref, "api-key")
+ ? authentication.secret_ref
+ : providerApiKeyReference(providerId),
+ };
+ }
+ if (authentication.type === "managed_identity") {
+ const resource = normalizedRequiredValue(authentication.resource, "resource");
+ const resourceUrl = new URL(resource);
+ if (!["https:", "api:"].includes(resourceUrl.protocol)) {
+ throw new TypeError("Managed identity resource is invalid");
+ }
+ return { type: "managed_identity", resource };
+ }
+ const issuerId = normalizedRequiredValue(authentication.issuer_id, "issuer_id");
+ const audience = normalizedRequiredValue(authentication.audience, "audience");
+ const scopes = [...new Set(authentication.scopes.map((scope) => scope.trim()).filter(Boolean))];
+ if (scopes.length === 0 || scopes.some((scope) => scope.length > 2_048)) {
+ throw new TypeError("Provider scopes are invalid");
+ }
+ if (authentication.type === "apim_client") {
+ const tokenEndpoint = normalizedRequiredValue(authentication.token_endpoint, "token_endpoint");
+ const endpoint = new URL(tokenEndpoint);
+ if (
+ !["http:", "https:"].includes(endpoint.protocol) ||
+ endpoint.username ||
+ endpoint.password
+ ) {
+ throw new TypeError("Token endpoint is invalid");
+ }
+ const clientId = normalizedRequiredValue(authentication.client_id, "client_id");
+ if (
+ authentication.client_secret_ref &&
+ !providerSecretReferenceMatches(providerId, authentication.client_secret_ref, "client-secret")
+ ) {
+ throw new TypeError("Client secret reference is invalid");
+ }
+ return {
+ type: "apim_client",
+ issuer_id: issuerId,
+ audience,
+ scopes,
+ token_endpoint: tokenEndpoint,
+ client_id: clientId,
+ ...(authentication.client_secret_ref
+ ? { client_secret_ref: authentication.client_secret_ref }
+ : {}),
+ };
+ }
+ const tokenExchange = authentication.token_exchange;
+ if (tokenExchange) {
+ const endpoint = new URL(
+ normalizedRequiredValue(tokenExchange.token_endpoint, "token_endpoint"),
+ );
+ if (
+ !["http:", "https:"].includes(endpoint.protocol) ||
+ endpoint.username ||
+ endpoint.password
+ ) {
+ throw new TypeError("Token exchange endpoint is invalid");
+ }
+ if (
+ tokenExchange.client_secret_ref &&
+ !providerSecretReferenceMatches(providerId, tokenExchange.client_secret_ref, "client-secret")
+ ) {
+ throw new TypeError("Token exchange client secret reference is invalid");
+ }
+ }
+ return {
+ type: authentication.type,
+ issuer_id: issuerId,
+ audience,
+ scopes,
+ ...(tokenExchange
+ ? {
+ token_exchange: {
+ mode: tokenExchange.mode,
+ token_endpoint: tokenExchange.token_endpoint,
+ client_id: normalizedRequiredValue(tokenExchange.client_id, "client_id"),
+ ...(tokenExchange.client_secret_ref
+ ? { client_secret_ref: tokenExchange.client_secret_ref }
+ : {}),
+ },
+ }
+ : {}),
+ };
+};
+
+const expiryTime = (value: typeof ManagedIdentityResponseSchema.Type): number => {
+ const expiresOn = Number(value.expires_on);
+ if (Number.isFinite(expiresOn) && expiresOn > Date.now() / 1000) return expiresOn * 1000;
+ const expiresIn = Number(value.expires_in);
+ if (Number.isFinite(expiresIn) && expiresIn > 0) return Date.now() + expiresIn * 1000;
+ return Date.now() + 5 * 60_000;
+};
+
+const managedIdentityEndpoint = (resource: string): URL => {
+ const configured =
+ process.env["LOCAL_STUDIO_MANAGED_IDENTITY_ENDPOINT"]?.trim() ||
+ process.env["IDENTITY_ENDPOINT"]?.trim() ||
+ "http://169.254.169.254/metadata/identity/oauth2/token";
+ const url = new URL(configured);
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
+ throw new Error("Managed identity endpoint is invalid");
+ }
+ url.searchParams.set("api-version", "2018-02-01");
+ url.searchParams.set("resource", resource);
+ return url;
+};
+
+const acquireManagedIdentityToken = (
+ provider: string,
+ resource: string,
+ signal?: AbortSignal,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const cached = managedIdentityCache.get(resource);
+ if (cached && Date.now() < cached.expiresAt - 60_000) return cached.token;
+ let pending = managedIdentityPending.get(resource);
+ if (!pending) {
+ pending = Deferred.makeUnsafe();
+ managedIdentityPending.set(resource, pending);
+ const shared = pending;
+ yield* Effect.gen(function* () {
+ const endpoint = managedIdentityEndpoint(resource);
+ const response = yield* Effect.tryPromise({
+ try: () =>
+ fetch(endpoint, {
+ headers: {
+ Metadata: "true",
+ ...(process.env["IDENTITY_HEADER"]?.trim()
+ ? { "X-IDENTITY-HEADER": process.env["IDENTITY_HEADER"]!.trim() }
+ : {}),
+ },
+ signal: AbortSignal.timeout(10_000),
+ redirect: "error",
+ }),
+ catch: () =>
+ new ProviderAuthenticationError({
+ provider,
+ reason: "identity_unavailable",
+ }),
+ });
+ if (!response.ok) {
+ return yield* Effect.fail(
+ new ProviderAuthenticationError({
+ provider,
+ reason: "identity_unavailable",
+ }),
+ );
+ }
+ const payload = yield* Effect.tryPromise({
+ try: () => response.json(),
+ catch: () =>
+ new ProviderAuthenticationError({
+ provider,
+ reason: "identity_unavailable",
+ }),
+ });
+ const decoded = yield* Schema.decodeUnknownEffect(ManagedIdentityResponseSchema)(
+ payload,
+ ).pipe(
+ Effect.mapError(
+ () =>
+ new ProviderAuthenticationError({
+ provider,
+ reason: "identity_unavailable",
+ }),
+ ),
+ );
+ const result = { token: decoded.access_token, expiresAt: expiryTime(decoded) };
+ managedIdentityCache.set(resource, result);
+ return result;
+ }).pipe(
+ Effect.tap((result) => Deferred.succeed(shared, result)),
+ Effect.tapError((error) => Deferred.fail(shared, error)),
+ Effect.ensuring(
+ Effect.sync(() => {
+ managedIdentityPending.delete(resource);
+ }),
+ ),
+ Effect.forkDetach({ startImmediately: true }),
+ );
+ }
+ const wait = Deferred.await(pending);
+ const result = yield* signal
+ ? Effect.raceFirst(
+ wait,
+ Effect.callback((resume) => {
+ const abort = (): void =>
+ resume(
+ Effect.fail(
+ new ProviderAuthenticationError({
+ provider,
+ reason: "identity_unavailable",
+ }),
+ ),
+ );
+ if (signal.aborted) {
+ abort();
+ return;
+ }
+ signal.addEventListener("abort", abort, { once: true });
+ return Effect.sync(() => signal.removeEventListener("abort", abort));
+ }),
+ )
+ : wait;
+ return result.token;
+ });
+
+const validateClientCredentialsToken = (
+ provider: string,
+ authentication: Extract,
+ token: string,
+): Effect.Effect =>
+ Effect.try({
+ try: () => decodeJwt(token),
+ catch: () => new ProviderAuthenticationError({ provider, reason: "token_invalid" }),
+ }).pipe(
+ Effect.flatMap((payload) => {
+ const audiences =
+ typeof payload.aud === "string"
+ ? [payload.aud]
+ : Array.isArray(payload.aud)
+ ? payload.aud
+ : [];
+ const scopes = new Set(
+ [
+ ...(typeof payload["scp"] === "string" ? payload["scp"].split(/\s+/u) : []),
+ ...(typeof payload["scope"] === "string" ? payload["scope"].split(/\s+/u) : []),
+ ].filter(Boolean),
+ );
+ if (typeof payload.exp === "number" && payload.exp * 1000 <= Date.now()) {
+ return Effect.fail(new ProviderAuthenticationError({ provider, reason: "token_invalid" }));
+ }
+ if (!audiences.includes(authentication.audience)) {
+ return Effect.fail(
+ new ProviderAuthenticationError({ provider, reason: "audience_mismatch" }),
+ );
+ }
+ if (!authentication.scopes.every((scope) => scopes.has(scope))) {
+ return Effect.fail(
+ new ProviderAuthenticationError({ provider, reason: "scope_mismatch" }),
+ );
+ }
+ return Effect.succeed(token);
+ }),
+ );
+
+const validatedDelegatedToken = (
+ provider: string,
+ authentication: Extract,
+ principal: NormalizedPrincipal,
+ token: string,
+): Effect.Effect =>
+ Effect.try({
+ try: () => decodeJwt(token),
+ catch: () => new ProviderAuthenticationError({ provider, reason: "token_invalid" }),
+ }).pipe(
+ Effect.flatMap((payload) => {
+ const audiences =
+ typeof payload.aud === "string"
+ ? [payload.aud]
+ : Array.isArray(payload.aud)
+ ? payload.aud
+ : [];
+ const scopes = new Set(
+ [
+ ...(typeof payload["scp"] === "string" ? payload["scp"].split(/\s+/u) : []),
+ ...(typeof payload["scope"] === "string" ? payload["scope"].split(/\s+/u) : []),
+ ].filter(Boolean),
+ );
+ if (
+ payload.sub !== principal.subject ||
+ payload.iss !== principal.issuer ||
+ (typeof payload.exp === "number" && payload.exp * 1000 <= Date.now())
+ ) {
+ return Effect.fail(new ProviderAuthenticationError({ provider, reason: "token_invalid" }));
+ }
+ if (!audiences.includes(authentication.audience)) {
+ return Effect.fail(
+ new ProviderAuthenticationError({ provider, reason: "audience_mismatch" }),
+ );
+ }
+ if (!authentication.scopes.every((scope) => scopes.has(scope))) {
+ return Effect.fail(new ProviderAuthenticationError({ provider, reason: "scope_mismatch" }));
+ }
+ return Effect.succeed(token);
+ }),
+ );
+
+const withSubscriptionKey = (
+ provider: ProviderConfig,
+ context: ProviderAuthenticationContext,
+ headers: Record,
+): Effect.Effect, ProviderAuthenticationError> => {
+ const subscription = provider.subscription_key;
+ if (subscription) {
+ const header = subscription.header.trim();
+ if (!header) {
+ return Effect.succeed(headers);
+ }
+ const reference = subscription.secret_ref.trim();
+ if (!reference) {
+ return Effect.fail(
+ new ProviderAuthenticationError({
+ provider: provider.id,
+ reason: "credential_unavailable",
+ }),
+ );
+ }
+ if (!context.secretStore) {
+ return Effect.fail(
+ new ProviderAuthenticationError({
+ provider: provider.id,
+ reason: "credential_unavailable",
+ }),
+ );
+ }
+ return context.secretStore.read(reference).pipe(
+ Effect.mapError(
+ () =>
+ new ProviderAuthenticationError({
+ provider: provider.id,
+ reason: "credential_unavailable",
+ }),
+ ),
+ Effect.flatMap((credential) =>
+ credential
+ ? Effect.succeed({ ...headers, [header]: credential })
+ : Effect.fail(
+ new ProviderAuthenticationError({
+ provider: provider.id,
+ reason: "credential_unavailable",
+ }),
+ ),
+ ),
+ );
+ }
+ if (context.directSubscriptionKey) {
+ const header = context.directSubscriptionKey.header.trim();
+ const value = context.directSubscriptionKey.value.trim();
+ if (header && value) {
+ return Effect.succeed({ ...headers, [header]: value });
+ }
+ }
+ return Effect.succeed(headers);
+};
+
+export const resolveProviderHeaders = (
+ provider: ProviderConfig,
+ context: ProviderAuthenticationContext = {},
+): Effect.Effect, ProviderAuthenticationError> => {
+ const authentication = provider.authentication;
+ if (authentication.type === "none") {
+ return withSubscriptionKey(provider, context, {});
+ }
+ if (authentication.type === "api_key") {
+ const direct = context.directApiKey?.trim();
+ if (direct) {
+ return withSubscriptionKey(provider, context, { Authorization: `Bearer ${direct}` });
+ }
+ const reference = authentication.secret_ref;
+ if (!reference || !context.secretStore) {
+ return Effect.fail(
+ new ProviderAuthenticationError({
+ provider: provider.id,
+ reason: "credential_unavailable",
+ }),
+ );
+ }
+ const headers = context.secretStore.read(reference).pipe(
+ Effect.mapError(
+ () =>
+ new ProviderAuthenticationError({
+ provider: provider.id,
+ reason: "credential_unavailable",
+ }),
+ ),
+ Effect.flatMap((credential) =>
+ credential
+ ? Effect.succeed({ Authorization: `Bearer ${credential}` })
+ : Effect.fail(
+ new ProviderAuthenticationError({
+ provider: provider.id,
+ reason: "credential_unavailable",
+ }),
+ ),
+ ),
+ );
+ return headers.pipe(Effect.flatMap((h) => withSubscriptionKey(provider, context, h)));
+ }
+ if (authentication.type === "managed_identity") {
+ return acquireManagedIdentityToken(provider.id, authentication.resource, context.signal).pipe(
+ Effect.map((token) => ({ Authorization: `Bearer ${token}` })),
+ Effect.flatMap((h) => withSubscriptionKey(provider, context, h)),
+ );
+ }
+ if (authentication.type === "apim_client") {
+ const clientSecretReference = authentication.client_secret_ref;
+ const directClientSecret = context.directClientSecret?.trim();
+ if (!clientSecretReference && !directClientSecret) {
+ return Effect.fail(
+ new ProviderAuthenticationError({
+ provider: provider.id,
+ reason: "credential_unavailable",
+ }),
+ );
+ }
+ return Effect.gen(function* () {
+ const clientSecret = directClientSecret
+ ? directClientSecret
+ : clientSecretReference && context.secretStore
+ ? yield* context.secretStore.read(clientSecretReference).pipe(
+ Effect.mapError(
+ () =>
+ new ProviderAuthenticationError({
+ provider: provider.id,
+ reason: "credential_unavailable",
+ }),
+ ),
+ )
+ : "";
+ if (!clientSecret) {
+ return yield* Effect.fail(
+ new ProviderAuthenticationError({
+ provider: provider.id,
+ reason: "credential_unavailable",
+ }),
+ );
+ }
+ const token = yield* clientCredentialsToken(
+ provider.id,
+ authentication,
+ clientSecret,
+ context.signal,
+ ).pipe(
+ Effect.mapError((error) =>
+ new ProviderAuthenticationError({
+ provider: provider.id,
+ reason:
+ error.reason === "credential_unavailable"
+ ? "credential_unavailable"
+ : "token_unavailable",
+ }),
+ ),
+ );
+ const validated = yield* validateClientCredentialsToken(
+ provider.id,
+ authentication,
+ token,
+ );
+ return validated;
+ }).pipe(
+ Effect.map((token) => ({ Authorization: `Bearer ${token}` })),
+ Effect.flatMap((h) => withSubscriptionKey(provider, context, h)),
+ );
+ }
+ if (!context.principal || context.principal.issuer_id !== authentication.issuer_id) {
+ return Effect.fail(
+ new ProviderAuthenticationError({
+ provider: provider.id,
+ reason: "identity_mismatch",
+ }),
+ );
+ }
+ if (!context.verifiedBearerToken) {
+ return Effect.fail(
+ new ProviderAuthenticationError({
+ provider: provider.id,
+ reason: "token_unavailable",
+ }),
+ );
+ }
+ return validatedDelegatedToken(
+ provider.id,
+ authentication,
+ context.principal,
+ context.verifiedBearerToken,
+ ).pipe(
+ Effect.flatMap((token) =>
+ exchangeProviderToken(
+ provider.id,
+ authentication,
+ context.principal!,
+ token,
+ context.secretStore,
+ context.signal,
+ ),
+ ),
+ Effect.mapError((error) =>
+ error instanceof ProviderAuthenticationError
+ ? error
+ : new ProviderAuthenticationError({
+ provider: provider.id,
+ reason:
+ error.reason === "credential_unavailable"
+ ? "credential_unavailable"
+ : "token_unavailable",
+ }),
+ ),
+ Effect.map((token) => ({ Authorization: `Bearer ${token}` })),
+ Effect.flatMap((h) => withSubscriptionKey(provider, context, h)),
+ );
+};
diff --git a/controller/src/services/provider-boundary.ts b/controller/src/services/provider-boundary.ts
new file mode 100644
index 000000000..f393f3923
--- /dev/null
+++ b/controller/src/services/provider-boundary.ts
@@ -0,0 +1,137 @@
+import { lookup } from "node:dns/promises";
+import { isIP } from "node:net";
+import { Effect } from "effect";
+import { normalizeOpenAIBaseUrl } from "../../../shared/agent/openai-endpoint";
+
+const defaultProviderHosts = new Set([
+ "127.0.0.1",
+ "::1",
+ "localhost",
+ "host.docker.internal",
+ "api.tprime.vlans.ca",
+ "api.thalesdigital.io",
+ "pop-os-1.tailadb2c1.ts.net",
+]);
+
+const defaultPrivateProviderHosts = new Set([
+ "127.0.0.1",
+ "::1",
+ "localhost",
+ "host.docker.internal",
+ "api.tprime.vlans.ca",
+ "api.thalesdigital.io",
+ "pop-os-1.tailadb2c1.ts.net",
+]);
+
+const configuredHosts = (name: string, defaults: ReadonlySet): Set => {
+ const hosts = new Set(defaults);
+ for (const entry of (process.env[name] ?? "").split(",")) {
+ const value = entry.trim().toLowerCase();
+ if (!value) continue;
+ try {
+ hosts.add(new URL(value.includes("://") ? value : `https://${value}`).hostname.toLowerCase());
+ } catch {}
+ }
+ return hosts;
+};
+
+export const configuredProviderHosts = (): Set => {
+ return configuredHosts("LOCAL_STUDIO_PROVIDER_HOST_ALLOWLIST", defaultProviderHosts);
+};
+
+export const normalizeAdmittedProviderBaseUrl = (value: string): string => {
+ const normalized = normalizeOpenAIBaseUrl(value);
+ const url = new URL(normalized);
+ const hostname = url.hostname.toLowerCase();
+ if (!configuredProviderHosts().has(hostname)) {
+ throw new TypeError("Provider host is not allowlisted");
+ }
+ if (url.protocol === "http:" && isIP(hostname) === 0 && hostname !== "localhost") {
+ const privateHttpHosts = new Set(
+ (process.env["LOCAL_STUDIO_PROVIDER_HTTP_HOST_ALLOWLIST"] ?? "")
+ .split(",")
+ .map((entry) => entry.trim().toLowerCase())
+ .filter(Boolean),
+ );
+ if (!defaultProviderHosts.has(hostname) && !privateHttpHosts.has(hostname)) {
+ throw new TypeError("Provider HTTP host is not explicitly admitted");
+ }
+ }
+ return normalized;
+};
+
+const restrictedIpv4 = (address: string): boolean => {
+ const parts = address.split(".").map(Number);
+ if (
+ parts.length !== 4 ||
+ parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
+ ) {
+ return true;
+ }
+ const [a, b] = parts as [number, number, number, number];
+ return (
+ a === 0 ||
+ a === 10 ||
+ a === 127 ||
+ (a === 100 && b >= 64 && b <= 127) ||
+ (a === 169 && b === 254) ||
+ (a === 172 && b >= 16 && b <= 31) ||
+ (a === 192 && b === 168) ||
+ a >= 224
+ );
+};
+
+const restrictedAddress = (address: string): boolean => {
+ const family = isIP(address);
+ if (family === 4) return restrictedIpv4(address);
+ if (family !== 6) return true;
+ const normalized = address.toLowerCase();
+ const mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/u)?.[1];
+ if (mapped) return restrictedIpv4(mapped);
+ return (
+ normalized === "::" ||
+ normalized === "::1" ||
+ normalized.startsWith("fc") ||
+ normalized.startsWith("fd") ||
+ /^fe[89ab]/u.test(normalized) ||
+ normalized.startsWith("ff")
+ );
+};
+
+export type ProviderHostnameLookup = (
+ hostname: string,
+) => Effect.Effect, unknown>;
+
+const systemLookup: ProviderHostnameLookup = (hostname) =>
+ Effect.tryPromise({
+ try: () => lookup(hostname, { all: true, verbatim: true }),
+ catch: (source) => source,
+ });
+
+export const assertProviderOutboundUrl = (
+ value: string,
+ hostnameLookup: ProviderHostnameLookup = systemLookup,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const normalized = normalizeAdmittedProviderBaseUrl(value);
+ const hostname = new URL(normalized).hostname.toLowerCase();
+ const addresses =
+ isIP(hostname) > 0
+ ? [{ address: hostname, family: isIP(hostname) }]
+ : yield* hostnameLookup(hostname);
+ if (addresses.length === 0)
+ return yield* Effect.fail(new TypeError("Provider host did not resolve"));
+ const privateHosts = configuredHosts(
+ "LOCAL_STUDIO_PROVIDER_PRIVATE_HOST_ALLOWLIST",
+ defaultPrivateProviderHosts,
+ );
+ if (
+ addresses.some(({ address }) => restrictedAddress(address)) &&
+ !privateHosts.has(hostname)
+ ) {
+ return yield* Effect.fail(
+ new TypeError("Provider host resolved to a restricted network address"),
+ );
+ }
+ return normalized;
+ });
diff --git a/controller/src/services/provider-routing.ts b/controller/src/services/provider-routing.ts
index c90d5165d..c46e0acab 100644
--- a/controller/src/services/provider-routing.ts
+++ b/controller/src/services/provider-routing.ts
@@ -1,7 +1,20 @@
import type { ProviderConfig } from "../config/persisted-config";
+import { Effect, Schema } from "effect";
+import {
+ normalizeOpenAIBaseUrl,
+ providerModelsEndpoint,
+} from "../../../shared/agent/openai-endpoint";
+import {
+ resolveProviderHeaders,
+ type ProviderAuthenticationContext,
+} from "./provider-authentication";
+import { assertProviderOutboundUrl } from "./provider-boundary";
export const DEFAULT_CHAT_PROVIDER = "openai";
+export const isReservedProviderId = (providerId: string): boolean =>
+ providerId.trim().toLowerCase() === DEFAULT_CHAT_PROVIDER;
+
export interface ParsedProviderModel {
provider: string;
modelId: string;
@@ -9,13 +22,27 @@ export interface ParsedProviderModel {
export interface ProviderRouteConfig {
baseUrl: string;
- apiKey: string;
+ provider: ProviderConfig;
}
export interface ControllerProviderRoutingConfig {
providers?: ProviderConfig[];
}
+type ProviderFetch = (
+ input: string | URL | Request,
+ init?: RequestInit,
+) => ReturnType;
+
+const ProviderModelsSchema = Schema.Struct({
+ data: Schema.optional(Schema.Array(Schema.Struct({ id: Schema.optional(Schema.String) }))),
+});
+
+export type ProviderModelRoute =
+ | { kind: "local"; provider: typeof DEFAULT_CHAT_PROVIDER; modelId: string }
+ | { kind: "remote"; provider: string; modelId: string; config: ProviderRouteConfig }
+ | { kind: "unavailable"; provider: string; modelId: string };
+
export const parseProviderModel = (rawModel: string): ParsedProviderModel => {
const trimmed = rawModel.trim();
if (!trimmed) {
@@ -39,13 +66,70 @@ export const resolveConfiguredProviderConfig = (
providers: ProviderConfig[] = [],
): ProviderRouteConfig | null => {
const match = providers.find((p) => p.id.toLowerCase() === providerId.toLowerCase() && p.enabled);
- if (!match || !match.api_key) return null;
- return { baseUrl: match.base_url, apiKey: match.api_key };
+ if (!match) return null;
+ if (match.authentication.type === "api_key" && !match.authentication.secret_ref) return null;
+ return { baseUrl: normalizeOpenAIBaseUrl(match.base_url), provider: match };
+};
+
+export const providerIsDiscoverable = (provider: ProviderConfig): boolean => {
+ return provider.enabled;
};
+export const discoverProviderModels = (
+ provider: ProviderConfig,
+ fetcher: ProviderFetch = fetch,
+ authenticationContext: ProviderAuthenticationContext = {},
+): Effect.Effect<{ provider: string; models: Array<{ id: string }> }, unknown> =>
+ Effect.gen(function* () {
+ const headers = yield* resolveProviderHeaders(provider, authenticationContext);
+ const baseUrl =
+ fetcher === fetch
+ ? yield* assertProviderOutboundUrl(provider.base_url)
+ : normalizeOpenAIBaseUrl(provider.base_url);
+ const response = yield* Effect.tryPromise({
+ try: (signal) =>
+ fetcher(providerModelsEndpoint(baseUrl, provider.path_style, provider.api_version), {
+ headers,
+ signal: authenticationContext.signal
+ ? AbortSignal.any([authenticationContext.signal, signal, AbortSignal.timeout(10_000)])
+ : AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
+ redirect: "error",
+ }),
+ catch: (source) => source,
+ });
+ if (!response.ok) return yield* Effect.fail(response.status);
+ const payload = yield* Effect.tryPromise({
+ try: () => response.json(),
+ catch: (source) => source,
+ });
+ const decoded = yield* Schema.decodeUnknownEffect(ProviderModelsSchema)(payload);
+ return {
+ provider: provider.id,
+ models: (decoded.data ?? []).flatMap((model) => {
+ const id = model.id?.trim();
+ return id ? [{ id }] : [];
+ }),
+ };
+ });
+
export const resolveProviderConfig = (
provider: string,
config: ControllerProviderRoutingConfig = {},
): ProviderRouteConfig | null => {
return resolveConfiguredProviderConfig(provider, config.providers);
};
+
+export const resolveProviderModelRoute = (
+ rawModel: string,
+ config: ControllerProviderRoutingConfig = {},
+ localModelMatched = false,
+): ProviderModelRoute => {
+ const parsed = parseProviderModel(rawModel);
+ if (localModelMatched || parsed.provider === DEFAULT_CHAT_PROVIDER) {
+ return { kind: "local", provider: DEFAULT_CHAT_PROVIDER, modelId: parsed.modelId };
+ }
+ const provider = resolveProviderConfig(parsed.provider, config);
+ return provider
+ ? { kind: "remote", provider: parsed.provider, modelId: parsed.modelId, config: provider }
+ : { kind: "unavailable", provider: parsed.provider, modelId: parsed.modelId };
+};
diff --git a/controller/src/services/provider-secret-store.ts b/controller/src/services/provider-secret-store.ts
new file mode 100644
index 000000000..2352f388f
--- /dev/null
+++ b/controller/src/services/provider-secret-store.ts
@@ -0,0 +1,372 @@
+import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
+import {
+ chmodSync,
+ closeSync,
+ existsSync,
+ fsyncSync,
+ lstatSync,
+ mkdirSync,
+ openSync,
+ readFileSync,
+ readdirSync,
+ renameSync,
+ unlinkSync,
+ writeFileSync,
+} from "node:fs";
+import { dirname, join } from "node:path";
+import { Effect, Schema } from "effect";
+
+const KEY_BYTES = 32;
+const KEY_ID_BYTES = 16;
+const NONCE_BYTES = 12;
+const TAG_BYTES = 16;
+const LEGACY_FORMAT_VERSION = 1;
+const FORMAT_VERSION = 2;
+const SECRET_REF_PATTERN =
+ /^provider:[a-z0-9][a-z0-9_-]{0,63}:(api-key|client-secret|subscription-key)(?::[a-f\d]{32})?$/u;
+
+export class ProviderSecretError extends Schema.TaggedErrorClass()(
+ "ProviderSecretError",
+ {
+ operation: Schema.Literals(["configure", "read", "write", "delete"]),
+ message: Schema.String,
+ source: Schema.optional(Schema.Unknown),
+ },
+) {}
+
+const secretError = (
+ operation: ProviderSecretError["operation"],
+ message: string,
+ source?: unknown,
+): ProviderSecretError =>
+ new ProviderSecretError({
+ operation,
+ message,
+ ...(source === undefined ? {} : { source }),
+ });
+
+const decodeKey = (value: string): Buffer => {
+ const key = /^[a-f\d]{64}$/iu.test(value)
+ ? Buffer.from(value, "hex")
+ : Buffer.from(value, "base64");
+ if (key.length !== KEY_BYTES) throw new Error("Provider master key must encode 32 bytes");
+ return key;
+};
+
+type ProviderMasterKey = {
+ id: string;
+ fingerprint: Buffer;
+ value: Buffer;
+};
+
+const masterKey = (id: string, value: string | Buffer): ProviderMasterKey => {
+ if (!/^[a-zA-Z0-9._-]{1,64}$/u.test(id)) {
+ throw new Error("Provider master key id is invalid");
+ }
+ const key = typeof value === "string" ? decodeKey(value) : value;
+ if (key.length !== KEY_BYTES) throw new Error("Provider master key must encode 32 bytes");
+ return {
+ id,
+ fingerprint: createHash("sha256").update(id).digest().subarray(0, KEY_ID_BYTES),
+ value: key,
+ };
+};
+
+const previousMasterKeys = (): ProviderMasterKey[] => {
+ const configured = process.env["LOCAL_STUDIO_PROVIDER_PREVIOUS_MASTER_KEYS"]?.trim();
+ if (!configured) return [];
+ const decoded: unknown = JSON.parse(configured);
+ if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) {
+ throw new Error("Previous provider master keys must be a JSON object");
+ }
+ return Object.entries(decoded).map(([id, value]) => {
+ if (typeof value !== "string") throw new Error("Previous provider master key is invalid");
+ return masterKey(id, value);
+ });
+};
+
+const authenticatedData = (reference: string, fingerprint: Buffer): Buffer =>
+ Buffer.concat([Buffer.from(reference), Buffer.from([0]), fingerprint]);
+
+const assertSafeFile = (path: string): void => {
+ const metadata = lstatSync(path);
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1) {
+ throw new Error("Provider secret file is unsafe");
+ }
+};
+
+const atomicWrite = (path: string, value: Uint8Array): void => {
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
+ const temporary = `${path}.tmp-${process.pid}-${randomBytes(8).toString("hex")}`;
+ try {
+ writeFileSync(temporary, value, { mode: 0o600 });
+ const temporaryHandle = openSync(temporary, "r");
+ try {
+ fsyncSync(temporaryHandle);
+ } finally {
+ closeSync(temporaryHandle);
+ }
+ renameSync(temporary, path);
+ chmodSync(path, 0o600);
+ syncDirectory(dirname(path));
+ } catch (error) {
+ if (existsSync(temporary)) unlinkSync(temporary);
+ throw error;
+ }
+};
+
+const syncDirectory = (path: string): void => {
+ try {
+ const handle = openSync(path, "r");
+ try {
+ fsyncSync(handle);
+ } finally {
+ closeSync(handle);
+ }
+ } catch (source) {
+ const code = (source as NodeJS.ErrnoException).code;
+ if (
+ process.platform === "win32" &&
+ ["EINVAL", "EISDIR", "ENOTSUP", "EPERM"].includes(code ?? "")
+ ) {
+ return;
+ }
+ throw source;
+ }
+};
+
+export const providerApiKeyReference = (providerId: string): string =>
+ `provider:${providerId}:api-key`;
+
+export const newProviderApiKeyReference = (providerId: string): string =>
+ `${providerApiKeyReference(providerId)}:${randomUUID().replaceAll("-", "")}`;
+
+export const newProviderClientSecretReference = (providerId: string): string =>
+ `provider:${providerId}:client-secret:${randomUUID().replaceAll("-", "")}`;
+
+export const providerSubscriptionKeyReference = (providerId: string): string =>
+ `provider:${providerId}:subscription-key`;
+
+export const newProviderSubscriptionKeyReference = (providerId: string): string =>
+ `${providerSubscriptionKeyReference(providerId)}:${randomUUID().replaceAll("-", "")}`;
+
+export const providerSecretReferenceMatches = (
+ providerId: string,
+ reference: string | undefined,
+ kind: "api-key" | "client-secret" | "subscription-key",
+): reference is string =>
+ Boolean(
+ reference &&
+ new RegExp(
+ `^provider:${providerId.replaceAll(/[$()*+.?[\\\]^{|}]/gu, "\\$&")}:${kind}(?::[a-f\\d]{32})?$`,
+ "u",
+ ).test(reference),
+ );
+
+export type ProviderSecretMutation = {
+ ref: string;
+ value: string | undefined;
+};
+
+export class ProviderSecretStore {
+ readonly #directory: string;
+ readonly #activeKey: ProviderMasterKey;
+ readonly #keys: ReadonlyMap;
+
+ constructor(dataDirectory: string, requireExternalKey: boolean) {
+ this.#directory = join(dataDirectory, "provider-secrets");
+ const configured = process.env["LOCAL_STUDIO_PROVIDER_MASTER_KEY"]?.trim();
+ try {
+ let activeKey: ProviderMasterKey;
+ if (configured) {
+ activeKey = masterKey(
+ process.env["LOCAL_STUDIO_PROVIDER_MASTER_KEY_ID"]?.trim() || "active",
+ configured,
+ );
+ } else {
+ if (requireExternalKey) {
+ throw new Error(
+ "LOCAL_STUDIO_PROVIDER_MASTER_KEY is required for a shared or non-loopback controller",
+ );
+ }
+ const keyPath = join(this.#directory, "local-master.key");
+ mkdirSync(this.#directory, { recursive: true, mode: 0o700 });
+ if (!existsSync(keyPath)) {
+ writeFileSync(keyPath, randomBytes(KEY_BYTES), { flag: "wx", mode: 0o600 });
+ }
+ assertSafeFile(keyPath);
+ chmodSync(keyPath, 0o600);
+ activeKey = masterKey("local", readFileSync(keyPath));
+ }
+ const keys = new Map();
+ for (const candidate of [activeKey, ...previousMasterKeys()]) {
+ const fingerprint = candidate.fingerprint.toString("hex");
+ if (keys.has(fingerprint)) throw new Error("Provider master key ids must be unique");
+ keys.set(fingerprint, candidate);
+ }
+ this.#activeKey = activeKey;
+ this.#keys = keys;
+ } catch (source) {
+ throw secretError("configure", "Provider secret storage is unavailable", source);
+ }
+ }
+
+ #path(reference: string): string {
+ if (!SECRET_REF_PATTERN.test(reference)) {
+ throw secretError("configure", "Provider secret reference is invalid");
+ }
+ return join(this.#directory, `${createHash("sha256").update(reference).digest("hex")}.bin`);
+ }
+
+ writeSync(reference: string, value: string): void {
+ try {
+ if (!value || value.length > 32_768) throw new Error("Provider credential is invalid");
+ const nonce = randomBytes(NONCE_BYTES);
+ const cipher = createCipheriv("aes-256-gcm", this.#activeKey.value, nonce);
+ cipher.setAAD(authenticatedData(reference, this.#activeKey.fingerprint));
+ const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
+ atomicWrite(
+ this.#path(reference),
+ Buffer.concat([
+ Buffer.from([FORMAT_VERSION]),
+ this.#activeKey.fingerprint,
+ nonce,
+ cipher.getAuthTag(),
+ encrypted,
+ ]),
+ );
+ } catch (source) {
+ if (source instanceof ProviderSecretError) throw source;
+ throw secretError("write", "Provider credential could not be stored", source);
+ }
+ }
+
+ readSync(reference: string): string | undefined {
+ try {
+ const path = this.#path(reference);
+ if (!existsSync(path)) return undefined;
+ assertSafeFile(path);
+ const bytes = readFileSync(path);
+ const version = bytes[0];
+ if (
+ bytes.length <= 1 + NONCE_BYTES + TAG_BYTES ||
+ (version !== LEGACY_FORMAT_VERSION && version !== FORMAT_VERSION)
+ ) {
+ throw new Error("Provider credential data is invalid");
+ }
+ const fingerprint =
+ version === FORMAT_VERSION ? bytes.subarray(1, 1 + KEY_ID_BYTES) : undefined;
+ const candidates = fingerprint
+ ? [this.#keys.get(fingerprint.toString("hex"))].filter(
+ (candidate): candidate is ProviderMasterKey => Boolean(candidate),
+ )
+ : [...this.#keys.values()];
+ if (candidates.length === 0) throw new Error("Provider credential key is unavailable");
+ const nonceStart = version === FORMAT_VERSION ? 1 + KEY_ID_BYTES : 1;
+ const tagStart = nonceStart + NONCE_BYTES;
+ const dataStart = tagStart + TAG_BYTES;
+ let source: unknown;
+ for (const candidate of candidates) {
+ try {
+ const decipher = createDecipheriv(
+ "aes-256-gcm",
+ candidate.value,
+ bytes.subarray(nonceStart, tagStart),
+ );
+ decipher.setAAD(
+ version === FORMAT_VERSION
+ ? authenticatedData(reference, candidate.fingerprint)
+ : Buffer.from(reference),
+ );
+ decipher.setAuthTag(bytes.subarray(tagStart, dataStart));
+ const value = Buffer.concat([
+ decipher.update(bytes.subarray(dataStart)),
+ decipher.final(),
+ ]).toString("utf8");
+ if (
+ version !== FORMAT_VERSION ||
+ !candidate.fingerprint.equals(this.#activeKey.fingerprint)
+ ) {
+ this.writeSync(reference, value);
+ }
+ return value;
+ } catch (cause) {
+ source = cause;
+ }
+ }
+ throw source ?? new Error("Provider credential could not be decrypted");
+ } catch (source) {
+ if (source instanceof ProviderSecretError) throw source;
+ throw secretError("read", "Provider credential could not be read", source);
+ }
+ }
+
+ removeSync(reference: string): void {
+ try {
+ const path = this.#path(reference);
+ if (existsSync(path)) unlinkSync(path);
+ } catch (source) {
+ if (source instanceof ProviderSecretError) throw source;
+ throw secretError("delete", "Provider credential could not be removed", source);
+ }
+ }
+
+ mutateSync(mutations: readonly ProviderSecretMutation[], persist: () => T): T {
+ const normalized = new Map();
+ for (const mutation of mutations) normalized.set(mutation.ref, mutation.value);
+ const snapshots = new Map();
+ for (const reference of normalized.keys()) {
+ snapshots.set(reference, this.readSync(reference));
+ }
+ try {
+ for (const [reference, value] of normalized) {
+ if (value === undefined) this.removeSync(reference);
+ else this.writeSync(reference, value);
+ }
+ return persist();
+ } catch (source) {
+ try {
+ for (const [reference, value] of snapshots) {
+ if (value === undefined) this.removeSync(reference);
+ else this.writeSync(reference, value);
+ }
+ } catch (rollbackSource) {
+ throw secretError("write", "Provider secret rollback failed", rollbackSource);
+ }
+ if (source instanceof ProviderSecretError) throw source;
+ throw secretError("write", "Provider secret transaction failed", source);
+ }
+ }
+
+ reconcileSync(activeReferences: ReadonlySet): void {
+ try {
+ if (!existsSync(this.#directory)) return;
+ const activeFiles = new Set(
+ [...activeReferences].map(
+ (reference) => `${createHash("sha256").update(reference).digest("hex")}.bin`,
+ ),
+ );
+ for (const entry of readdirSync(this.#directory, { withFileTypes: true })) {
+ if (
+ entry.isFile() &&
+ /^[a-f\d]{64}\.bin$/u.test(entry.name) &&
+ !activeFiles.has(entry.name)
+ ) {
+ unlinkSync(join(this.#directory, entry.name));
+ }
+ }
+ } catch (source) {
+ throw secretError("delete", "Provider secret reconciliation failed", source);
+ }
+ }
+
+ read(reference: string): Effect.Effect {
+ return Effect.try({
+ try: () => this.readSync(reference),
+ catch: (source) =>
+ source instanceof ProviderSecretError
+ ? source
+ : secretError("read", "Provider credential could not be read", source),
+ });
+ }
+}
diff --git a/controller/src/services/provider-token-exchange.ts b/controller/src/services/provider-token-exchange.ts
new file mode 100644
index 000000000..a46ca46da
--- /dev/null
+++ b/controller/src/services/provider-token-exchange.ts
@@ -0,0 +1,343 @@
+import { createHash } from "node:crypto";
+import type {
+ NormalizedPrincipal,
+ ProviderAuthentication,
+} from "@local-studio/contracts/enterprise-auth";
+import { Deferred, Effect, Schema } from "effect";
+import type { ProviderSecretStore } from "./provider-secret-store";
+
+type DelegatedAuthentication = Extract<
+ ProviderAuthentication,
+ { type: "oidc_user" | "apim_gateway" }
+>;
+
+const TokenResponseSchema = Schema.Struct({
+ access_token: Schema.String,
+ expires_in: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
+ token_type: Schema.optional(Schema.String),
+});
+
+type TokenLease = { token: string; expiresAt: number };
+
+export class ProviderTokenExchangeError extends Schema.TaggedErrorClass()(
+ "ProviderTokenExchangeError",
+ {
+ provider: Schema.String,
+ reason: Schema.Literals([
+ "configuration_invalid",
+ "credential_unavailable",
+ "exchange_failed",
+ "response_invalid",
+ ]),
+ },
+) {}
+
+const cache = new Map();
+const pending = new Map>();
+const MAX_CACHE_ENTRIES = 1_024;
+
+const failure = (
+ provider: string,
+ reason: ProviderTokenExchangeError["reason"],
+): ProviderTokenExchangeError => new ProviderTokenExchangeError({ provider, reason });
+
+const cacheLease = (key: string, lease: TokenLease): void => {
+ const now = Date.now();
+ for (const [entryKey, entry] of cache) {
+ if (entry.expiresAt <= now) cache.delete(entryKey);
+ }
+ cache.set(key, lease);
+ while (cache.size > MAX_CACHE_ENTRIES) {
+ const oldest = cache.keys().next().value as string | undefined;
+ if (!oldest) break;
+ cache.delete(oldest);
+ }
+};
+
+const exchangeKey = (
+ provider: string,
+ principal: NormalizedPrincipal,
+ token: string,
+ authentication: DelegatedAuthentication,
+): string =>
+ createHash("sha256")
+ .update(
+ [
+ provider,
+ principal.issuer,
+ principal.tenant,
+ principal.subject,
+ authentication.audience,
+ authentication.scopes.join(" "),
+ authentication.token_exchange?.mode ?? "",
+ authentication.token_exchange?.token_endpoint ?? "",
+ authentication.token_exchange?.client_id ?? "",
+ authentication.token_exchange?.client_secret_ref ?? "",
+ token,
+ ].join("\0"),
+ )
+ .digest("hex");
+
+const endpoint = (
+ provider: string,
+ principal: NormalizedPrincipal,
+ value: string,
+): Effect.Effect =>
+ Effect.try({
+ try: () => {
+ const url = new URL(value);
+ const issuer = new URL(principal.issuer);
+ const loopback = ["127.0.0.1", "::1", "localhost"].includes(url.hostname);
+ if ((!loopback && url.protocol !== "https:") || url.origin !== issuer.origin) {
+ throw new Error("Token exchange endpoint is outside the validated issuer");
+ }
+ return url;
+ },
+ catch: () => failure(provider, "configuration_invalid"),
+ });
+
+const requestBody = (
+ authentication: DelegatedAuthentication,
+ subjectToken: string,
+ clientSecret: string | undefined,
+): URLSearchParams => {
+ const exchange = authentication.token_exchange!;
+ const body = new URLSearchParams({
+ client_id: exchange.client_id,
+ scope: authentication.scopes.join(" "),
+ });
+ if (clientSecret) body.set("client_secret", clientSecret);
+ if (exchange.mode === "entra_obo") {
+ body.set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer");
+ body.set("assertion", subjectToken);
+ body.set("requested_token_use", "on_behalf_of");
+ } else {
+ body.set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange");
+ body.set("subject_token", subjectToken);
+ body.set("subject_token_type", "urn:ietf:params:oauth:token-type:access_token");
+ body.set("requested_token_type", "urn:ietf:params:oauth:token-type:access_token");
+ body.set("audience", authentication.audience);
+ }
+ return body;
+};
+
+const waitForLease = (
+ provider: string,
+ deferred: Deferred.Deferred,
+ signal: AbortSignal | undefined,
+): Effect.Effect => {
+ if (!signal) return Deferred.await(deferred);
+ return Effect.raceFirst(
+ Deferred.await(deferred),
+ Effect.callback((resume) => {
+ const abort = (): void => resume(Effect.fail(failure(provider, "exchange_failed")));
+ if (signal.aborted) {
+ abort();
+ return;
+ }
+ signal.addEventListener("abort", abort, { once: true });
+ return Effect.sync(() => signal.removeEventListener("abort", abort));
+ }),
+ );
+};
+
+export const exchangeProviderToken = (
+ provider: string,
+ authentication: DelegatedAuthentication,
+ principal: NormalizedPrincipal,
+ subjectToken: string,
+ secretStore: ProviderSecretStore | undefined,
+ signal?: AbortSignal,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const exchange = authentication.token_exchange;
+ if (!exchange) return subjectToken;
+ const key = exchangeKey(provider, principal, subjectToken, authentication);
+ const cached = cache.get(key);
+ if (cached && Date.now() < cached.expiresAt - 60_000) return cached.token;
+ let deferred = pending.get(key);
+ if (!deferred) {
+ deferred = Deferred.makeUnsafe();
+ pending.set(key, deferred);
+ const active = deferred;
+ yield* Effect.gen(function* () {
+ const url = yield* endpoint(provider, principal, exchange.token_endpoint);
+ const clientSecret = exchange.client_secret_ref
+ ? yield* secretStore
+ ? secretStore
+ .read(exchange.client_secret_ref)
+ .pipe(Effect.mapError(() => failure(provider, "credential_unavailable")))
+ : Effect.fail(failure(provider, "credential_unavailable"))
+ : undefined;
+ if (exchange.client_secret_ref && !clientSecret) {
+ return yield* Effect.fail(failure(provider, "credential_unavailable"));
+ }
+ const response = yield* Effect.tryPromise({
+ try: () =>
+ fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: requestBody(authentication, subjectToken, clientSecret),
+ signal: AbortSignal.timeout(10_000),
+ redirect: "error",
+ }),
+ catch: () => failure(provider, "exchange_failed"),
+ });
+ if (!response.ok) return yield* Effect.fail(failure(provider, "exchange_failed"));
+ const declaredLength = Number(response.headers.get("content-length") ?? 0);
+ if (Number.isFinite(declaredLength) && declaredLength > 65_536) {
+ return yield* Effect.fail(failure(provider, "response_invalid"));
+ }
+ const responseText = yield* Effect.tryPromise({
+ try: () => response.text(),
+ catch: () => failure(provider, "response_invalid"),
+ });
+ if (responseText.length > 65_536) {
+ return yield* Effect.fail(failure(provider, "response_invalid"));
+ }
+ const payload = yield* Effect.try({
+ try: () => JSON.parse(responseText),
+ catch: () => failure(provider, "response_invalid"),
+ });
+ const decoded = yield* Schema.decodeUnknownEffect(TokenResponseSchema)(payload).pipe(
+ Effect.mapError(() => failure(provider, "response_invalid")),
+ );
+ if (decoded.token_type && decoded.token_type.toLowerCase() !== "bearer") {
+ return yield* Effect.fail(failure(provider, "response_invalid"));
+ }
+ if (!decoded.access_token || decoded.access_token.length > 65_536) {
+ return yield* Effect.fail(failure(provider, "response_invalid"));
+ }
+ const expiresIn = Number(decoded.expires_in);
+ const leaseSeconds =
+ Number.isFinite(expiresIn) && expiresIn > 0
+ ? Math.min(Math.floor(expiresIn), 86_400)
+ : 300;
+ const lease = {
+ token: decoded.access_token,
+ expiresAt: Date.now() + leaseSeconds * 1000,
+ };
+ cacheLease(key, lease);
+ return lease;
+ }).pipe(
+ Effect.tap((lease) => Deferred.succeed(active, lease)),
+ Effect.tapError((error) => Deferred.fail(active, error)),
+ Effect.ensuring(
+ Effect.sync(() => {
+ pending.delete(key);
+ }),
+ ),
+ Effect.forkDetach({ startImmediately: true }),
+ );
+ }
+ return (yield* waitForLease(provider, deferred, signal)).token;
+ });
+
+const clientCredentialsKey = (
+ provider: string,
+ tokenEndpoint: string,
+ clientId: string,
+ scope: string,
+ audience: string,
+): string =>
+ createHash("sha256")
+ .update(["client_credentials", provider, tokenEndpoint, clientId, scope, audience].join("\0"))
+ .digest("hex");
+
+const clientCredentialsRequestBody = (
+ clientId: string,
+ clientSecret: string,
+ scope: string,
+): URLSearchParams =>
+ new URLSearchParams({
+ grant_type: "client_credentials",
+ client_id: clientId,
+ client_secret: clientSecret,
+ scope,
+ });
+
+export const clientCredentialsToken = (
+ provider: string,
+ authentication: Extract,
+ clientSecret: string,
+ signal?: AbortSignal,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const scope = authentication.scopes.join(" ");
+ const key = clientCredentialsKey(
+ provider,
+ authentication.token_endpoint,
+ authentication.client_id,
+ scope,
+ authentication.audience,
+ );
+ const cached = cache.get(key);
+ if (cached && Date.now() < cached.expiresAt - 60_000) return cached.token;
+ let deferred = pending.get(key);
+ if (!deferred) {
+ deferred = Deferred.makeUnsafe();
+ pending.set(key, deferred);
+ const active = deferred;
+ yield* Effect.gen(function* () {
+ const url = new URL(authentication.token_endpoint);
+ const response = yield* Effect.tryPromise({
+ try: () =>
+ fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: clientCredentialsRequestBody(
+ authentication.client_id,
+ clientSecret,
+ scope,
+ ),
+ signal: AbortSignal.timeout(10_000),
+ redirect: "error",
+ }),
+ catch: () => failure(provider, "exchange_failed"),
+ });
+ if (!response.ok) return yield* Effect.fail(failure(provider, "exchange_failed"));
+ const declaredLength = Number(response.headers.get("content-length") ?? 0);
+ if (Number.isFinite(declaredLength) && declaredLength > 65_536) {
+ return yield* Effect.fail(failure(provider, "response_invalid"));
+ }
+ const responseText = yield* Effect.tryPromise({
+ try: () => response.text(),
+ catch: () => failure(provider, "response_invalid"),
+ });
+ if (responseText.length > 65_536) {
+ return yield* Effect.fail(failure(provider, "response_invalid"));
+ }
+ const payload = yield* Effect.try({
+ try: () => JSON.parse(responseText),
+ catch: () => failure(provider, "response_invalid"),
+ });
+ const decoded = yield* Schema.decodeUnknownEffect(TokenResponseSchema)(payload).pipe(
+ Effect.mapError(() => failure(provider, "response_invalid")),
+ );
+ if (decoded.token_type && decoded.token_type.toLowerCase() !== "bearer") {
+ return yield* Effect.fail(failure(provider, "response_invalid"));
+ }
+ if (!decoded.access_token || decoded.access_token.length > 65_536) {
+ return yield* Effect.fail(failure(provider, "response_invalid"));
+ }
+ const expiresIn = Number(decoded.expires_in);
+ const leaseSeconds =
+ Number.isFinite(expiresIn) && expiresIn > 0
+ ? Math.min(Math.floor(expiresIn), 86_400)
+ : 300;
+ const lease = { token: decoded.access_token, expiresAt: Date.now() + leaseSeconds * 1000 };
+ cacheLease(key, lease);
+ return lease;
+ }).pipe(
+ Effect.tap((lease) => Deferred.succeed(active, lease)),
+ Effect.tapError((error) => Deferred.fail(active, error)),
+ Effect.ensuring(
+ Effect.sync(() => {
+ pending.delete(key);
+ }),
+ ),
+ Effect.forkDetach({ startImmediately: true }),
+ );
+ }
+ return (yield* waitForLease(provider, deferred, signal)).token;
+ });
diff --git a/controller/tests/apim-policy-contract.test.ts b/controller/tests/apim-policy-contract.test.ts
new file mode 100644
index 000000000..1133e839d
--- /dev/null
+++ b/controller/tests/apim-policy-contract.test.ts
@@ -0,0 +1,223 @@
+import { describe, expect, test } from "bun:test";
+import { existsSync, readFileSync, readdirSync } from "node:fs";
+import { join } from "node:path";
+
+const root = join(import.meta.dir, "..", "..");
+const policy = readFileSync(join(root, "deploy/azure/apim/policy.xml"), "utf8");
+const openapi = readFileSync(join(root, "deploy/azure/apim/api.openapi.yaml"), "utf8");
+const parameters = JSON.parse(
+ readFileSync(join(root, "deploy/azure/apim/parameters.example.json"), "utf8"),
+) as Record;
+const diagnostics = JSON.parse(
+ readFileSync(join(root, "deploy/azure/apim/diagnostics.example.json"), "utf8"),
+) as Record;
+const bicep = readFileSync(join(root, "deploy/azure/apim/infra/main.bicep"), "utf8");
+const roleModules = ["cognitive-role-assignment.bicep", "key-vault-role-assignment.bicep"]
+ .map((name) => readFileSync(join(root, "deploy/azure/apim/infra/modules", name), "utf8"))
+ .join("\n");
+const deploymentParameters = JSON.parse(
+ readFileSync(join(root, "deploy/azure/apim/infra/main.parameters.example.json"), "utf8"),
+) as { parameters: { keyVaultNamedValues: { value: Record } } };
+const parameterSchema = JSON.parse(
+ readFileSync(join(root, "deploy/azure/apim/parameters.schema.json"), "utf8"),
+) as { properties: { parameters: { required: string[] } } };
+const scriptsDirectory = join(root, "deploy/azure/apim/scripts");
+const previewDirectory = join(root, "deploy/azure/apim-preview");
+
+describe("standard APIM Foundry package", () => {
+ test("declares exactly the stable public operations", () => {
+ expect(openapi.match(/operationId:/gu)?.length).toBe(5);
+ for (const operation of [
+ "models-list",
+ "chat-completions",
+ "responses-create",
+ "agents-list",
+ "agent-invoke",
+ ]) {
+ expect(openapi).toContain(`operationId: ${operation}`);
+ expect(policy).toContain(`context.Operation.Id == "${operation}"`);
+ }
+ });
+
+ test("cryptographically validates both issuers before claim authorization", () => {
+ expect(policy).toContain(" {
+ for (const control of [
+ "rate-limit-by-key",
+ "validate-content",
+ "llm-content-safety",
+ "llm-token-limit",
+ "llm-emit-token-metric",
+ "{{allowed-models}}",
+ "{{allowed-agents}}",
+ '');
+ expect(policy).not.toContain(""),
+ );
+ for (const reason of ["tenant", "clearance", "role", "model", "agent", "operation"]) {
+ expect(policy).toContain(`denied reason=${reason} correlation=`);
+ }
+ expect(policy.match(/severity="warning"/gu)?.length).toBe(6);
+ expect(policy.match(/name="x-correlation-id" exists-action="override"/gu)?.length).toBe(9);
+ });
+
+ test("removes caller credentials and routes through the managed identity backend", () => {
+ for (const route of [
+ "/openai/v1/models",
+ "/openai/v1/chat/completions",
+ "/openai/v1/responses",
+ "/agents",
+ ]) {
+ expect(policy).toContain(`template="${route}"`);
+ }
+ expect(policy).toContain("v1");
+ const managedIdentityIndex = policy.indexOf(" {
+ for (const name of [
+ "accepted-tenant",
+ "allowed-agents",
+ "allowed-models",
+ "apim-api-audience",
+ "foundry-project-endpoint",
+ "request-max-bytes",
+ "token-quota-per-minute",
+ ]) {
+ expect(parameters[name]).toBeTruthy();
+ }
+ expect(deploymentParameters.parameters.keyVaultNamedValues.value).toEqual({});
+ const serializedDiagnostics = JSON.stringify(diagnostics);
+ expect(serializedDiagnostics).not.toContain("authorization");
+ expect(serializedDiagnostics).not.toContain("api-key");
+ expect(serializedDiagnostics).not.toContain("request-body");
+ });
+
+ test("deploys an immutable standard APIM revision and its dependencies", () => {
+ for (const resource of [
+ "Microsoft.ApiManagement/service/apis@2024-05-01",
+ "Microsoft.ApiManagement/service/namedValues@2024-05-01",
+ "Microsoft.ApiManagement/service/backends@2024-05-01",
+ "Microsoft.ApiManagement/service/apis/policies@2024-05-01",
+ "Microsoft.ApiManagement/service/apis/diagnostics@2024-05-01",
+ "Microsoft.Authorization/roleAssignments@2022-04-01",
+ ]) {
+ expect(`${bicep}\n${roleModules}`).toContain(resource);
+ }
+ expect(bicep).toContain("var apiName = '${apiId};rev=${apiRevision}'");
+ expect(bicep).toContain("name: apiName");
+ expect(bicep).toContain("param bootstrapRevision bool = false");
+ expect(bicep).toContain("isCurrent: bootstrapRevision");
+ expect(bicep).toContain("apim.identity.principalId");
+ expect(bicep).toContain("scheme: 'ManagedIdentity'");
+ expect(bicep).toContain("var foundryBackendId = '${snapshotPrefix}foundry'");
+ expect(bicep).toContain("name: '${snapshotPrefix}${item.key}'");
+ expect(bicep).toContain("value: apiPolicySnapshot");
+ expect(bicep).toContain("url: string(namedValues['foundry-project-endpoint'])");
+ expect(bicep).toContain("keyVault: {");
+ expect(bicep).toContain("secretIdentifier: string(item.value)");
+ expect(readFileSync(join(scriptsDirectory, "validate.mjs"), "utf8")).toContain(
+ "must use an unversioned Azure secret URL",
+ );
+ });
+
+ test("binds the APIM identity to the narrow deployment roles", () => {
+ for (const role of [
+ "53ca6127-db72-4b80-b1b0-d745d6d5456d",
+ "a97b65f3-24c7-4388-baec-2e87135dc908",
+ "4633458b-17de-408a-b874-0445c86b69e6",
+ ]) {
+ expect(bicep).toContain(role);
+ }
+ expect(bicep).not.toContain("Owner");
+ expect(bicep).not.toContain("Contributor");
+ });
+
+ test("ships explicit deployment, validation, promotion, and rollback commands", () => {
+ for (const script of [
+ "deploy.sh",
+ "enable-system-identity.sh",
+ "preflight-azure.sh",
+ "prove-revision-isolation.mjs",
+ "promote-revision.sh",
+ "rollback-revision.sh",
+ "validate-azure.sh",
+ "validate-rollback.mjs",
+ "validate.mjs",
+ ]) {
+ expect(existsSync(join(scriptsDirectory, script))).toBe(true);
+ }
+ const promotion = readFileSync(join(scriptsDirectory, "promote-revision.sh"), "utf8");
+ const rollback = readFileSync(join(scriptsDirectory, "rollback-revision.sh"), "utf8");
+ expect(promotion).toContain("az apim api release create");
+ expect(promotion).toContain("--api-revision");
+ expect(promotion).toContain("is already current");
+ expect(rollback).toContain("rollback-manifest");
+ expect(rollback).toContain("validate-rollback.mjs");
+ expect(parameterSchema.properties.parameters.required).toContain("namedValues");
+ expect(readFileSync(join(scriptsDirectory, "preflight-azure.sh"), "utf8")).toContain(
+ "bootstrapRevision=true",
+ );
+ });
+
+ test("keeps the preview profile non-deployable", () => {
+ const deployables = readdirSync(previewDirectory).filter((name) =>
+ /\.(bicep|bicepparam|json|sh|mjs)$/u.test(name),
+ );
+ expect(deployables).toEqual([]);
+ });
+});
diff --git a/controller/tests/config-env.test.ts b/controller/tests/config-env.test.ts
new file mode 100644
index 000000000..3b1638bd9
--- /dev/null
+++ b/controller/tests/config-env.test.ts
@@ -0,0 +1,58 @@
+import { afterEach, expect, test } from "bun:test";
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { createConfig } from "../src/config/env";
+
+const roots: string[] = [];
+const keys = [
+ "LOCAL_STUDIO_DATA_DIR",
+ "LOCAL_STUDIO_NOTEBOOK_ROOT",
+ "LOCAL_STUDIO_NOTEBOOK_PYTHON",
+ "LOCAL_STUDIO_NOTEBOOK_SMOLVM",
+ "LOCAL_STUDIO_NOTEBOOK_NODE_IMAGE",
+ "LOCAL_STUDIO_NOTEBOOK_PYTHON_IMAGE",
+] as const;
+const original = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
+
+afterEach(async () => {
+ for (const key of keys) {
+ const value = original[key];
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ }
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
+});
+
+test("notebook environment resolves paths and trims runtime commands", async () => {
+ const root = await mkdtemp(join(tmpdir(), "local-studio-config-"));
+ roots.push(root);
+ process.env["LOCAL_STUDIO_DATA_DIR"] = root;
+ process.env["LOCAL_STUDIO_NOTEBOOK_ROOT"] = "./governed-notebooks";
+ process.env["LOCAL_STUDIO_NOTEBOOK_PYTHON"] = " /opt/notebook/python ";
+ process.env["LOCAL_STUDIO_NOTEBOOK_SMOLVM"] = " /opt/notebook/smolvm ";
+ process.env["LOCAL_STUDIO_NOTEBOOK_NODE_IMAGE"] = " node:22@sha256:node ";
+ process.env["LOCAL_STUDIO_NOTEBOOK_PYTHON_IMAGE"] = " /opt/notebook/python.tar@sha256:python ";
+
+ const config = createConfig();
+
+ expect(config.notebook_root).toBe(resolve("./governed-notebooks"));
+ expect(config.notebook_python).toBe("/opt/notebook/python");
+ expect(config.notebook_smolvm).toBe("/opt/notebook/smolvm");
+ expect(config.notebook_node_image).toBe("node:22@sha256:node");
+ expect(config.notebook_python_image).toBe("/opt/notebook/python.tar@sha256:python");
+});
+
+test("notebook defaults stay rooted in the controller data directory", async () => {
+ const root = await mkdtemp(join(tmpdir(), "local-studio-config-"));
+ roots.push(root);
+ process.env["LOCAL_STUDIO_DATA_DIR"] = root;
+ for (const key of keys.slice(1)) delete process.env[key];
+
+ const config = createConfig();
+
+ expect(config.notebook_root).toBe(join(root, "notebooks"));
+ expect(config.notebook_smolvm).toBe("smolvm");
+ expect(config.notebook_node_image).toBe(join(root, "node-notebook-image.tar"));
+ expect(config.notebook_python_image).toBe(join(root, "python-notebook-image.tar"));
+});
diff --git a/controller/tests/environment-config.test.ts b/controller/tests/environment-config.test.ts
new file mode 100644
index 000000000..1c88fa047
--- /dev/null
+++ b/controller/tests/environment-config.test.ts
@@ -0,0 +1,82 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { createConfig } from "../src/config/env";
+import { savePersistedConfig } from "../src/config/persisted-config";
+
+const keys = [
+ "LOCAL_STUDIO_DATA_DIR",
+ "LOCAL_STUDIO_KUBERAY_API_URL",
+ "LOCAL_STUDIO_KUBERAY_TOKEN_FILE",
+ "LOCAL_STUDIO_KUBERAY_CA_FILE",
+] as const;
+const original = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
+const directories: string[] = [];
+
+afterEach(() => {
+ for (const key of keys) {
+ const value = original[key];
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ }
+ for (const directory of directories.splice(0)) {
+ rmSync(directory, { recursive: true, force: true });
+ }
+});
+
+const temporaryDirectory = (): string => {
+ const directory = mkdtempSync(join(tmpdir(), "environment-config-"));
+ directories.push(directory);
+ return directory;
+};
+
+describe("environment Kubernetes configuration", () => {
+ test("preserves trusted environment credentials while validating the endpoint", () => {
+ const directory = temporaryDirectory();
+ const trustedToken = join(directory, "trusted-environment.token");
+ writeFileSync(trustedToken, "workload-token", { mode: 0o600 });
+ process.env["LOCAL_STUDIO_DATA_DIR"] = directory;
+ process.env["LOCAL_STUDIO_KUBERAY_API_URL"] = " https://cluster.internal:6443/ ";
+ process.env["LOCAL_STUDIO_KUBERAY_TOKEN_FILE"] = trustedToken;
+
+ const config = createConfig();
+
+ expect(config.kuberay_api_url).toBe("https://cluster.internal:6443");
+ expect(config.kuberay_token_file).toBe(resolve(trustedToken));
+ });
+
+ test("loads a persisted controller credential reference after restart", () => {
+ const directory = temporaryDirectory();
+ const credentialRoot = join(directory, "credentials");
+ const tokenFile = join(credentialRoot, "cluster.token");
+ mkdirSync(credentialRoot, { recursive: true });
+ writeFileSync(tokenFile, "workload-token", { mode: 0o600 });
+ savePersistedConfig(directory, {
+ kubernetes_connection: {
+ enabled: true,
+ api_url: "https://cluster.internal",
+ token_file: "controller:cluster.token",
+ ca_file: null,
+ },
+ });
+ process.env["LOCAL_STUDIO_DATA_DIR"] = directory;
+ delete process.env["LOCAL_STUDIO_KUBERAY_API_URL"];
+ delete process.env["LOCAL_STUDIO_KUBERAY_TOKEN_FILE"];
+ delete process.env["LOCAL_STUDIO_KUBERAY_CA_FILE"];
+
+ const config = createConfig();
+
+ expect(config.kuberay_api_url).toBe("https://cluster.internal");
+ expect(config.kuberay_token_file).toBe(realpathSync(tokenFile));
+ });
+
+ test("fails loudly on secret-bearing environment endpoints", () => {
+ const directory = temporaryDirectory();
+ process.env["LOCAL_STUDIO_DATA_DIR"] = directory;
+ process.env["LOCAL_STUDIO_KUBERAY_API_URL"] =
+ "https://operator:secret@cluster.internal";
+
+ expect(() => createConfig()).toThrow("must not contain user information");
+ });
+});
diff --git a/controller/tests/environment-routes.test.ts b/controller/tests/environment-routes.test.ts
new file mode 100644
index 000000000..8e230436f
--- /dev/null
+++ b/controller/tests/environment-routes.test.ts
@@ -0,0 +1,403 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import {
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ readdirSync,
+ realpathSync,
+ rmSync,
+ symlinkSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { Effect, Layer, ManagedRuntime } from "effect";
+import { Hono } from "hono";
+import type { AppContext } from "../src/app-context";
+import { loadPersistedConfig } from "../src/config/persisted-config";
+import type { ControllerRuntime } from "../src/core/effect-runtime";
+import { isHttpStatus, serviceUnavailable } from "../src/core/errors";
+import {
+ controllerRuntimeMiddleware,
+ type ControllerEnvironment,
+} from "../src/http/effect-handler";
+import { createMutatingAuthMiddleware } from "../src/http/security-middleware";
+import { registerEnvironmentRoutes } from "../src/modules/environment/routes";
+import { prepareKubernetesConnection } from "../src/modules/environment/configuration";
+import type { KubeRayGateway } from "../src/modules/workbench/kuberay-gateway";
+
+const directories: string[] = [];
+
+const temporaryDirectory = (): string => {
+ const directory = mkdtempSync(join(tmpdir(), "environment-routes-"));
+ directories.push(directory);
+ return directory;
+};
+
+afterEach(() => {
+ for (const directory of directories.splice(0)) {
+ rmSync(directory, { recursive: true, force: true });
+ }
+});
+
+const makeApp = (
+ directory: string,
+ gateway: KubeRayGateway | null = null,
+) => {
+ const runtime = ManagedRuntime.make(Layer.empty) as unknown as ControllerRuntime;
+ const context = {
+ config: {
+ api_key: "test-api-key",
+ data_dir: directory,
+ },
+ kubeRayGateway: gateway,
+ } as unknown as AppContext;
+ const app = new Hono();
+ app.use("*", controllerRuntimeMiddleware(runtime));
+ app.use("*", createMutatingAuthMiddleware(context));
+ registerEnvironmentRoutes(app, context);
+ app.onError((error, ctx) =>
+ isHttpStatus(error)
+ ? ctx.json({ detail: error.detail }, error.status as 400 | 503)
+ : ctx.json({ detail: "Internal Server Error" }, 500),
+ );
+ return { app, context, runtime };
+};
+
+const request = (
+ app: Hono,
+ path: string,
+ method = "GET",
+ body?: unknown,
+) =>
+ app.request(path, {
+ method,
+ headers: {
+ Authorization: "Bearer test-api-key",
+ ...(body === undefined ? {} : { "Content-Type": "application/json" }),
+ },
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+ });
+
+describe("environment routes", () => {
+ test("enforces controller authentication over a live HTTP listener", async () => {
+ const directory = temporaryDirectory();
+ const { app, runtime } = makeApp(directory);
+ const server = Bun.serve({ port: 0, fetch: app.fetch });
+ const endpoint = `http://127.0.0.1:${server.port}/environment/kubernetes`;
+
+ try {
+ const unauthorized = await fetch(endpoint);
+ const authorized = await fetch(endpoint, {
+ headers: { Authorization: "Bearer test-api-key" },
+ });
+ const unsafe = await fetch(endpoint, {
+ method: "PUT",
+ headers: {
+ Authorization: "Bearer test-api-key",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ enabled: true,
+ api_url: "https://attacker.example",
+ token_file: "/etc/hosts",
+ ca_file: null,
+ }),
+ });
+
+ expect(unauthorized.status).toBe(401);
+ expect(authorized.status).toBe(200);
+ expect(unsafe.status).toBe(400);
+ expect((await unsafe.json() as { detail: string }).detail).not.toContain("/etc/hosts");
+ } finally {
+ server.stop(true);
+ await runtime.dispose();
+ }
+ });
+
+ test("serves unconfigured evidence and canonicalizes a disabled round trip", async () => {
+ const directory = temporaryDirectory();
+ const { app, context, runtime } = makeApp(directory);
+
+ const initial = await request(app, "/environment/kubernetes");
+ const initialProbe = await request(app, "/environment/kubernetes/probe", "POST");
+ const disabled = await request(app, "/environment/kubernetes", "PUT", {
+ enabled: false,
+ api_url: "https://ignored.example",
+ token_file: "/ignored/token",
+ ca_file: "/ignored/ca",
+ });
+ const body = await disabled.json() as {
+ configuration: {
+ enabled: boolean;
+ api_url: string;
+ token_file: string;
+ ca_file: string | null;
+ };
+ probe: { state: string };
+ };
+
+ expect(initial.status).toBe(200);
+ expect((await initial.json() as { probe: { state: string } }).probe.state).toBe(
+ "unconfigured",
+ );
+ expect(initialProbe.status).toBe(200);
+ expect((await initialProbe.json() as { probe: { state: string } }).probe.state).toBe(
+ "unconfigured",
+ );
+ expect(disabled.status).toBe(200);
+ expect(body.configuration).toEqual({
+ enabled: false,
+ api_url: "",
+ token_file: "",
+ ca_file: null,
+ });
+ expect(body.probe.state).toBe("unconfigured");
+ expect(context.kubeRayGateway).toBeNull();
+ expect(loadPersistedConfig(directory).kubernetes_connection?.enabled).toBe(false);
+ await runtime.dispose();
+ });
+
+ test("commissions safe references and replaces the live gateway after persistence", async () => {
+ const directory = temporaryDirectory();
+ const credentialRoot = join(directory, "credentials");
+ const tokenFile = join(credentialRoot, "cluster.token");
+ const caFile = join(credentialRoot, "cluster.ca");
+ mkdirSync(credentialRoot, { recursive: true });
+ writeFileSync(tokenFile, "workload-token", { mode: 0o600 });
+ writeFileSync(caFile, "certificate", { mode: 0o644 });
+ const { app, context, runtime } = makeApp(directory);
+
+ const response = await request(app, "/environment/kubernetes", "PUT", {
+ enabled: true,
+ api_url: "https://cluster.internal:6443",
+ token_file: tokenFile,
+ ca_file: caFile,
+ });
+ const body = await response.json() as {
+ configuration: { token_file: string; ca_file: string | null };
+ };
+
+ expect(response.status).toBe(200);
+ expect(body.configuration.token_file).toBe("controller:cluster.token");
+ expect(body.configuration.ca_file).toBe("controller:cluster.ca");
+ expect(JSON.stringify(body)).not.toContain(directory);
+ expect(context.kubeRayGateway).not.toBeNull();
+ expect(context.config.kuberay_token_file).toBe(realpathSync(tokenFile));
+ expect(loadPersistedConfig(directory).kubernetes_connection?.token_file).toBe(
+ "controller:cluster.token",
+ );
+ const restarted = prepareKubernetesConnection(
+ loadPersistedConfig(directory).kubernetes_connection!,
+ directory,
+ );
+ expect(restarted.runtime.token_file).toBe(realpathSync(tokenFile));
+ expect(restarted.response.token_file).toBe("controller:cluster.token");
+ expect(readFileSync(join(directory, "studio-settings.json"), "utf8")).not.toContain(tokenFile);
+ await runtime.dispose();
+ });
+
+ test("rejects arbitrary local files and ambiguous Kubernetes URLs", async () => {
+ const directory = temporaryDirectory();
+ const credentialRoot = join(directory, "credentials");
+ const tokenFile = join(credentialRoot, "cluster.token");
+ mkdirSync(credentialRoot, { recursive: true });
+ writeFileSync(tokenFile, "workload-token", { mode: 0o600 });
+ const { app, context, runtime } = makeApp(directory);
+
+ const arbitraryFile = await request(app, "/environment/kubernetes", "PUT", {
+ enabled: true,
+ api_url: "https://attacker.example",
+ token_file: "/etc/hosts",
+ ca_file: null,
+ });
+ const userInfo = await request(app, "/environment/kubernetes", "PUT", {
+ enabled: true,
+ api_url: "https://user:secret@cluster.internal",
+ token_file: tokenFile,
+ ca_file: null,
+ });
+ const basePath = await request(app, "/environment/kubernetes", "PUT", {
+ enabled: true,
+ api_url: "https://cluster.internal/proxy?target=metadata",
+ token_file: tokenFile,
+ ca_file: null,
+ });
+
+ expect(arbitraryFile.status).toBe(400);
+ expect(userInfo.status).toBe(400);
+ expect(basePath.status).toBe(400);
+ expect(context.kubeRayGateway).toBeNull();
+ expect(loadPersistedConfig(directory).kubernetes_connection).toBeUndefined();
+ await runtime.dispose();
+ });
+
+ test("rejects permissive controller tokens and symbolic-link escapes", async () => {
+ const directory = temporaryDirectory();
+ const credentialRoot = join(directory, "credentials");
+ const permissiveToken = join(credentialRoot, "permissive.token");
+ const escapedToken = join(credentialRoot, "escaped.token");
+ mkdirSync(credentialRoot, { recursive: true });
+ writeFileSync(permissiveToken, "workload-token", { mode: 0o644 });
+ symlinkSync("/etc/hosts", escapedToken);
+ const { app, runtime } = makeApp(directory);
+
+ const permissive = await request(app, "/environment/kubernetes", "PUT", {
+ enabled: true,
+ api_url: "https://cluster.internal",
+ token_file: permissiveToken,
+ ca_file: null,
+ });
+ const escaped = await request(app, "/environment/kubernetes", "PUT", {
+ enabled: true,
+ api_url: "https://cluster.internal",
+ token_file: escapedToken,
+ ca_file: null,
+ });
+
+ expect(permissive.status).toBe(400);
+ expect(escaped.status).toBe(400);
+ await runtime.dispose();
+ });
+
+ test("returns typed contradicted evidence without exposing probe failures", async () => {
+ const directory = temporaryDirectory();
+ const gateway = {
+ probe: () => Effect.fail(serviceUnavailable("connect ECONNREFUSED /private/token")),
+ } as unknown as KubeRayGateway;
+ const { app, context, runtime } = makeApp(directory, gateway);
+ context.config.kuberay_api_url = "https://cluster.internal";
+ context.config.kuberay_token_file = "/private/token";
+
+ const response = await request(app, "/environment/kubernetes/probe", "POST");
+ const body = await response.json() as {
+ configuration: { token_file: string };
+ probe: { state: string; detail: string };
+ };
+
+ expect(response.status).toBe(200);
+ expect(body.probe.state).toBe("contradicted");
+ expect(body.probe.detail).not.toContain("ECONNREFUSED");
+ expect(body.configuration.token_file).toBe("existing:token");
+ expect(JSON.stringify(body)).not.toContain("/private/token");
+ await runtime.dispose();
+ });
+
+ test("preserves trusted environment credentials only for their existing endpoint", async () => {
+ const directory = temporaryDirectory();
+ const gateway = {
+ probe: () => Effect.fail(serviceUnavailable("not used")),
+ } as unknown as KubeRayGateway;
+ const { app, context, runtime } = makeApp(directory, gateway);
+ context.config.kuberay_api_url = "https://cluster.internal";
+ context.config.kuberay_token_file = "/trusted/environment/token";
+
+ const preserved = await request(app, "/environment/kubernetes", "PUT", {
+ enabled: true,
+ api_url: "https://cluster.internal",
+ token_file: "existing:token",
+ ca_file: null,
+ });
+ const redirected = await request(app, "/environment/kubernetes", "PUT", {
+ enabled: true,
+ api_url: "https://attacker.example",
+ token_file: "existing:token",
+ ca_file: null,
+ });
+
+ expect(preserved.status).toBe(200);
+ expect(redirected.status).toBe(400);
+ expect(context.config.kuberay_api_url).toBe("https://cluster.internal");
+ await runtime.dispose();
+ });
+
+ test("keeps the live gateway unchanged when persistence fails", async () => {
+ const directory = temporaryDirectory();
+ const credentialRoot = join(directory, "credentials");
+ const tokenFile = join(credentialRoot, "cluster.token");
+ mkdirSync(credentialRoot, { recursive: true });
+ writeFileSync(tokenFile, "workload-token", { mode: 0o600 });
+ mkdirSync(join(directory, "studio-settings.json"));
+ const originalGateway = {
+ probe: () => Effect.fail(serviceUnavailable("original gateway")),
+ } as unknown as KubeRayGateway;
+ const { app, context, runtime } = makeApp(directory, originalGateway);
+
+ const response = await request(app, "/environment/kubernetes", "PUT", {
+ enabled: true,
+ api_url: "https://cluster.internal",
+ token_file: tokenFile,
+ ca_file: null,
+ });
+
+ expect(response.status).toBe(503);
+ expect(context.kubeRayGateway).toBe(originalGateway);
+ expect(context.config.kuberay_api_url).toBeUndefined();
+ expect(context.config.kuberay_token_file).toBeUndefined();
+ expect(readdirSync(directory).some((entry) => entry.includes(".tmp-"))).toBe(false);
+ await runtime.dispose();
+ });
+
+ test("probes a protocol-faithful Kubernetes and Ray discovery fixture", async () => {
+ const directory = temporaryDirectory();
+ const credentialRoot = join(directory, "credentials");
+ const tokenFile = join(credentialRoot, "cluster.token");
+ mkdirSync(credentialRoot, { recursive: true });
+ writeFileSync(tokenFile, "fixture-workload-token", { mode: 0o600 });
+ const observedPaths: string[] = [];
+ const cluster = Bun.serve({
+ port: 0,
+ fetch: (incoming) => {
+ const url = new URL(incoming.url);
+ observedPaths.push(url.pathname);
+ if (incoming.headers.get("authorization") !== "Bearer fixture-workload-token") {
+ return Response.json({ message: "unauthorized" }, { status: 401 });
+ }
+ if (url.pathname === "/version") {
+ return Response.json({ gitVersion: "v1.33.1" });
+ }
+ if (url.pathname === "/apis/ray.io/v1") {
+ return Response.json({
+ groupVersion: "ray.io/v1",
+ resources: [{ name: "rayjobs", verbs: ["get", "list", "patch"] }],
+ });
+ }
+ return Response.json({ message: "not found" }, { status: 404 });
+ },
+ });
+ const { app, runtime } = makeApp(directory);
+
+ try {
+ const configured = await request(app, "/environment/kubernetes", "PUT", {
+ enabled: true,
+ api_url: `http://127.0.0.1:${cluster.port}`,
+ token_file: tokenFile,
+ ca_file: null,
+ });
+ const probe = await request(app, "/environment/kubernetes/probe", "POST");
+ const body = await probe.json() as {
+ configuration: { token_file: string };
+ probe: {
+ state: string;
+ kubernetes_version: string | null;
+ ray_api_version: string | null;
+ };
+ };
+
+ expect(configured.status).toBe(200);
+ expect(probe.status).toBe(200);
+ expect(body.probe).toMatchObject({
+ state: "observed",
+ kubernetes_version: "v1.33.1",
+ ray_api_version: "ray.io/v1",
+ });
+ expect(body.configuration.token_file).toBe("controller:cluster.token");
+ expect(JSON.stringify(body)).not.toContain("fixture-workload-token");
+ expect(JSON.stringify(body)).not.toContain(directory);
+ expect(observedPaths.sort()).toEqual(["/apis/ray.io/v1", "/version"]);
+ } finally {
+ cluster.stop(true);
+ await runtime.dispose();
+ }
+ });
+});
diff --git a/controller/tests/fixtures/smolvm-notebook-fixture.mjs b/controller/tests/fixtures/smolvm-notebook-fixture.mjs
new file mode 100755
index 000000000..b8420c0ea
--- /dev/null
+++ b/controller/tests/fixtures/smolvm-notebook-fixture.mjs
@@ -0,0 +1,41 @@
+#!/usr/bin/env node
+import { readFile, stat, writeFile } from "node:fs/promises";
+import path from "node:path";
+
+if (process.env.SMOLVM_FIXTURE_ARGS) {
+ await writeFile(process.env.SMOLVM_FIXTURE_ARGS, JSON.stringify(process.argv.slice(2)));
+}
+const volume = process.argv[process.argv.indexOf("--volume") + 1];
+const scratch = volume.slice(0, volume.lastIndexOf(":/workspace"));
+const request = JSON.parse(await readFile(path.join(scratch, "request.json"), "utf8"));
+const notebookPath = path.join(scratch, path.basename(request.path));
+const [scratchMode, notebookMode, requestMode] = await Promise.all([
+ stat(scratch),
+ stat(notebookPath),
+ stat(path.join(scratch, "request.json")),
+]);
+if (
+ (scratchMode.mode & 0o005) !== 0o005 ||
+ (notebookMode.mode & 0o006) !== 0o006 ||
+ (requestMode.mode & 0o004) !== 0o004
+) {
+ throw new Error("staged notebook permissions do not support the unprivileged guest");
+}
+const notebook = JSON.parse(await readFile(notebookPath, "utf8"));
+notebook.cells[0].execution_count = 1;
+notebook.cells[0].outputs = [{ output_type: "stream", text: "python-sandbox\n" }];
+await writeFile(notebookPath, `${JSON.stringify(notebook)}\n`);
+process.stdout.write(
+ JSON.stringify({
+ kernel_name: "python3",
+ cells: [
+ {
+ index: 0,
+ cell_type: "code",
+ source: "print('python-sandbox')",
+ execution_count: 1,
+ outputs: [{ type: "stream", text: "python-sandbox\n" }],
+ },
+ ],
+ }),
+);
diff --git a/controller/tests/foundry-adapter.test.ts b/controller/tests/foundry-adapter.test.ts
new file mode 100644
index 000000000..576be3b6e
--- /dev/null
+++ b/controller/tests/foundry-adapter.test.ts
@@ -0,0 +1,227 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import type { NormalizedPrincipal } from "@local-studio/contracts/enterprise-auth";
+import { Effect, Schema } from "effect";
+import type { ProviderConfig } from "../src/config/persisted-config";
+import { HttpStatus } from "../src/core/errors";
+import {
+ FOUNDRY_REQUEST_LIMIT_BYTES,
+ enforceFoundryPrincipal,
+ fetchFoundryCatalog,
+ readFoundryRequest,
+ requestFoundryGateway,
+ usageFromHeaders,
+} from "../src/modules/foundry/adapter";
+
+const servers: Array> = [];
+
+afterEach(() => {
+ for (const server of servers.splice(0)) server.stop(true);
+});
+
+const provider = (gateway: string): ProviderConfig => ({
+ id: "foundry",
+ name: "Foundry",
+ base_url: gateway,
+ enabled: true,
+ authentication: {
+ type: "apim_gateway",
+ issuer_id: "entra",
+ audience: "api://local-studio",
+ scopes: ["api://local-studio/invoke"],
+ },
+ foundry: {
+ provider_id: "foundry",
+ gateway_url: gateway,
+ project_endpoint: "https://resource.services.ai.azure.com/api/projects/project",
+ project_name: "project",
+ allowed_models: ["model-admitted"],
+ allowed_agents: ["agent-admitted"],
+ authentication: {
+ type: "apim_gateway",
+ issuer_id: "entra",
+ audience: "api://local-studio",
+ scopes: ["api://local-studio/invoke"],
+ },
+ },
+});
+
+const principal = (overrides: Partial