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 + + + CI, release, license, stars, issues + + 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 + + + CI, license, release + + `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 = {}): NormalizedPrincipal => ({ + subject: "subject-1", + issuer: "https://login.microsoftonline.com/tenant/v2.0", + issuer_id: "entra", + tenant: "tenant", + display_name: "Scientist", + roles: ["scientist"], + entitlements: ["notebook:read", "notebook:execute", "ray:admit", "model:invoke", "agent:invoke"], + clearance: "C2", + issued_at: 1, + expires_at: 2, + ...overrides, +}); + +const enterprise = { + mode: "required_oidc" as const, + session_idle_seconds: 900, + session_absolute_seconds: 3600, + issuers: [ + { + id: "entra", + kind: "entra" as const, + issuer: "https://login.microsoftonline.com/tenant/v2.0", + client_id: "client", + audience: "api://local-studio", + scopes: ["api://local-studio/invoke"], + tenant: "tenant", + role_claim: "roles", + group_claim: "groups", + role_mappings: { Scientist: ["scientist" as const] }, + clearance_mappings: { C2: "C2" as const }, + }, + ], +}; + +describe("Foundry adapter", () => { + test("intersects live catalogs with the deployment allowlist and preserves correlation", async () => { + const server = Bun.serve({ + port: 0, + fetch: (request) => { + expect(request.headers.get("authorization")).toBe("Bearer delegated-token"); + return Response.json( + { + data: [ + { id: "model-admitted", object: "model" }, + { id: "model-denied", object: "model" }, + ], + }, + { headers: { "x-correlation-id": "apim-correlation" } }, + ); + }, + }); + servers.push(server); + const catalog = await Effect.runPromise( + fetchFoundryCatalog(provider(`http://127.0.0.1:${server.port}`), "delegated-token", "models"), + ); + expect(catalog.data.map(({ id }) => id)).toEqual(["model-admitted"]); + expect(catalog.correlation_id).toBe("apim-correlation"); + expect(catalog.provider_id).toBe("foundry"); + }); + + test("replaces malformed upstream correlation identifiers", async () => { + const server = Bun.serve({ + port: 0, + fetch: () => + Response.json( + { data: [{ id: "model-admitted", object: "model" }] }, + { headers: { "x-correlation-id": "../../forged" } }, + ), + }); + servers.push(server); + const catalog = await Effect.runPromise( + fetchFoundryCatalog(provider(`http://127.0.0.1:${server.port}`), "delegated-token", "models"), + ); + expect(catalog.correlation_id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, + ); + }); + + test("fails closed on issuer, tenant, clearance, entitlement, and auth mode", () => { + const configured = provider("https://gateway.example"); + expect( + enforceFoundryPrincipal(configured, principal(), enterprise, "model:invoke").subject, + ).toBe("subject-1"); + for (const candidate of [ + principal({ issuer_id: "keycloak" }), + principal({ tenant: "other" }), + principal({ clearance: "C1" }), + principal({ entitlements: ["notebook:read"] }), + ]) { + expect(() => + enforceFoundryPrincipal(configured, candidate, enterprise, "model:invoke"), + ).toThrow(); + } + expect(() => + enforceFoundryPrincipal( + { ...configured, foundry: { ...configured.foundry!, authentication: { type: "none" } } }, + principal(), + enterprise, + "model:invoke", + ), + ).toThrow(); + }); + + test("bounds JSON request bodies before parsing", async () => { + const request = new Request("http://localhost/ai/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ input: "x".repeat(FOUNDRY_REQUEST_LIMIT_BYTES) }), + }); + const error = await Effect.runPromise( + Effect.flip(readFoundryRequest(request, Schema.Record(Schema.String, Schema.Unknown))), + ); + expect(error.status).toBe(413); + }); + + test("propagates client cancellation and maps APIM quota without response content", async () => { + let aborted = false; + const server = Bun.serve({ + port: 0, + fetch: (request) => { + if (new URL(request.url).pathname === "/quota") { + return new Response("sensitive backend detail", { status: 429 }); + } + return new Promise((resolve) => { + request.signal.addEventListener( + "abort", + () => { + aborted = true; + resolve(new Response(null, { status: 499 })); + }, + { once: true }, + ); + }); + }, + }); + servers.push(server); + const configured = provider(`http://127.0.0.1:${server.port}`); + const quota = await Effect.runPromise( + Effect.flip( + requestFoundryGateway({ + provider: configured, + path: "/quota", + token: "delegated-token", + }), + ), + ); + expect(quota).toBeInstanceOf(HttpStatus); + expect((quota as HttpStatus).status).toBe(429); + expect((quota as HttpStatus).detail).toBe("APIM quota exceeded"); + expect(JSON.stringify(quota)).not.toContain("sensitive backend detail"); + const controller = new AbortController(); + const pending = Effect.runPromiseExit( + requestFoundryGateway({ + provider: configured, + path: "/slow", + token: "delegated-token", + signal: controller.signal, + }), + ); + await Bun.sleep(20); + controller.abort(); + expect((await pending)._tag).toBe("Failure"); + for (let index = 0; index < 20 && !aborted; index += 1) await Bun.sleep(5); + expect(aborted).toBe(true); + }); + + test("records usage only when APIM returns token metrics", () => { + expect(usageFromHeaders(new Headers())).toBeUndefined(); + expect( + usageFromHeaders( + new Headers({ + "x-ms-input-tokens": "12", + "x-ms-output-tokens": "8", + "x-ms-total-tokens": "20", + }), + ), + ).toEqual({ input_tokens: 12, output_tokens: 8, total_tokens: 20 }); + }); +}); diff --git a/controller/tests/foundry-routes.integration.test.ts b/controller/tests/foundry-routes.integration.test.ts new file mode 100644 index 000000000..7934fe4d5 --- /dev/null +++ b/controller/tests/foundry-routes.integration.test.ts @@ -0,0 +1,491 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { NormalizedPrincipal } from "@local-studio/contracts/enterprise-auth"; +import { Effect, Layer, ManagedRuntime } from "effect"; +import { Hono } from "hono"; +import type { AppContext } from "../src/app-context"; +import type { ProviderConfig } from "../src/config/persisted-config"; +import type { ControllerRuntime } from "../src/core/effect-runtime"; +import { isHttpStatus } from "../src/core/errors"; +import { + controllerRuntimeMiddleware, + type ControllerEnvironment, +} from "../src/http/effect-handler"; +import { registerFoundryRoutes } from "../src/modules/foundry/routes"; +import { ScientificWorkbenchStore } from "../src/modules/workbench/store"; +import { createScientificRayJobRecord } from "../src/modules/workbench/service"; +import type { ScientificRayJobSubmission } from "../contracts/scientific-workbench"; + +type FixtureMode = "healthy" | "malformed" | "oversized" | "slow"; +type ObservedRequest = { path: string; body: unknown }; + +const servers: Array> = []; +const runtimes: ControllerRuntime[] = []; +const workbenchStores: ScientificWorkbenchStore[] = []; + +afterEach(async () => { + for (const server of servers.splice(0)) server.stop(true); + for (const runtime of runtimes.splice(0)) await runtime.dispose(); + for (const store of workbenchStores.splice(0)) await Effect.runPromise(store.close()); +}); + +const principal = (overrides: Partial = {}): NormalizedPrincipal => ({ + subject: "scientist-1", + issuer: "https://login.microsoftonline.com/tenant/v2.0", + issuer_id: "entra", + tenant: "tenant", + display_name: "Scientist", + roles: ["scientist"], + entitlements: ["model:invoke", "agent:invoke"], + clearance: "C2", + issued_at: 1, + expires_at: 4_102_444_800, + ...overrides, +}); + +const delegatedToken = [ + Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url"), + Buffer.from( + JSON.stringify({ + sub: "scientist-1", + iss: "https://login.microsoftonline.com/tenant/v2.0", + aud: "api://local-studio", + scp: "api://local-studio/invoke", + exp: 4_102_444_800, + }), + ).toString("base64url"), + "fixture-signature", +].join("."); + +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", "model-slow"], + allowed_agents: ["agent-admitted"], + authentication: { + type: "apim_gateway", + issuer_id: "entra", + audience: "api://local-studio", + scopes: ["api://local-studio/invoke"], + }, + }, +}); + +const scientificSubmission = (): ScientificRayJobSubmission => ({ + id: "submission-01", + project_id: "project-01", + notebook_id: "notebook-01", + compute_lease_id: "lease-01", + experiment_id: "experiment-01", + classification: "C2", + compute_profile: { + id: "cpu-small", + name: "CPU small", + cpu_cores: 2, + memory_gb: 4, + gpu_count: 0, + gpu_resource: null, + min_workers: 0, + max_workers: 1, + max_runtime_minutes: 30, + idle_timeout_minutes: 5, + network_policy: "deny-by-default", + classification_ceiling: "C2", + }, + environment_image: `registry.example.test/science@sha256:${"a".repeat(64)}`, + environment_digest: `sha256:${"a".repeat(64)}`, + entrypoint: "python main.py", + datasets: [], + models: [ + { + provider_id: "foundry", + model_id: "model-admitted", + qualified_id: "foundry/model-admitted", + endpoint_class: "openai-compatible", + tool_mode: "approved", + }, + ], + parameters: {}, + random_seeds: [42], + approval_ids: ["approval-01"], + requested_by: "scientist-1", + requested_at: "2026-07-29T00:00:00.000Z", +}); + +const makeGateway = () => { + let mode: FixtureMode = "healthy"; + let aborted = false; + const observed: ObservedRequest[] = []; + const server = Bun.serve({ + port: 0, + fetch: async (request) => { + expect(request.headers.get("authorization")).toBe(`Bearer ${delegatedToken}`); + const path = new URL(request.url).pathname; + const body = + request.method === "POST" + ? await request + .clone() + .json() + .catch(() => null) + : null; + observed.push({ path, body }); + if (path === "/ai/v1/models") { + if (mode === "malformed") return Response.json({ results: [] }); + if (mode === "oversized") { + return Response.json({ + data: [{ id: `model-${"x".repeat(2 * 1024 * 1024)}` }], + }); + } + return Response.json( + { + data: [ + { id: "model-admitted", object: "model" }, + { id: "model-denied", object: "model" }, + ], + }, + { headers: { "x-correlation-id": "models-correlation" } }, + ); + } + if (path === "/ai/v1/agents") { + return Response.json( + { + data: [ + { id: "agent-admitted", object: "agent" }, + { id: "agent-denied", object: "agent" }, + ], + }, + { headers: { "x-correlation-id": "agents-correlation" } }, + ); + } + if (mode === "slow") { + return new Promise((resolve) => { + request.signal.addEventListener( + "abort", + () => { + aborted = true; + resolve(new Response(null, { status: 499 })); + }, + { once: true }, + ); + }); + } + if (path === "/ai/v1/chat/completions") { + return Response.json( + { id: "chat-1", choices: [{ message: { content: "fixture" } }] }, + { + headers: { + "x-correlation-id": "chat-correlation", + "x-ms-input-tokens": "5", + "x-ms-output-tokens": "3", + "x-ms-total-tokens": "8", + }, + }, + ); + } + if (path === "/ai/v1/responses") { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode('data: {"type":"response.output_text.delta"}\n\n'), + ); + controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + return new Response(stream, { + headers: { + "content-type": "text/event-stream", + "x-correlation-id": "stream-correlation", + }, + }); + } + if (path === "/ai/v1/agents/agent-admitted/invoke") { + return Response.json( + { id: "agent-response-1", output: [{ type: "message" }] }, + { headers: { "x-correlation-id": "agent-correlation" } }, + ); + } + return Response.json({ error: "fixture route not found" }, { status: 404 }); + }, + }); + servers.push(server); + return { + url: `http://127.0.0.1:${server.port}`, + observed, + setMode: (value: FixtureMode) => { + mode = value; + }, + wasAborted: () => aborted, + }; +}; + +const makeController = (gateway: string, principals: Record) => { + const runtime = ManagedRuntime.make(Layer.empty) as unknown as ControllerRuntime; + runtimes.push(runtime); + const scientificWorkbenchStore = new ScientificWorkbenchStore(":memory:"); + workbenchStores.push(scientificWorkbenchStore); + const context = { + config: { + providers: [provider(gateway)], + enterprise_auth: { + mode: "required_oidc", + session_idle_seconds: 900, + session_absolute_seconds: 3600, + issuers: [ + { + id: "entra", + kind: "entra", + issuer: "https://login.microsoftonline.com/tenant/v2.0", + client_id: "client", + audience: "api://local-studio", + scopes: ["api://local-studio/invoke"], + tenant: "tenant", + role_claim: "roles", + group_claim: "groups", + role_mappings: {}, + clearance_mappings: {}, + }, + ], + }, + }, + stores: { scientificWorkbenchStore }, + } as unknown as AppContext; + const app = new Hono(); + app.use("*", controllerRuntimeMiddleware(runtime)); + app.use("*", async (ctx, next) => { + const selected = principals[ctx.req.header("x-fixture-principal") ?? "valid"]; + if (selected) { + ctx.set("enterprisePrincipal", selected); + ctx.set("enterpriseBearerToken", delegatedToken); + } + await next(); + }); + registerFoundryRoutes(app, context); + app.onError((error, ctx) => + isHttpStatus(error) + ? ctx.json({ detail: error.detail }, error.status as 400 | 403 | 404 | 413 | 503) + : ctx.json({ detail: "Internal Server Error" }, 500), + ); + const server = Bun.serve({ port: 0, fetch: app.fetch }); + servers.push(server); + return { + base: `http://127.0.0.1:${server.port}`, + scientificWorkbenchStore, + }; +}; + +const request = (base: string, path: string, init: RequestInit = {}, selected = "valid") => + fetch(`${base}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${delegatedToken}`, + "x-fixture-principal": selected, + ...init.headers, + }, + }); + +describe("Foundry HTTP integration", () => { + test("filters both catalogs and reports observed health with exact correlations", async () => { + const gateway = makeGateway(); + const { base } = makeController(gateway.url, { valid: principal() }); + const models = await request(base, "/ai/v1/models"); + const agents = await request(base, "/ai/v1/agents"); + const health = await request(base, "/ai/v1/health"); + expect(models.status).toBe(200); + expect(agents.status).toBe(200); + expect(health.status).toBe(200); + expect( + ((await models.json()) as { data: Array<{ id: string }> }).data.map(({ id }) => id), + ).toEqual(["model-admitted"]); + expect( + ((await agents.json()) as { data: Array<{ id: string }> }).data.map(({ id }) => id), + ).toEqual(["agent-admitted"]); + expect(await health.json()).toMatchObject({ + configured: true, + required: true, + state: "observed", + correlation_ids: ["models-correlation", "agents-correlation"], + model_count: 1, + agent_count: 1, + }); + }); + + test("fails closed for missing, wrong-tenant, low-clearance, and unentitled principals", async () => { + const gateway = makeGateway(); + const { base } = makeController(gateway.url, { + wrongTenant: principal({ tenant: "other" }), + lowClearance: principal({ clearance: "C1" }), + unentitled: principal({ entitlements: ["model:invoke"] }), + }); + for (const selected of ["missing", "wrongTenant", "lowClearance", "unentitled"]) { + const response = await request(base, "/ai/v1/agents", {}, selected); + expect(response.status).toBe(403); + } + expect(gateway.observed).toHaveLength(0); + }); + + test("rejects denied resources and malformed or oversized catalogs", async () => { + const gateway = makeGateway(); + const { base } = makeController(gateway.url, { valid: principal() }); + const deniedModel = await request(base, "/ai/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "model-denied", messages: [] }), + }); + const deniedAgent = await request(base, "/ai/v1/agents/agent-denied/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ input: "hello" }), + }); + expect(deniedModel.status).toBe(400); + expect(deniedAgent.status).toBe(400); + expect(gateway.observed).toHaveLength(0); + gateway.setMode("malformed"); + expect((await request(base, "/ai/v1/models")).status).toBe(503); + gateway.setMode("oversized"); + expect((await request(base, "/ai/v1/models")).status).toBe(503); + }); + + test("relays model and agent payloads, SSE, correlation, usage evidence, and cancellation", async () => { + const gateway = makeGateway(); + const { base, scientificWorkbenchStore } = makeController(gateway.url, { + valid: principal(), + otherSubject: principal({ subject: "scientist-2" }), + }); + const submission = scientificSubmission(); + await Effect.runPromise( + scientificWorkbenchStore.saveRayJob( + submission, + createScientificRayJobRecord(submission, "2026-07-29T00:00:01.000Z", principal()), + ), + ); + const evidence: string[] = []; + const originalInfo = console.info; + console.info = (line?: unknown) => evidence.push(String(line)); + try { + const mismatchedModel = await request(base, "/ai/v1/responses", { + method: "POST", + headers: { + "content-type": "application/json", + "x-local-studio-scientific-submission-id": submission.id, + }, + body: JSON.stringify({ model: "model-slow", input: "forged link" }), + }); + expect(mismatchedModel.status).toBe(400); + expect(gateway.observed).toHaveLength(0); + const chat = await request(base, "/ai/v1/chat/completions", { + method: "POST", + headers: { + "content-type": "application/json", + "x-local-studio-scientific-submission-id": submission.id, + }, + body: JSON.stringify({ + model: "model-admitted", + messages: [{ role: "user", content: "hi" }], + }), + }); + expect(chat.status).toBe(200); + expect(chat.headers.get("x-correlation-id")).toBe("chat-correlation"); + expect(chat.headers.get("x-ms-total-tokens")).toBe("8"); + const stream = await request(base, "/ai/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "model-admitted", input: "hi", stream: true }), + }); + expect(stream.headers.get("content-type")).toContain("text/event-stream"); + expect(await stream.text()).toContain("data: [DONE]"); + const agent = await request(base, "/ai/v1/agents/agent-admitted/invoke", { + method: "POST", + headers: { + "content-type": "application/json", + "x-local-studio-scientific-submission-id": submission.id, + }, + body: JSON.stringify({ input: "summarize", conversation_id: "conversation-1" }), + }); + expect(agent.status).toBe(200); + expect(agent.headers.get("x-correlation-id")).toBe("agent-correlation"); + expect(gateway.observed).toContainEqual({ + path: "/ai/v1/agents/agent-admitted/invoke", + body: { input: "summarize", conversation_id: "conversation-1" }, + }); + expect( + await Effect.runPromise( + scientificWorkbenchStore.listFoundryInvocationEvidence(submission.id), + ), + ).toEqual([ + expect.objectContaining({ + submission_id: submission.id, + kind: "model", + resource_id: "model-admitted", + correlation_id: "chat-correlation", + principal: expect.objectContaining({ + subject: "scientist-1", + issuer: "https://login.microsoftonline.com/tenant/v2.0", + tenant: "tenant", + }), + }), + expect.objectContaining({ + submission_id: submission.id, + kind: "agent", + resource_id: "agent-admitted", + correlation_id: "agent-correlation", + }), + ]); + const forgedLink = await request( + base, + "/ai/v1/chat/completions", + { + method: "POST", + headers: { + "content-type": "application/json", + "x-local-studio-scientific-submission-id": submission.id, + }, + body: JSON.stringify({ model: "model-admitted", messages: [] }), + }, + "otherSubject", + ); + expect(forgedLink.status).toBe(403); + const parsedEvidence = evidence.map((line) => JSON.parse(line) as Record); + expect(parsedEvidence).toContainEqual( + expect.objectContaining({ + event: "model_invocation", + correlation_id: "chat-correlation", + resource_id: "model-admitted", + usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 }, + subject: "scientist-1", + issuer: "https://login.microsoftonline.com/tenant/v2.0", + tenant: "tenant", + clearance: "C2", + }), + ); + + gateway.setMode("slow"); + const abort = new AbortController(); + const pending = request(base, "/ai/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "model-slow", input: "wait" }), + signal: abort.signal, + }); + await Bun.sleep(20); + abort.abort(); + await expect(pending).rejects.toThrow(); + for (let index = 0; index < 20 && !gateway.wasAborted(); index += 1) await Bun.sleep(5); + expect(gateway.wasAborted()).toBe(true); + } finally { + console.info = originalInfo; + } + }); +}); diff --git a/controller/tests/kuberay-gateway.test.ts b/controller/tests/kuberay-gateway.test.ts new file mode 100644 index 000000000..2147bc2ae --- /dev/null +++ b/controller/tests/kuberay-gateway.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ScientificRayJobSubmission } from "@local-studio/contracts/scientific-workbench"; +import { Effect } from "effect"; +import { KubeRayGateway } from "../src/modules/workbench/kuberay-gateway"; +import { createScientificRayJobRecord } from "../src/modules/workbench/service"; + +const submission = (): ScientificRayJobSubmission => ({ + id: "submission-01", + project_id: "project-01", + notebook_id: "notebook-01", + compute_lease_id: "lease-01", + experiment_id: "experiment-01", + classification: "C2", + compute_profile: { + id: "gpu-small", + name: "GPU small", + cpu_cores: 8, + memory_gb: 32, + gpu_count: 1, + gpu_resource: "nvidia.com/gpu", + min_workers: 1, + max_workers: 4, + max_runtime_minutes: 240, + idle_timeout_minutes: 30, + network_policy: "deny-by-default", + classification_ceiling: "C2", + }, + environment_image: `registry.internal/science@sha256:${"c".repeat(64)}`, + environment_digest: `sha256:${"a".repeat(64)}`, + entrypoint: "python train.py", + datasets: [], + models: [ + { + provider_id: "tensorprime", + model_id: "qwen", + qualified_id: "tensorprime/qwen", + endpoint_class: "openai-compatible", + tool_mode: "none", + }, + ], + parameters: {}, + random_seeds: [42], + approval_ids: ["approval-01"], + requested_by: "scientist-01", + requested_at: "2026-07-27T16:00:00Z", +}); + +describe("KubeRay gateway", () => { + test("uses server-side apply with workload identity and maps running status", async () => { + let observedUrl = ""; + let observedInit: RequestInit | undefined; + const gateway = new KubeRayGateway( + { + apiUrl: "https://kubernetes.example", + tokenFile: "/token", + }, + (input, init) => { + observedUrl = String(input); + observedInit = init; + return Effect.succeed( + Response.json({ + metadata: { uid: "uid-01", resourceVersion: "17" }, + status: { + jobStatus: "RUNNING", + jobDeploymentStatus: "Running", + startTime: "2026-07-27T16:02:00Z", + }, + }), + ); + }, + () => "workload-token", + ); + const record = createScientificRayJobRecord(submission(), "2026-07-27T16:01:00Z"); + + const updated = await Effect.runPromise( + gateway.submit(record, "2026-07-27T16:02:00Z"), + ); + const headers = new Headers(observedInit?.headers); + + expect(observedInit?.method).toBe("PATCH"); + expect(headers.get("content-type")).toBe("application/apply-patch+yaml"); + expect(headers.get("authorization")).toBe("Bearer workload-token"); + expect(observedUrl).toContain( + "/apis/ray.io/v1/namespaces/workbench-project-01/rayjobs/experiment-experiment-01", + ); + expect(observedUrl).toContain("fieldManager=local-studio-workbench"); + expect(updated.state).toBe("running"); + expect(updated.cluster?.resource_version).toBe("17"); + }); + + test("reconciles complete KubeRay status to succeeded", async () => { + const gateway = new KubeRayGateway( + { apiUrl: "https://kubernetes.example", tokenFile: "/token" }, + () => + Effect.succeed( + Response.json({ + metadata: { uid: "uid-01", resourceVersion: "18" }, + status: { + jobStatus: "SUCCEEDED", + jobDeploymentStatus: "Complete", + message: "Job finished successfully.", + endTime: "2026-07-27T16:10:00Z", + }, + }), + ), + () => "workload-token", + ); + const queued = createScientificRayJobRecord(submission(), "2026-07-27T16:01:00Z"); + const submitted = { ...queued, state: "submitted" as const }; + + const updated = await Effect.runPromise( + gateway.reconcile(submitted, "2026-07-27T16:11:00Z"), + ); + + expect(updated.state).toBe("succeeded"); + expect(updated.cluster?.message).toBe("Job finished successfully."); + expect(updated.cluster?.ended_at).toBe("2026-07-27T16:10:00Z"); + }); + + test("fails closed on malformed cluster responses", async () => { + const gateway = new KubeRayGateway( + { apiUrl: "https://kubernetes.example", tokenFile: "/token" }, + () => Effect.succeed(Response.json({ status: "not-a-RayJob" })), + () => "workload-token", + ); + + try { + await Effect.runPromise( + gateway.submit( + createScientificRayJobRecord(submission(), "2026-07-27T16:01:00Z"), + "2026-07-27T16:02:00Z", + ), + ); + throw new Error("expected gateway to fail"); + } catch (error) { + expect((error as { detail?: string }).detail).toBe( + "KubeRay API returned an invalid RayJob document", + ); + } + }); + + test("refuses invalid submit and reconcile transitions", async () => { + const gateway = new KubeRayGateway( + { apiUrl: "https://kubernetes.example", tokenFile: "/token" }, + () => Effect.succeed(Response.json({ metadata: {} })), + () => "workload-token", + ); + const queued = createScientificRayJobRecord(submission(), "2026-07-27T16:01:00Z"); + + await expect( + Effect.runPromise(gateway.submit({ ...queued, state: "succeeded" }, "2026-07-27T16:02:00Z")), + ).rejects.toBeDefined(); + await expect( + Effect.runPromise(gateway.reconcile(queued, "2026-07-27T16:02:00Z")), + ).rejects.toBeDefined(); + }); + + test("observes Kubernetes and Ray API versions with the workload credential", async () => { + const requests: Array<{ url: string; authorization: string | null }> = []; + const gateway = new KubeRayGateway( + { + apiUrl: "https://cluster.internal", + tokenFile: "/run/secrets/kubernetes/token", + }, + (input, init) => { + const url = String(input); + requests.push({ + url, + authorization: new Headers(init?.headers).get("authorization"), + }); + return Effect.succeed( + Response.json( + url.endsWith("/version") + ? { gitVersion: "v1.33.1" } + : { + groupVersion: "ray.io/v1", + resources: [{ name: "rayjobs", verbs: ["get", "patch"] }], + }, + ), + ); + }, + () => "workload-token\n", + ); + + const result = await Effect.runPromise(gateway.probe()); + + expect(result).toEqual({ + kubernetesVersion: "v1.33.1", + rayApiVersion: "ray.io/v1", + }); + expect(requests).toHaveLength(2); + expect(requests.every((request) => request.authorization === "Bearer workload-token")).toBe( + true, + ); + }); + + test("fails closed on an invalid Ray API discovery document", async () => { + const gateway = new KubeRayGateway( + { + apiUrl: "https://cluster.internal", + tokenFile: "/run/secrets/kubernetes/token", + }, + (input) => + Effect.succeed( + Response.json( + String(input).endsWith("/version") ? { gitVersion: "v1.33.1" } : { resources: [] }, + ), + ), + () => "workload-token", + ); + + await expect(Effect.runPromise(gateway.probe())).rejects.toBeDefined(); + }); + + test("rejects Ray API discovery without required operations", async () => { + const gateway = new KubeRayGateway( + { + apiUrl: "https://cluster.internal", + tokenFile: "/run/secrets/kubernetes/token", + }, + (input) => + Effect.succeed( + Response.json( + String(input).endsWith("/version") + ? { gitVersion: "v1.33.1" } + : { + groupVersion: "ray.io/v1", + resources: [{ name: "rayjobs", verbs: ["get"] }], + }, + ), + ), + () => "workload-token", + ); + + await expect(Effect.runPromise(gateway.probe())).rejects.toMatchObject({ + detail: "RayJob API does not advertise required get and patch operations", + }); + }); + + test("sanitizes credential and transport failures", async () => { + const credentialFailure = new KubeRayGateway( + { apiUrl: "https://cluster.internal", tokenFile: "/private/secret" }, + () => Effect.die("fetch must not run"), + () => { + throw new Error("ENOENT /private/secret"); + }, + ); + const transportFailure = new KubeRayGateway( + { apiUrl: "https://cluster.internal", tokenFile: "/token" }, + () => Effect.fail(new Error("connect ECONNREFUSED 10.0.0.1")), + () => "workload-token", + ); + + await expect(Effect.runPromise(credentialFailure.probe())).rejects.toMatchObject({ + detail: "KubeRay credential material is unavailable", + }); + await expect(Effect.runPromise(transportFailure.probe())).rejects.toMatchObject({ + detail: "KubeRay API request failed", + }); + }); + + test("submits and reconciles against a protocol-faithful HTTP fixture", async () => { + const directory = mkdtempSync(join(tmpdir(), "local-studio-kuberay-")); + const tokenFile = join(directory, "token"); + writeFileSync(tokenFile, "fixture-workload-token", { mode: 0o600 }); + let patchObserved = false; + const server = Bun.serve({ + port: 0, + fetch: async (request) => { + const authorization = request.headers.get("authorization"); + if (authorization !== "Bearer fixture-workload-token") { + return Response.json({ message: "unauthorized" }, { status: 401 }); + } + if (request.method === "PATCH") { + patchObserved = true; + expect(request.headers.get("content-type")).toBe("application/apply-patch+yaml"); + expect((await request.json() as { kind?: string }).kind).toBe("RayJob"); + return Response.json({ + metadata: { uid: "fixture-uid", resourceVersion: "1" }, + status: { jobStatus: "RUNNING", jobDeploymentStatus: "Running" }, + }); + } + return Response.json({ + metadata: { uid: "fixture-uid", resourceVersion: "2" }, + status: { + jobStatus: "SUCCEEDED", + jobDeploymentStatus: "Complete", + endTime: "2026-07-28T20:00:00Z", + }, + }); + }, + }); + try { + const gateway = new KubeRayGateway({ + apiUrl: `http://127.0.0.1:${server.port}`, + tokenFile, + }); + const queued = createScientificRayJobRecord(submission(), "2026-07-28T19:59:00Z"); + const submitted = await Effect.runPromise( + gateway.submit(queued, "2026-07-28T19:59:10Z"), + ); + const terminal = await Effect.runPromise( + gateway.reconcile(submitted, "2026-07-28T20:00:01Z"), + ); + + expect(patchObserved).toBe(true); + expect(submitted.state).toBe("running"); + expect(terminal.state).toBe("succeeded"); + expect(terminal.cluster?.resource_version).toBe("2"); + } finally { + server.stop(true); + rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/controller/tests/machine-enrollment-service.test.ts b/controller/tests/machine-enrollment-service.test.ts new file mode 100644 index 000000000..9c384194f --- /dev/null +++ b/controller/tests/machine-enrollment-service.test.ts @@ -0,0 +1,256 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { + MachineEnrollmentProfile, + MachineOwnedResource, +} from "@local-studio/contracts/machine-enrollment"; +import { Effect } from "effect"; +import { + MachineEnrollmentService, + decodeMachineEnrollmentProfile, + machinePlanDigest, + transitionMachine, +} from "../src/modules/machines/enrollment-service"; + +const roots: string[] = []; +const root = (): string => { + const value = mkdtempSync(join(tmpdir(), "machine-enrollment-")); + roots.push(value); + return value; +}; + +afterEach(() => { + for (const value of roots.splice(0)) rmSync(value, { recursive: true, force: true }); +}); + +const profile = (changes: Partial = {}): MachineEnrollmentProfile => ({ + machine_id: "tensorprime-01", + display_name: "TensorPrime 01", + locality: "remote", + appliance_id: "cortaix-factory", + classification: "C2", + rig_id: "rig-01", + rig_node_id: "node-01", + runtime_refs: [{ id: "runtime-vllm" }], + access_refs: [ + { + id: "access-fabric-01", + kind: "ssh", + endpoint: "tensorprime", + credential_ref: "keyring:machine/tensorprime-01/ssh", + }, + ], + agent_refs: [{ id: "agent-runtime-01" }], + ...changes, +}); + +const resource = (id = "controller-service"): MachineOwnedResource => ({ + resource_id: id, + kind: "service", + external_ref: `systemd:user:${id}`, + ownership: "local-studio", + apply_action: "create", + rollback_action: "remove", +}); + +describe("machine enrollment", () => { + test("validates C2 appliance binding, stable ids, keyring refs, and rejects secrets", () => { + expect(decodeMachineEnrollmentProfile(profile())).toEqual(profile()); + expect(() => decodeMachineEnrollmentProfile(profile({ machine_id: "Bad ID" }))).toThrow(); + expect(() => + decodeMachineEnrollmentProfile(profile({ appliance_id: "local-studio" })), + ).toThrow(); + expect(() => + decodeMachineEnrollmentProfile({ + ...profile(), + access_refs: [ + { + id: "access-fabric-01", + kind: "ssh", + endpoint: "host", + credential_ref: "plain:value", + }, + ], + }), + ).toThrow(); + expect(() => + decodeMachineEnrollmentProfile({ ...profile(), api_key: "do-not-store" }), + ).toThrow(/secret material/); + expect( + decodeMachineEnrollmentProfile({ + ...profile(), + access_refs: [ + { + id: "access-fabric-01", + kind: "boundary", + endpoint: "https://boundary.example", + credential_ref: "vault:access:boundary", + }, + ], + }).access_refs[0]?.credential_ref, + ).toBe("vault:access:boundary"); + }); + + test("creates a deterministic plan digest independent of object key order", () => { + const first = profile(); + const second = JSON.parse(JSON.stringify(first)) as MachineEnrollmentProfile; + expect(machinePlanDigest(first)).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(machinePlanDigest(first)).toBe(machinePlanDigest(second)); + }); + + test("enforces lifecycle transitions and treats same-state transition as idempotent", () => { + const at = "2026-07-28T12:00:00.000Z"; + const record = { + profile: profile(), + state: "draft" as const, + plan_digest: machinePlanDigest(profile()), + created_at: at, + updated_at: at, + events: [], + receipt: null, + recovery_required: false, + }; + expect(transitionMachine(record, "draft", at, "repeat")).toBe(record); + expect(transitionMachine(record, "probed", at, "probe passed").state).toBe("probed"); + expect(() => transitionMachine(record, "active", at, "skip")).toThrow( + "Invalid machine lifecycle transition", + ); + }); + + test("persists atomically with restrictive permissions and registration is idempotent", async () => { + const directory = root(); + const service = new MachineEnrollmentService(directory, () => "2026-07-28T12:00:00.000Z"); + const first = await service.register(profile()); + const second = await service.register(profile()); + const path = join(directory, "machine-enrollments.json"); + + expect(second).toEqual(first); + expect((await service.list()).length).toBe(1); + expect(statSync(path).mode & 0o777).toBe(0o600); + expect(readFileSync(path, "utf8")).not.toContain("do-not-store"); + }); + + test("applies once, records exact owned resources, and reconciles without changes", async () => { + const service = new MachineEnrollmentService(root(), () => "2026-07-28T12:00:00.000Z"); + await service.register(profile()); + await service.transition("tensorprime-01", "probed", "probe passed"); + await service.transition("tensorprime-01", "admitted", "policy passed"); + await service.transition("tensorprime-01", "configured", "references bound"); + const first = await service.apply("tensorprime-01", [resource()]); + const second = await service.apply("tensorprime-01", [resource()]); + const reconciled = await service.reconcile("tensorprime-01"); + + expect(second.receipt?.receipt_id).toBe(first.receipt?.receipt_id); + expect(second.receipt?.owned_resources).toEqual([resource()]); + expect(second.receipt?.rollback_journal).toEqual([ + { resource_id: "controller-service", status: "pending" }, + ]); + expect(reconciled).toEqual(second); + expect(() => service.apply("tensorprime-01", [resource("different")])).toThrow( + "differ from the existing receipt", + ); + }); + + test("offboards only receipt-owned resources in reverse order", async () => { + const service = new MachineEnrollmentService(root(), () => "2026-07-28T12:00:00.000Z"); + await service.register(profile()); + await service.transition("tensorprime-01", "probed", "probe passed"); + await service.transition("tensorprime-01", "admitted", "policy passed"); + await service.transition("tensorprime-01", "configured", "references bound"); + await service.apply("tensorprime-01", [resource("first"), resource("second")]); + const rolledBack: string[] = []; + const revoked = await Effect.runPromise( + service.offboard("tensorprime-01", (owned) => + Effect.sync(() => rolledBack.push(owned.resource_id)).pipe(Effect.asVoid), + ), + ); + + expect(rolledBack).toEqual(["second", "first"]); + expect(revoked.state).toBe("revoked"); + expect(revoked.receipt?.rollback_journal.map(({ status }) => status)).toEqual([ + "rolled_back", + "rolled_back", + ]); + }); + + test("persists recovery-required state when rollback fails", async () => { + const service = new MachineEnrollmentService(root(), () => "2026-07-28T12:00:00.000Z"); + await service.register(profile()); + await service.transition("tensorprime-01", "probed", "probe passed"); + await service.transition("tensorprime-01", "admitted", "policy passed"); + await service.transition("tensorprime-01", "configured", "references bound"); + await service.apply("tensorprime-01", [resource()]); + + await expect( + Effect.runPromise( + service.offboard("tensorprime-01", () => Effect.fail(new Error("rollback failed"))), + ), + ).rejects.toThrow("rollback failed"); + const [failed] = await service.list(); + expect(failed?.state).toBe("failed"); + expect(failed?.recovery_required).toBe(true); + expect(failed?.receipt?.owned_resources).toEqual([resource()]); + expect(failed?.receipt?.rollback_journal[0]?.status).toBe("failed"); + }); + + test("recovery retries only unfinished rollback journal entries", async () => { + const service = new MachineEnrollmentService(root(), () => "2026-07-28T12:00:00.000Z"); + await service.register(profile()); + await service.transition("tensorprime-01", "probed", "probe passed"); + await service.transition("tensorprime-01", "admitted", "policy passed"); + await service.transition("tensorprime-01", "configured", "references bound"); + await service.apply("tensorprime-01", [resource("first"), resource("second")]); + await expect( + Effect.runPromise( + service.offboard("tensorprime-01", ({ resource_id }) => + resource_id === "first" ? Effect.fail(new Error("rollback failed")) : Effect.void, + ), + ), + ).rejects.toThrow(); + const retried: string[] = []; + const revoked = await Effect.runPromise( + service.offboard("tensorprime-01", ({ resource_id }) => + Effect.sync(() => retried.push(resource_id)).pipe(Effect.asVoid), + ), + ); + expect(retried).toEqual(["first"]); + expect(revoked.state).toBe("revoked"); + }); + + test("rejects malformed persisted state instead of silently resetting it", async () => { + const directory = root(); + writeFileSync(join(directory, "machine-enrollments.json"), '{"version":1,"machines":"bad"}'); + chmodSync(join(directory, "machine-enrollments.json"), 0o600); + const service = new MachineEnrollmentService(directory); + expect(() => service.list()).toThrow(); + }); + + test("serializes mutations across service instances without losing machines", async () => { + const directory = root(); + const first = new MachineEnrollmentService(directory); + const second = new MachineEnrollmentService(directory); + await Promise.all([ + first.register(profile()), + second.register(profile({ machine_id: "tensorprime-02", display_name: "TensorPrime 02" })), + ]); + expect((await first.list()).map(({ profile: value }) => value.machine_id)).toEqual([ + "tensorprime-01", + "tensorprime-02", + ]); + }); + + test("rejects persisted digest and rollback journal drift", async () => { + const directory = root(); + const service = new MachineEnrollmentService(directory); + await service.register(profile()); + const path = join(directory, "machine-enrollments.json"); + const state = JSON.parse(readFileSync(path, "utf8")) as { + machines: Array<{ plan_digest: string }>; + }; + state.machines[0]!.plan_digest = `sha256:${"0".repeat(64)}`; + writeFileSync(path, JSON.stringify(state), { mode: 0o600 }); + expect(() => service.list()).toThrow("plan digest drift"); + }); +}); diff --git a/controller/tests/machine-routes.test.ts b/controller/tests/machine-routes.test.ts new file mode 100644 index 000000000..3946eb725 --- /dev/null +++ b/controller/tests/machine-routes.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { MachineEnrollmentProfile } from "@local-studio/contracts/machine-enrollment"; +import { Effect, Layer, ManagedRuntime } from "effect"; +import { Hono } from "hono"; +import type { AppContext } from "../src/app-context"; +import type { ControllerRuntime } from "../src/core/effect-runtime"; +import { isHttpStatus } from "../src/core/errors"; +import { + controllerRuntimeMiddleware, + type ControllerEnvironment, +} from "../src/http/effect-handler"; +import { createMutatingAuthMiddleware } from "../src/http/security-middleware"; +import { MachineEnrollmentService } from "../src/modules/machines/enrollment-service"; +import { registerMachineRoutes } from "../src/modules/machines/routes"; +import { RigStore } from "../src/stores/rig-store"; + +const roots: string[] = []; +const root = (): string => { + const directory = mkdtempSync(join(tmpdir(), "machine-routes-")); + roots.push(directory); + return directory; +}; + +afterEach(() => { + for (const directory of roots.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +const profile = (): MachineEnrollmentProfile => ({ + machine_id: "tensorprime-01", + display_name: "TensorPrime 01", + locality: "remote", + appliance_id: "cortaix-factory", + classification: "C2", + rig_id: "rig-existing", + rig_node_id: "node-existing", + runtime_refs: [{ id: "runtime-vllm" }], + access_refs: [ + { + id: "access-fabric-01", + kind: "boundary", + endpoint: "https://boundary.example", + credential_ref: "vault:access:boundary", + }, + ], + agent_refs: [{ id: "tensorprime-01:pi" }], +}); + +const makeApp = (directory: string) => { + const runtime = ManagedRuntime.make(Layer.empty) as unknown as ControllerRuntime; + const context = { + config: { api_key: "test-api-key" }, + machineEnrollmentService: new MachineEnrollmentService(directory), + } as unknown as AppContext; + const app = new Hono(); + app.use("*", controllerRuntimeMiddleware(runtime)); + app.use("*", createMutatingAuthMiddleware(context)); + registerMachineRoutes(app, context); + app.onError((error, ctx) => + isHttpStatus(error) + ? ctx.json({ detail: error.detail }, error.status as 400 | 404) + : ctx.json({ detail: String(error) }, 500), + ); + return { app, runtime }; +}; + +const request = ( + app: Hono, + path: string, + method = "GET", + body?: unknown, + authenticated = true, +) => + app.request(path, { + method, + headers: { + ...(authenticated ? { Authorization: "Bearer test-api-key" } : {}), + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + +describe("machine routes", () => { + test("requires controller authentication and serves the complete fixture lifecycle", async () => { + const directory = root(); + const { app, runtime } = makeApp(directory); + expect((await request(app, "/machines", "GET", undefined, false)).status).toBe(401); + expect((await request(app, "/machines", "POST", profile(), false)).status).toBe(401); + + const created = await request(app, "/machines", "POST", profile()); + expect(created.status).toBe(201); + const plan = await request(app, "/machines/tensorprime-01/plan", "POST"); + expect(plan.status).toBe(200); + expect((await plan.json() as { plan: { digest: string } }).plan.digest).toMatch(/^sha256:/); + + for (const state of ["probed", "admitted", "configured"] as const) { + const response = await request(app, "/machines/tensorprime-01/state", "PATCH", { + state, + reason: `fixture ${state}`, + }); + expect(response.status).toBe(200); + } + const applied = await request(app, "/machines/tensorprime-01/apply", "POST"); + expect(applied.status).toBe(200); + const appliedBody = await applied.json() as { + machine: { state: string; receipt: { owned_resources: Array<{ external_ref: string }> } }; + }; + expect(appliedBody.machine.state).toBe("active"); + expect(appliedBody.machine.receipt.owned_resources[0]?.external_ref).toBe( + "loopback:machine:tensorprime-01", + ); + expect((await request(app, "/machines/tensorprime-01/reconcile", "POST")).status).toBe(200); + const revoked = await request(app, "/machines/tensorprime-01", "DELETE"); + expect(revoked.status).toBe(200); + expect((await revoked.json() as { machine: { state: string } }).machine.state).toBe("revoked"); + await runtime.dispose(); + }); + + test("survives service restart without changing legacy rig persistence", async () => { + const directory = root(); + const rigStore = new RigStore(join(directory, "controller.db")); + rigStore.save({ + id: "rig-existing", + name: "Existing rig", + description: null, + nodes: [], + created_at: "2026-07-28T12:00:00.000Z", + updated_at: "2026-07-28T12:00:00.000Z", + }); + const first = makeApp(directory); + expect((await request(first.app, "/machines", "POST", profile())).status).toBe(201); + await first.runtime.dispose(); + + const second = makeApp(directory); + const response = await request(second.app, "/machines/tensorprime-01"); + expect(response.status).toBe(200); + expect((await response.json() as { machine: { profile: { rig_id?: string } } }).machine.profile.rig_id) + .toBe("rig-existing"); + expect(rigStore.get("rig-existing")?.name).toBe("Existing rig"); + await Effect.runPromise(rigStore.close()); + await second.runtime.dispose(); + }); +}); diff --git a/controller/tests/notebook-gateway.test.ts b/controller/tests/notebook-gateway.test.ts new file mode 100644 index 000000000..6b4b7ebe6 --- /dev/null +++ b/controller/tests/notebook-gateway.test.ts @@ -0,0 +1,377 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { Effect } from "effect"; +import { NotebookGateway, type NotebookBridge } from "../src/modules/workbench/notebook-gateway"; + +const roots: string[] = []; +const identity = { notebook_id: "notebook-01", project_id: "project-01", actor_id: "scientist-01" }; +const smolvmFixture = resolve(import.meta.dir, "fixtures/smolvm-notebook-fixture.mjs"); +const originalSmolvmFixtureArgs = process.env["SMOLVM_FIXTURE_ARGS"]; + +const document = { + kernel_name: "python3", + cells: [ + { + index: 0, + cell_type: "code" as const, + source: "2 + 2", + execution_count: null, + outputs: [], + }, + ], +}; + +const setup = async () => { + const root = await mkdtemp(join(tmpdir(), "local-studio-notebook-")); + roots.push(root); + await writeFile(join(root, "demo.ipynb"), JSON.stringify({ cells: [] })); + return root; +}; + +afterEach(async () => { + if (originalSmolvmFixtureArgs === undefined) delete process.env["SMOLVM_FIXTURE_ARGS"]; + else process.env["SMOLVM_FIXTURE_ARGS"] = originalSmolvmFixtureArgs; + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("notebook gateway", () => { + test("inspects a notebook inside the governed root with a revision", async () => { + const root = await setup(); + const bridge: NotebookBridge = () => Effect.succeed(document); + const gateway = new NotebookGateway(root, "python3", bridge); + const value = await Effect.runPromise(gateway.inspect("demo.ipynb")); + + expect(value.path).toBe("demo.ipynb"); + expect(value.revision).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(value.cells[0]?.source).toBe("2 + 2"); + }); + + test("rejects mutation when the inspected revision is stale", async () => { + const root = await setup(); + const bridge: NotebookBridge = () => Effect.succeed(document); + const gateway = new NotebookGateway(root, "python3", bridge); + const approval = gateway.issueApproval({ + ...identity, + expected_revision: `sha256:${"0".repeat(64)}`, + operation: "patch", + cell_index: 0, + }); + + try { + await Effect.runPromise( + gateway.patch( + "demo.ipynb", + { + expected_revision: `sha256:${"0".repeat(64)}`, + cell_index: 0, + source: "3 + 3", + approval_id: approval.id, + }, + identity, + ), + ); + throw new Error("expected stale revision rejection"); + } catch (error) { + expect((error as { detail?: string }).detail).toBe( + "Notebook changed after the agent inspected it", + ); + } + }); + + test("rejects notebook paths outside the governed root", async () => { + const root = await setup(); + const bridge: NotebookBridge = () => Effect.succeed(document); + const gateway = new NotebookGateway(root, "python3", bridge); + + try { + await Effect.runPromise(gateway.inspect("../outside.ipynb")); + throw new Error("expected containment rejection"); + } catch (error) { + expect((error as { detail?: string }).detail).toBe("Notebook document was not found"); + } + }); + + test("rejects a notebook symlink that resolves outside the governed root", async () => { + const root = await setup(); + const outside = await mkdtemp(join(tmpdir(), "local-studio-notebook-outside-")); + roots.push(outside); + await writeFile(join(outside, "outside.ipynb"), JSON.stringify({ cells: [] })); + await symlink(join(outside, "outside.ipynb"), join(root, "linked.ipynb")); + const gateway = new NotebookGateway(root, "python3", () => Effect.succeed(document)); + + await expect(Effect.runPromise(gateway.inspect("linked.ipynb"))).rejects.toMatchObject({ + detail: "Notebook path leaves the governed root", + }); + }); + + test("routes Node.js execution through the sandbox bridge", async () => { + const root = await setup(); + const nodeDocument = { ...document, kernel_name: "nodejs" }; + await writeFile( + join(root, "demo.ipynb"), + JSON.stringify({ cells: [], metadata: { kernelspec: { name: "nodejs" } } }), + ); + const bridge: NotebookBridge = () => Effect.succeed(nodeDocument); + let sandboxRequest: Parameters[0] | null = null; + const sandboxBridge: NotebookBridge = (request) => { + sandboxRequest = request; + return Effect.succeed(nodeDocument); + }; + const gateway = new NotebookGateway( + root, + "python3", + bridge, + "smolvm", + "node@sha256:test", + sandboxBridge, + ); + const inspected = await Effect.runPromise(gateway.inspect("demo.ipynb")); + const approval = gateway.issueApproval({ + ...identity, + expected_revision: inspected.revision, + operation: "execute", + cell_index: 0, + }); + + const value = await Effect.runPromise( + gateway.execute( + "demo.ipynb", + { + expected_revision: inspected.revision, + cell_index: 0, + approval_id: approval.id, + timeout_seconds: 30, + }, + identity, + ), + ); + + expect(value.runtime).toBe("node"); + expect(sandboxRequest).toMatchObject({ + operation: "execute", + expected_revision: inspected.revision, + timeout_seconds: 30, + }); + }); + + test("applies revision-bound notebook structure changes", async () => { + const root = await setup(); + let structureRequest: Parameters[0] | null = null; + const bridge: NotebookBridge = (request) => { + structureRequest = request; + return Effect.succeed(document); + }; + const gateway = new NotebookGateway(root, "python3", bridge); + const inspected = await Effect.runPromise(gateway.inspect("demo.ipynb")); + const approval = gateway.issueApproval({ + ...identity, + expected_revision: inspected.revision, + operation: "structure", + cell_index: 1, + }); + + await Effect.runPromise( + gateway.structure( + "demo.ipynb", + { + expected_revision: inspected.revision, + operation: "insert", + cell_index: 1, + cell_type: "markdown", + approval_id: approval.id, + }, + identity, + ), + ); + + expect(structureRequest).toMatchObject({ + operation: "structure", + action: "insert", + cell_index: 1, + cell_type: "markdown", + }); + }); + + test("consumes a scoped approval once and persists bounded interaction evidence", async () => { + const root = await setup(); + const bridge: NotebookBridge = () => Effect.succeed(document); + const gateway = new NotebookGateway(root, "python3", bridge); + const inspected = await Effect.runPromise(gateway.inspect("demo.ipynb", identity)); + const approval = gateway.issueApproval({ + ...identity, + expected_revision: inspected.revision, + operation: "patch", + cell_index: 0, + }); + const request = { + expected_revision: inspected.revision, + cell_index: 0, + source: "3 + 3", + approval_id: approval.id, + }; + + await Effect.runPromise(gateway.patch("demo.ipynb", request, identity)); + await expect( + Effect.runPromise(gateway.patch("demo.ipynb", request, identity)), + ).rejects.toMatchObject({ + detail: "Notebook approval is missing, expired, used, or out of scope", + }); + const events = await Effect.runPromise(gateway.listEvents(identity.notebook_id)); + expect(events.map(({ operation }) => operation)).toEqual(["inspect", "patch"]); + }); + + test("fails closed without a pinned local Python image", async () => { + const root = await setup(); + const bridge: NotebookBridge = () => Effect.succeed(document); + const gateway = new NotebookGateway(root, "python3", bridge); + const inspected = await Effect.runPromise(gateway.inspect("demo.ipynb")); + const approval = gateway.issueApproval({ + ...identity, + expected_revision: inspected.revision, + operation: "execute", + cell_index: 0, + }); + + await expect( + Effect.runPromise( + gateway.execute( + "demo.ipynb", + { + expected_revision: inspected.revision, + cell_index: 0, + approval_id: approval.id, + }, + identity, + ), + ), + ).rejects.toMatchObject({ + detail: "Python notebook image must be pinned by sha256 digest", + }); + }); + + test("executes Python through bounded network-disabled SmolVM and commits the notebook", async () => { + const root = await setup(); + const notebookPath = join(root, "demo.ipynb"); + await writeFile( + notebookPath, + JSON.stringify({ + cells: [ + { + cell_type: "code", + source: "print('python-sandbox')", + execution_count: null, + outputs: [], + }, + ], + metadata: { kernelspec: { name: "python3" } }, + }), + ); + const imagePath = join(root, "python-image.tar"); + const image = Buffer.from("pinned-python-image"); + await writeFile(imagePath, image); + const argsPath = join(root, "smolvm-args.json"); + await chmod(smolvmFixture, 0o755); + process.env["SMOLVM_FIXTURE_ARGS"] = argsPath; + const gateway = new NotebookGateway( + root, + "python3", + () => Effect.succeed(document), + smolvmFixture, + "node@sha256:test", + undefined, + `${imagePath}@sha256:${createHash("sha256").update(image).digest("hex")}`, + ); + const inspected = await Effect.runPromise(gateway.inspect("demo.ipynb")); + const approval = gateway.issueApproval({ + ...identity, + expected_revision: inspected.revision, + operation: "execute", + cell_index: 0, + }); + const value = await Effect.runPromise( + gateway.execute( + "demo.ipynb", + { + expected_revision: inspected.revision, + cell_index: 0, + approval_id: approval.id, + timeout_seconds: 15, + }, + identity, + ), + ); + const args = JSON.parse(await readFile(argsPath, "utf8")) as string[]; + expect(args).toContain("--unprivileged"); + expect(args).toContain("--cpus"); + expect(args).toContain("--mem"); + expect(args).toContain("--storage"); + expect(args).toContain("--overlay"); + expect(args).toContain("--timeout"); + expect(args).not.toContain("--net"); + expect(args.slice(-3)[0]).toBe("python3"); + expect(value.cells[0]?.outputs[0]?.text).toBe("python-sandbox\n"); + expect(JSON.parse(await readFile(notebookPath, "utf8")).cells[0].execution_count).toBe(1); + const volume = args[args.indexOf("--volume") + 1] ?? ""; + const scratch = volume.slice(0, volume.lastIndexOf(":/workspace")); + await expect(stat(scratch)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + test("rejects the losing commit when concurrent executions share a revision", async () => { + const root = await setup(); + const notebookPath = join(root, "demo.ipynb"); + await writeFile( + notebookPath, + JSON.stringify({ + cells: [{ cell_type: "code", source: "2 + 2", execution_count: null, outputs: [] }], + metadata: { kernelspec: { name: "python3" } }, + }), + ); + const imagePath = join(root, "python-image.tar"); + const image = Buffer.from("pinned-python-image"); + await writeFile(imagePath, image); + await chmod(smolvmFixture, 0o755); + const gateway = new NotebookGateway( + root, + "python3", + () => Effect.succeed(document), + smolvmFixture, + "node@sha256:test", + undefined, + `${imagePath}@sha256:${createHash("sha256").update(image).digest("hex")}`, + ); + const inspected = await Effect.runPromise(gateway.inspect("demo.ipynb")); + const approvals = [0, 1].map(() => + gateway.issueApproval({ + ...identity, + expected_revision: inspected.revision, + operation: "execute", + cell_index: 0, + }), + ); + const results = await Promise.allSettled( + approvals.map((approval) => + Effect.runPromise( + gateway.execute( + "demo.ipynb", + { + expected_revision: inspected.revision, + cell_index: 0, + approval_id: approval.id, + }, + identity, + ), + ), + ), + ); + + expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1); + const rejected = results.find(({ status }) => status === "rejected"); + expect(rejected).toMatchObject({ + status: "rejected", + reason: { detail: "Notebook changed during sandboxed execution" }, + }); + }); +}); diff --git a/controller/tests/notebook-governance.test.ts b/controller/tests/notebook-governance.test.ts new file mode 100644 index 000000000..ec27230ee --- /dev/null +++ b/controller/tests/notebook-governance.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Effect } from "effect"; +import { NotebookGovernance } from "../src/modules/workbench/notebook-governance"; + +const roots: string[] = []; +const base = { + actor_id: "scientist-01", + project_id: "project-01", + notebook_id: "notebook-01", + expected_revision: `sha256:${"a".repeat(64)}`, + operation: "patch" as const, + cell_index: 2, +}; + +const setup = async (now: () => number = () => Date.parse("2026-07-28T12:00:00Z")) => { + const root = await mkdtemp(join(tmpdir(), "notebook-governance-")); + roots.push(root); + return new NotebookGovernance(root, now); +}; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("notebook governance", () => { + test("rejects every approval scope mismatch and consumes each grant", async () => { + const governance = await setup(); + const mismatches = [ + { actor_id: "other" }, + { project_id: "other" }, + { notebook_id: "other" }, + { expected_revision: `sha256:${"b".repeat(64)}` }, + { operation: "execute" as const }, + { cell_index: 3 }, + ]; + + for (const mismatch of mismatches) { + const approval = governance.issueApproval(base); + await expect( + Effect.runPromise( + governance.consumeApproval(approval.id, { + ...base, + ...mismatch, + }), + ), + ).rejects.toMatchObject({ + detail: "Notebook approval is missing, expired, used, or out of scope", + }); + await expect( + Effect.runPromise(governance.consumeApproval(approval.id, base)), + ).rejects.toMatchObject({ + detail: "Notebook approval is missing, expired, used, or out of scope", + }); + } + }); + + test("rejects expired grants", async () => { + let now = Date.parse("2026-07-28T12:00:00Z"); + const governance = await setup(() => now); + const approval = governance.issueApproval(base); + now += 5 * 60_000; + + await expect( + Effect.runPromise(governance.consumeApproval(approval.id, base)), + ).rejects.toMatchObject({ + detail: "Notebook approval is missing, expired, used, or out of scope", + }); + }); + + test("returns only the requested notebook and bounds evidence to 500 events", async () => { + const governance = await setup(); + for (let index = 0; index < 505; index += 1) { + await Effect.runPromise( + governance.recordEvent({ + notebook_id: index === 0 ? "other" : base.notebook_id, + project_id: base.project_id, + actor_id: base.actor_id, + operation: "inspect", + revision_before: base.expected_revision, + revision_after: base.expected_revision, + cell_index: null, + approval_id: null, + }), + ); + } + + const events = await Effect.runPromise(governance.listEvents(base.notebook_id)); + expect(events).toHaveLength(500); + expect(events.every(({ notebook_id }) => notebook_id === base.notebook_id)).toBe(true); + expect(await Effect.runPromise(governance.listEvents("other"))).toHaveLength(1); + }); +}); diff --git a/controller/tests/notebook-smolvm-runtime.test.ts b/controller/tests/notebook-smolvm-runtime.test.ts new file mode 100644 index 000000000..75fbb251d --- /dev/null +++ b/controller/tests/notebook-smolvm-runtime.test.ts @@ -0,0 +1,73 @@ +import { afterEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Effect } from "effect"; +import { + runNotebookVm, + verifyNotebookImage, +} from "../src/modules/workbench/notebook-smolvm-runtime"; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +test("SmolVM runner rejects combined output above one MiB", async () => { + await expect( + Effect.runPromise( + runNotebookVm( + process.execPath, + ["-e", "process.stdout.write('x'.repeat(600000));process.stderr.write('y'.repeat(600000))"], + 1, + ), + ), + ).rejects.toMatchObject({ detail: "SmolVM notebook output exceeded 1 MiB" }); +}); + +test("SmolVM runner enforces its host-side timeout", async () => { + const root = await mkdtemp(join(tmpdir(), "local-studio-timeout-")); + roots.push(root); + const pidFile = join(root, "pid"); + await expect( + Effect.runPromise( + runNotebookVm( + process.execPath, + [ + "-e", + "require('fs').writeFileSync(process.argv[1],String(process.pid));setTimeout(()=>{},10000)", + pidFile, + ], + -4.95, + ), + ), + ).rejects.toMatchObject({ detail: "SmolVM notebook operation timed out" }); + const pid = Number(await readFile(pidFile, "utf8")); + expect(() => process.kill(pid, 0)).toThrow(); +}); + +test("Python images fail closed unless they are local and digest pinned", async () => { + await expect( + Effect.runPromise(verifyNotebookImage(`python:3.12@sha256:${"0".repeat(64)}`, "Python", true)), + ).rejects.toMatchObject({ detail: "Python notebook image must be a local tar archive" }); +}); + +test("local notebook images reject a mismatched digest", async () => { + const root = await mkdtemp(join(tmpdir(), "local-studio-image-")); + roots.push(root); + const image = join(root, "python.tar"); + await writeFile(image, "verified-content"); + + await expect( + Effect.runPromise(verifyNotebookImage(`${image}@sha256:${"0".repeat(64)}`, "Python", true)), + ).rejects.toMatchObject({ detail: "Python notebook image digest does not match" }); +}); + +test("Node image references retain remote digest compatibility", async () => { + const digest = createHash("sha256").update("node").digest("hex"); + await expect( + Effect.runPromise(verifyNotebookImage(`node:22@sha256:${digest}`, "Node", false)), + ).resolves.toBe(`node:22@sha256:${digest}`); +}); diff --git a/controller/tests/provider-routing.integration.test.ts b/controller/tests/provider-routing.integration.test.ts new file mode 100644 index 000000000..7462b6634 --- /dev/null +++ b/controller/tests/provider-routing.integration.test.ts @@ -0,0 +1,485 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { generateKeyPair, SignJWT } from "jose"; + +type CapturedRequest = { + path: string; + authorization: string | null; + model: string | null; + stream: boolean; +}; + +const freePort = (): number => { + const server = Bun.serve({ port: 0, fetch: () => new Response("reserved") }); + const port = server.port; + server.stop(true); + if (port === undefined) throw new Error("Could not allocate a verification port"); + return port; +}; + +const waitForController = async (url: string): Promise => { + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + try { + const response = await fetch(`${url}/health`); + if (response.ok) return; + } catch {} + await Bun.sleep(100); + } + throw new Error("Isolated controller did not become ready"); +}; + +const jsonRequest = async ( + url: string, + path: string, + init?: RequestInit, +): Promise<{ response: Response; body: Record }> => { + const response = await fetch(`${url}${path}`, init); + const body = (await response.json()) as Record; + return { response, body }; +}; + +test("isolated controller routes keyless and keyed OpenAI providers over real HTTP", async () => { + const root = mkdtempSync(join(tmpdir(), "local-studio-provider-integration-")); + const dataDirectory = join(root, "data"); + const modelsDirectory = join(root, "models"); + mkdirSync(dataDirectory, { recursive: true }); + mkdirSync(modelsDirectory, { recursive: true }); + const captured: CapturedRequest[] = []; + let fallbackHits = 0; + const fallback = Bun.serve({ + port: 0, + fetch: () => { + fallbackHits += 1; + return Response.json({ data: [{ id: "fallback-model" }] }); + }, + }); + const upstream = Bun.serve({ + port: 0, + fetch: async (request) => { + const url = new URL(request.url); + if (url.pathname === "/redirect/v1/models") { + return Response.redirect(`http://127.0.0.1:${fallback.port}/v1/models`, 302); + } + const body = + request.method === "POST" ? ((await request.json()) as Record) : {}; + captured.push({ + path: url.pathname, + authorization: request.headers.get("authorization"), + model: typeof body["model"] === "string" ? body["model"] : null, + stream: body["stream"] === true, + }); + const prefix = url.pathname.split("/")[1] ?? ""; + const model = prefix === "keyed" ? "keyed-model" : "model-a"; + if (url.pathname.endsWith("/v1/models")) { + return Response.json({ object: "list", data: [{ id: model, object: "model" }] }); + } + if (url.pathname.endsWith("/v1/chat/completions")) { + if (body["stream"] === true) { + return new Response( + `data: ${JSON.stringify({ + id: "chat", + choices: [{ index: 0, delta: { content: "4" } }], + })}\n\ndata: [DONE]\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ); + } + return Response.json({ + id: "chat", + object: "chat.completion", + model: body["model"], + choices: [ + { index: 0, finish_reason: "stop", message: { role: "assistant", content: "4" } }, + ], + }); + } + return new Response("not found", { status: 404 }); + }, + }); + const controllerPort = freePort(); + const controllerUrl = `http://127.0.0.1:${controllerPort}`; + const controllerEnvironment = { + ...process.env, + LOCAL_STUDIO_HOST: "127.0.0.1", + LOCAL_STUDIO_PORT: String(controllerPort), + LOCAL_STUDIO_DATA_DIR: dataDirectory, + LOCAL_STUDIO_MODELS_DIR: modelsDirectory, + LOCAL_STUDIO_INFERENCE_HOST: "127.0.0.1", + LOCAL_STUDIO_INFERENCE_PORT: String(fallback.port), + LOCAL_STUDIO_DISABLE_METRICS: "true", + LOCAL_STUDIO_PROVIDER_HOST_ALLOWLIST: "127.0.0.1", + LOCAL_STUDIO_KUBERAY_API_URL: "", + LOCAL_STUDIO_KUBERAY_TOKEN_FILE: "", + LOCAL_STUDIO_KUBERAY_CA_FILE: "", + LOCAL_STUDIO_ENTERPRISE_AUTH_CONFIG: "", + }; + const output: string[] = []; + let controller = Bun.spawn([process.execPath, "src/main.ts"], { + cwd: join(import.meta.dir, ".."), + env: controllerEnvironment, + stdout: "pipe", + stderr: "pipe", + }); + const captureOutput = async (process: Bun.Subprocess): Promise => { + const stdout = + process.stdout && typeof process.stdout !== "number" + ? await new Response(process.stdout).text() + : ""; + const stderr = + process.stderr && typeof process.stderr !== "number" + ? await new Response(process.stderr).text() + : ""; + output.push(stdout, stderr); + }; + const stopController = async (): Promise => { + controller.kill("SIGTERM"); + await controller.exited; + await captureOutput(controller); + }; + try { + await waitForController(controllerUrl); + const initial = await jsonRequest(controllerUrl, "/studio/providers"); + expect(initial.body["providers"]).toEqual([]); + + const probe = await jsonRequest(controllerUrl, "/studio/providers/probe", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "keyless", + name: "Keyless", + base_url: `http://127.0.0.1:${upstream.port}/keyless/v1/`, + authentication: { type: "none" }, + }), + }); + expect(probe.response.status).toBe(200); + expect(probe.body).toEqual({ provider: "keyless", models: [{ id: "model-a" }] }); + const afterProbe = await jsonRequest(controllerUrl, "/studio/providers"); + expect(afterProbe.body["providers"]).toEqual([]); + + const created = await jsonRequest(controllerUrl, "/studio/providers", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "keyless", + name: "Keyless", + base_url: `http://127.0.0.1:${upstream.port}/keyless`, + authentication: { type: "none" }, + }), + }); + expect(created.response.status).toBe(200); + const providers = await jsonRequest(controllerUrl, "/studio/providers"); + expect((providers.body["providers"] as unknown[]).length).toBe(1); + + const catalog = await jsonRequest(controllerUrl, "/studio/provider-models"); + expect(catalog.body).toEqual({ + providers: [{ provider: "keyless", models: [{ id: "model-a" }] }], + }); + const models = await jsonRequest(controllerUrl, "/v1/models"); + expect((models.body["data"] as Array<{ id: string }>).map(({ id }) => id)).toContain( + "keyless/model-a", + ); + + const nonStreaming = await jsonRequest(controllerUrl, "/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "keyless/model-a", + messages: [{ role: "user", content: "Reply with exactly 4." }], + stream: false, + }), + }); + expect(nonStreaming.response.status).toBe(200); + expect( + (nonStreaming.body["choices"] as Array<{ message: { content: string } }>)[0]?.message.content, + ).toBe("4"); + + const streaming = await fetch(`${controllerUrl}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "keyless/model-a", + messages: [{ role: "user", content: "Reply with exactly 4." }], + stream: true, + }), + }); + const streamBody = await streaming.text(); + expect(streaming.status).toBe(200); + expect(streamBody).toContain('"content":"4"'); + expect(streamBody).toContain("data: [DONE]"); + + const secret = "integration-secret-value"; + const keyed = await jsonRequest(controllerUrl, "/studio/providers", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "keyed", + name: "Keyed", + base_url: `http://127.0.0.1:${upstream.port}/keyed`, + api_key: secret, + authentication: { type: "api_key" }, + }), + }); + expect(keyed.response.status).toBe(200); + const keyedChat = await jsonRequest(controllerUrl, "/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "keyed/keyed-model", + messages: [{ role: "user", content: "Reply with exactly 4." }], + }), + }); + expect(keyedChat.response.status).toBe(200); + + const disabled = await jsonRequest(controllerUrl, "/studio/providers", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "disabled", + name: "Disabled", + base_url: `http://127.0.0.1:${upstream.port}/disabled`, + enabled: false, + authentication: { type: "none" }, + }), + }); + expect(disabled.response.status).toBe(200); + const fallbackBeforeDenied = fallbackHits; + for (const model of ["unknown/model-a", "disabled/model-a"]) { + const denied = await fetch(`${controllerUrl}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model, messages: [{ role: "user", content: "4" }] }), + }); + expect(denied.status).toBe(404); + } + expect(fallbackHits).toBe(fallbackBeforeDenied); + + const redirectProbe = await fetch(`${controllerUrl}/studio/providers/probe`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "redirected", + name: "Redirected", + base_url: `http://127.0.0.1:${upstream.port}/redirect`, + authentication: { type: "none" }, + }), + }); + expect(redirectProbe.status).toBe(503); + expect(fallbackHits).toBe(fallbackBeforeDenied); + + const deniedHost = await fetch(`${controllerUrl}/studio/providers`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "denied-host", + name: "Denied host", + base_url: "http://example.invalid", + authentication: { type: "none" }, + }), + }); + expect(deniedHost.status).toBe(400); + + expect( + captured + .filter(({ path }) => path.startsWith("/keyless/")) + .every(({ authorization }) => authorization === null), + ).toBe(true); + expect( + captured.some( + ({ path, authorization, model }) => + path === "/keyed/v1/chat/completions" && + authorization === `Bearer ${secret}` && + model === "keyed-model", + ), + ).toBe(true); + + await stopController(); + const settingsPath = join(dataDirectory, "studio-settings.json"); + const persisted = JSON.parse(readFileSync(settingsPath, "utf8")) as { + providers: Array>; + }; + persisted.providers.push({ + id: "legacy", + name: "Legacy keyless", + base_url: `http://127.0.0.1:${upstream.port}/keyless`, + api_key: "", + enabled: true, + }); + writeFileSync(settingsPath, JSON.stringify(persisted, null, 2)); + controller = Bun.spawn([process.execPath, "src/main.ts"], { + cwd: join(import.meta.dir, ".."), + env: controllerEnvironment, + stdout: "pipe", + stderr: "pipe", + }); + await waitForController(controllerUrl); + const restarted = await jsonRequest(controllerUrl, "/studio/providers"); + const legacy = (restarted.body["providers"] as Array>).find( + ({ id }) => id === "legacy", + ); + expect(legacy?.["authentication"]).toEqual({ type: "none" }); + const legacyChat = await jsonRequest(controllerUrl, "/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "legacy/model-a", + messages: [{ role: "user", content: "Reply with exactly 4." }], + }), + }); + expect(legacyChat.response.status).toBe(200); + await stopController(); + expect(output.join("")).not.toContain(secret); + const controllerLog = readFileSync(join(dataDirectory, "logs", "vllm_controller.log"), "utf8"); + expect(controllerLog).not.toContain(secret); + } finally { + if (controller.exitCode === null) { + controller.kill("SIGTERM"); + await controller.exited; + } + upstream.stop(true); + fallback.stop(true); + rmSync(root, { recursive: true, force: true }); + } +}, 60_000); + +test("isolated controller probes and creates an apim_client provider with client_secret", async () => { + const root = mkdtempSync(join(tmpdir(), "local-studio-apim-integration-")); + const dataDirectory = join(root, "data"); + const modelsDirectory = join(root, "models"); + mkdirSync(dataDirectory, { recursive: true }); + mkdirSync(modelsDirectory, { recursive: true }); + const keys = await generateKeyPair("RS256"); + const tokenServer = Bun.serve({ + port: 0, + async fetch() { + const accessToken = await new SignJWT({ + aud: "api://gateway", + scp: "models.invoke", + }) + .setProtectedHeader({ alg: "RS256" }) + .setIssuedAt() + .setExpirationTime("10m") + .sign(keys.privateKey); + return Response.json({ + access_token: accessToken, + expires_in: 600, + token_type: "Bearer", + }); + }, + }); + let upstreamAuthorization: string | null = null; + const upstream = Bun.serve({ + port: 0, + async fetch(request) { + const url = new URL(request.url); + upstreamAuthorization = request.headers.get("authorization"); + if (url.pathname.endsWith("/v1/models")) { + return Response.json({ data: [{ id: "gateway-model" }] }); + } + return new Response("not found", { status: 404 }); + }, + }); + const controllerPort = freePort(); + const controllerUrl = `http://127.0.0.1:${controllerPort}`; + const controllerEnvironment = { + ...process.env, + LOCAL_STUDIO_HOST: "127.0.0.1", + LOCAL_STUDIO_PORT: String(controllerPort), + LOCAL_STUDIO_DATA_DIR: dataDirectory, + LOCAL_STUDIO_MODELS_DIR: modelsDirectory, + LOCAL_STUDIO_INFERENCE_HOST: "127.0.0.1", + LOCAL_STUDIO_INFERENCE_PORT: String(upstream.port), + LOCAL_STUDIO_DISABLE_METRICS: "true", + LOCAL_STUDIO_PROVIDER_HOST_ALLOWLIST: "127.0.0.1", + LOCAL_STUDIO_KUBERAY_API_URL: "", + LOCAL_STUDIO_KUBERAY_TOKEN_FILE: "", + LOCAL_STUDIO_KUBERAY_CA_FILE: "", + LOCAL_STUDIO_ENTERPRISE_AUTH_CONFIG: "", + }; + const controller = Bun.spawn([process.execPath, "src/main.ts"], { + cwd: join(import.meta.dir, ".."), + env: controllerEnvironment, + stdout: "pipe", + stderr: "pipe", + }); + const stopController = async (): Promise => { + controller.kill("SIGTERM"); + await controller.exited; + }; + try { + await waitForController(controllerUrl); + const clientSecret = "apim-client-secret-value"; + const probe = await jsonRequest(controllerUrl, "/studio/providers/probe", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "apim-gw", + name: "APIM Gateway", + base_url: `http://127.0.0.1:${upstream.port}/v1`, + client_secret: clientSecret, + authentication: { + type: "apim_client", + issuer_id: "issuer-01", + audience: "api://gateway", + scopes: ["models.invoke"], + token_endpoint: `http://127.0.0.1:${tokenServer.port}`, + client_id: "local-studio", + }, + }), + }); + expect(probe.response.status).toBe(200); + expect(probe.body).toEqual({ provider: "apim-gw", models: [{ id: "gateway-model" }] }); + expect(upstreamAuthorization).toMatch(/^Bearer /); + expect(upstreamAuthorization).not.toContain(clientSecret); + + const created = await jsonRequest(controllerUrl, "/studio/providers", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "apim-gw", + name: "APIM Gateway", + base_url: `http://127.0.0.1:${upstream.port}/v1`, + client_secret: clientSecret, + authentication: { + type: "apim_client", + issuer_id: "issuer-01", + audience: "api://gateway", + scopes: ["models.invoke"], + token_endpoint: `http://127.0.0.1:${tokenServer.port}`, + client_id: "local-studio", + }, + }), + }); + expect(created.response.status).toBe(200); + const providerBody = created.body["provider"] as Record; + const auth = providerBody["authentication"] as Record; + expect(auth["type"]).toBe("apim_client"); + expect(auth["issuer_id"]).toBe("issuer-01"); + expect(auth["audience"]).toBe("api://gateway"); + expect(auth["client_id"]).toBe("local-studio"); + expect(auth["token_endpoint"]).toBe(`http://127.0.0.1:${tokenServer.port}`); + expect(auth).not.toHaveProperty("client_secret"); + expect(auth["client_secret_ref"]).toMatch(/^provider:apim-gw:client-secret:/u); + + await stopController(); + const settingsPath = join(dataDirectory, "studio-settings.json"); + const persisted = JSON.parse(readFileSync(settingsPath, "utf8")) as { + providers: Array>; + }; + const persistedProvider = persisted.providers.find((p) => p["id"] === "apim-gw"); + expect(persistedProvider).toBeDefined(); + const persistedAuth = persistedProvider?.["authentication"] as Record; + expect(persistedAuth["client_secret_ref"]).toMatch(/^provider:apim-gw:client-secret:/u); + const settingsJson = readFileSync(settingsPath, "utf8"); + expect(settingsJson).not.toContain(clientSecret); + } finally { + if (controller.exitCode === null) { + controller.kill("SIGTERM"); + await controller.exited; + } + tokenServer.stop(true); + upstream.stop(true); + rmSync(root, { recursive: true, force: true }); + } +}, 60_000); diff --git a/controller/tests/provider-routing.test.ts b/controller/tests/provider-routing.test.ts new file mode 100644 index 000000000..05f0ae56f --- /dev/null +++ b/controller/tests/provider-routing.test.ts @@ -0,0 +1,325 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Effect } from "effect"; +import { + normalizeOpenAIBaseUrl, + openAIEndpoint, + providerChatEndpoint, + providerModelsEndpoint, +} from "../../shared/agent/openai-endpoint"; +import { loadPersistedConfig } from "../src/config/persisted-config"; +import { discoverScientificModelCatalog } from "../src/modules/workbench/service"; +import { buildChatCompletionsStreamResponse } from "../src/modules/proxy/chat-completions-stream"; +import { + discoverProviderModels, + isReservedProviderId, + resolveProviderModelRoute, +} from "../src/services/provider-routing"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("OpenAI-compatible endpoint normalization", () => { + test.each([ + "http://example.test", + "http://example.test/", + "http://example.test/v1", + "http://example.test/v1/", + ])("normalizes %s to one API version segment", (value) => { + expect(normalizeOpenAIBaseUrl(value)).toBe("http://example.test/v1"); + expect(openAIEndpoint(value, "models")).toBe("http://example.test/v1/models"); + expect(openAIEndpoint(value, "chat/completions")).toBe( + "http://example.test/v1/chat/completions", + ); + }); + + test("preserves a gateway prefix while adding one API version segment", () => { + expect(openAIEndpoint("https://gateway.test/ai/v1/", "responses")).toBe( + "https://gateway.test/ai/v1/responses", + ); + }); + + test("collapses repeated terminal API version segments", () => { + expect(openAIEndpoint("https://gateway.test/ai/v1/v1/", "models")).toBe( + "https://gateway.test/ai/v1/models", + ); + }); + + test("rejects embedded credentials and unsupported protocols", () => { + expect(() => normalizeOpenAIBaseUrl("ssh://example.test")).toThrow(); + expect(() => normalizeOpenAIBaseUrl("https://user:pass@example.test")).toThrow(); + }); + + test("routes OpenAI and Azure chat paths by provider path style", () => { + expect(providerChatEndpoint("https://gateway.test/openai", "model-a", "openai", undefined)).toBe( + "https://gateway.test/openai/v1/chat/completions", + ); + expect(providerChatEndpoint("https://gateway.test/openai", "model-a", "azure", "2024-10-21")).toBe( + "https://gateway.test/openai/deployments/model-a/chat/completions?api-version=2024-10-21", + ); + expect(providerChatEndpoint("https://gateway.test/openai/v1", "model-a", "azure", undefined)).toBe( + "https://gateway.test/openai/deployments/model-a/chat/completions?api-version=2024-10-21", + ); + expect(providerChatEndpoint("https://gateway.test/openai", "model a/b", "azure", "2024-10-21")).toBe( + "https://gateway.test/openai/deployments/model%20a%2Fb/chat/completions?api-version=2024-10-21", + ); + }); + + test("routes OpenAI and Azure model discovery paths by provider path style", () => { + expect(providerModelsEndpoint("https://gateway.test/openai", "openai", undefined)).toBe( + "https://gateway.test/openai/v1/models", + ); + expect(providerModelsEndpoint("https://gateway.test/openai", undefined, undefined)).toBe( + "https://gateway.test/openai/v1/models", + ); + expect(providerModelsEndpoint("https://gateway.test/openai", "azure", "2024-10-21")).toBe( + "https://gateway.test/openai/deployments?api-version=2024-10-21", + ); + expect(providerModelsEndpoint("https://gateway.test/openai/v1", "azure", undefined)).toBe( + "https://gateway.test/openai/deployments?api-version=2024-10-21", + ); + }); +}); + +describe("provider routing", () => { + test("routes an enabled keyless provider without an authorization value", () => { + const route = resolveProviderModelRoute("tensorprime/model-a", { + providers: [ + { + id: "tensorprime", + name: "TensorPrime", + base_url: "http://api.test/v1/", + enabled: true, + authentication: { type: "none" }, + }, + ], + }); + expect(route).toMatchObject({ + kind: "remote", + provider: "tensorprime", + modelId: "model-a", + config: { baseUrl: "http://api.test/v1" }, + }); + }); + + test("fails closed for unknown, disabled, and unresolved credential providers", () => { + const providers = [ + { + id: "disabled", + name: "Disabled", + base_url: "http://disabled.test", + enabled: false, + authentication: { type: "none" as const }, + }, + { + id: "locked", + name: "Locked", + base_url: "http://locked.test", + enabled: true, + authentication: { type: "api_key" as const }, + }, + ]; + expect(resolveProviderModelRoute("missing/model-a", { providers }).kind).toBe("unavailable"); + expect(resolveProviderModelRoute("disabled/model-a", { providers }).kind).toBe("unavailable"); + expect(resolveProviderModelRoute("locked/model-a", { providers }).kind).toBe("unavailable"); + expect(resolveProviderModelRoute("model-a", { providers }).kind).toBe("local"); + }); + + test("keeps a matched local recipe local when its model id contains a slash", () => { + expect(resolveProviderModelRoute("org/model-a", {}, true)).toEqual({ + kind: "local", + provider: "openai", + modelId: "model-a", + }); + }); + + test("reserves the local provider identifier case-insensitively", () => { + expect(isReservedProviderId("openai")).toBe(true); + expect(isReservedProviderId(" OpenAI ")).toBe(true); + expect(isReservedProviderId("tensorprime")).toBe(false); + }); +}); + +describe("keyless discovery and streaming", () => { + test("discovers models without an Authorization header", async () => { + let observedUrl = ""; + let observedAuthorization: string | null = "missing"; + let observedRedirect: RequestRedirect | undefined; + const result = await Effect.runPromise( + discoverProviderModels( + { + id: "tensorprime", + name: "TensorPrime", + base_url: "http://api.test/v1", + enabled: true, + authentication: { type: "none" }, + }, + async (input, init) => { + observedUrl = String(input); + observedAuthorization = new Headers(init?.headers).get("authorization"); + observedRedirect = init?.redirect; + return Response.json({ data: [{ id: "model-a" }] }); + }, + ), + ); + expect(observedUrl).toBe("http://api.test/v1/models"); + expect(observedAuthorization).toBeNull(); + expect(observedRedirect).toBe("error"); + expect(result).toEqual({ provider: "tensorprime", models: [{ id: "model-a" }] }); + }); + + test("discovers models from the Azure deployments endpoint when path_style is azure", async () => { + let observedUrl = ""; + const result = await Effect.runPromise( + discoverProviderModels( + { + id: "azure-prod", + name: "Azure Production", + base_url: "https://myresource.openai.azure.com/openai", + enabled: true, + authentication: { type: "none" }, + path_style: "azure", + api_version: "2024-10-21", + }, + async (input) => { + observedUrl = String(input); + return Response.json({ data: [{ id: "dep-gpt-4" }, { id: "dep-gpt-35" }] }); + }, + ), + ); + expect(observedUrl).toBe( + "https://myresource.openai.azure.com/openai/deployments?api-version=2024-10-21", + ); + expect(result).toEqual({ + provider: "azure-prod", + models: [{ id: "dep-gpt-4" }, { id: "dep-gpt-35" }], + }); + }); + + test("uses the same normalized keyless endpoint for scientific admission", async () => { + let observedUrl = ""; + let observedAuthorization: string | null = "missing"; + let observedRedirect: RequestRedirect | undefined; + const catalog = await Effect.runPromise( + discoverScientificModelCatalog( + [ + { + id: "tensorprime", + name: "TensorPrime", + base_url: "http://api.test/", + enabled: true, + authentication: { type: "none" }, + }, + ], + async (input, init) => { + observedUrl = String(input); + observedAuthorization = new Headers(init?.headers).get("authorization"); + observedRedirect = init?.redirect; + return Response.json({ data: [{ id: "model-a" }] }); + }, + ), + ); + expect(observedUrl).toBe("http://api.test/v1/models"); + expect(observedAuthorization).toBeNull(); + expect(observedRedirect).toBe("error"); + expect(catalog.get("tensorprime")).toEqual(new Set(["model-a"])); + }); + + test("streams a keyless provider response from the normalized chat endpoint", async () => { + const originalFetch = globalThis.fetch; + let observedUrl = ""; + let observedAuthorization: string | null = "missing"; + let observedRedirect: RequestRedirect | undefined; + globalThis.fetch = (async (input, init) => { + observedUrl = String(input); + observedAuthorization = new Headers(init?.headers).get("authorization"); + observedRedirect = init?.redirect; + return new Response( + 'data: {"id":"c","choices":[{"index":0,"delta":{"content":"4"}}]}\n\ndata: [DONE]\n\n', + { headers: { "content-type": "text/event-stream" } }, + ); + }) as typeof fetch; + try { + const response = buildChatCompletionsStreamResponse({ + upstreamUrl: openAIEndpoint("http://api.test/v1/", "chat/completions"), + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "model-a", stream: true }), + clientSignal: new AbortController().signal, + matchedRecipe: null, + sourceHeader: null, + sessionId: null, + recordedModel: "model-a", + recordedProvider: "tensorprime", + requestStart: performance.now(), + requestProvider: "tensorprime", + providerRouting: { + baseUrl: "http://api.test/v1", + provider: { + id: "tensorprime", + name: "TensorPrime", + base_url: "http://api.test/v1", + enabled: true, + authentication: { type: "none" }, + }, + }, + context: { + logger: { + error: () => undefined, + warn: () => undefined, + }, + stores: {}, + } as never, + keepaliveIntervalMs: 60_000, + }); + const body = await response.text(); + expect(observedUrl).toBe("http://api.test/v1/chat/completions"); + expect(observedAuthorization).toBeNull(); + expect(observedRedirect).toBe("error"); + expect(body).toContain('"content":"4"'); + expect(body).toContain("data: [DONE]"); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + +describe("legacy provider migration", () => { + test("maps records with keys to api_key and empty records to none", () => { + const directory = mkdtempSync(join(tmpdir(), "local-studio-provider-")); + temporaryDirectories.push(directory); + writeFileSync( + join(directory, "studio-settings.json"), + JSON.stringify({ + providers: [ + { + id: "keyed", + name: "Keyed", + base_url: "http://127.0.0.1:8101", + api_key: "secret", + enabled: true, + }, + { + id: "keyless", + name: "Keyless", + base_url: "http://127.0.0.1:8102", + api_key: "", + enabled: true, + }, + ], + }), + ); + const providers = loadPersistedConfig(directory).providers ?? []; + const authentication = providers[0]?.authentication; + expect(authentication?.type).toBe("api_key"); + if (authentication?.type !== "api_key") throw new Error("Expected API-key migration"); + expect(authentication.secret_ref).toMatch(/^provider:keyed:api-key:[a-f0-9]{32}$/u); + expect(providers[1]?.authentication).toEqual({ type: "none" }); + }); +}); diff --git a/controller/tests/provider-security.test.ts b/controller/tests/provider-security.test.ts new file mode 100644 index 000000000..384a8b555 --- /dev/null +++ b/controller/tests/provider-security.test.ts @@ -0,0 +1,818 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createCipheriv, createHash, randomBytes } from "node:crypto"; +import { + linkSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { NormalizedPrincipal } from "@local-studio/contracts/enterprise-auth"; +import { Effect } from "effect"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import type { ProviderConfig } from "../src/config/persisted-config"; +import { loadPersistedConfig, savePersistedConfig } from "../src/config/persisted-config"; +import { buildChatCompletionsStreamResponse } from "../src/modules/proxy/chat-completions-stream"; +import { + assertProviderOutboundUrl, + type ProviderHostnameLookup, +} from "../src/services/provider-boundary"; +import { resolveProviderHeaders } from "../src/services/provider-authentication"; +import { EnterpriseTokenVerifier } from "../src/http/enterprise-auth"; +import { + ProviderSecretStore, + newProviderApiKeyReference, + newProviderClientSecretReference, + newProviderSubscriptionKeyReference, + providerApiKeyReference, +} from "../src/services/provider-secret-store"; +import { discoverProviderModels } from "../src/services/provider-routing"; + +const originalFetch = globalThis.fetch; +const originalEnvironment = { + masterKey: process.env["LOCAL_STUDIO_PROVIDER_MASTER_KEY"], + masterKeyId: process.env["LOCAL_STUDIO_PROVIDER_MASTER_KEY_ID"], + previousMasterKeys: process.env["LOCAL_STUDIO_PROVIDER_PREVIOUS_MASTER_KEYS"], + providerHosts: process.env["LOCAL_STUDIO_PROVIDER_HOST_ALLOWLIST"], + privateHosts: process.env["LOCAL_STUDIO_PROVIDER_PRIVATE_HOST_ALLOWLIST"], + managedIdentityEndpoint: process.env["LOCAL_STUDIO_MANAGED_IDENTITY_ENDPOINT"], +}; +const temporaryDirectories: string[] = []; +const temporaryServers: Array<{ stop(closeActiveConnections?: boolean): void }> = []; + +const restoreEnvironment = (name: string, value: string | undefined): void => { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; +}; + +afterEach(() => { + globalThis.fetch = originalFetch; + restoreEnvironment("LOCAL_STUDIO_PROVIDER_MASTER_KEY", originalEnvironment.masterKey); + restoreEnvironment("LOCAL_STUDIO_PROVIDER_MASTER_KEY_ID", originalEnvironment.masterKeyId); + restoreEnvironment( + "LOCAL_STUDIO_PROVIDER_PREVIOUS_MASTER_KEYS", + originalEnvironment.previousMasterKeys, + ); + restoreEnvironment("LOCAL_STUDIO_PROVIDER_HOST_ALLOWLIST", originalEnvironment.providerHosts); + restoreEnvironment( + "LOCAL_STUDIO_PROVIDER_PRIVATE_HOST_ALLOWLIST", + originalEnvironment.privateHosts, + ); + restoreEnvironment( + "LOCAL_STUDIO_MANAGED_IDENTITY_ENDPOINT", + originalEnvironment.managedIdentityEndpoint, + ); + for (const server of temporaryServers.splice(0)) server.stop(true); + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +const temporaryDirectory = (): string => { + const directory = mkdtempSync(join(tmpdir(), "local-studio-provider-security-")); + temporaryDirectories.push(directory); + return directory; +}; + +const masterKey = (byte: string): string => byte.repeat(64); + +const principal = (overrides: Partial = {}): NormalizedPrincipal => ({ + subject: "scientist-01", + issuer: "https://issuer.test/realm", + issuer_id: "issuer-01", + tenant: "tenant-01", + display_name: "Scientist", + roles: ["scientist"], + entitlements: ["model:invoke"], + clearance: "C2", + issued_at: Math.floor(Date.now() / 1000) - 10, + expires_at: Math.floor(Date.now() / 1000) + 600, + ...overrides, +}); + +const delegatedToken = (claims: Record): string => { + const encode = (value: unknown): string => + Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${encode({ alg: "none" })}.${encode(claims)}.signature`; +}; + +const managedIdentityProvider = (resource = "https://gateway.test"): ProviderConfig => ({ + id: "azure", + name: "Azure", + base_url: "https://gateway.test/v1", + enabled: true, + authentication: { + type: "managed_identity", + resource, + }, +}); + +describe("provider secret storage", () => { + test("encrypts values and fails closed for a wrong key or corrupted ciphertext", () => { + const directory = temporaryDirectory(); + const ref = providerApiKeyReference("tensorprime"); + const secret = "not-visible-in-storage"; + process.env["LOCAL_STUDIO_PROVIDER_MASTER_KEY"] = masterKey("1"); + const store = new ProviderSecretStore(directory, true); + store.writeSync(ref, secret); + const secretDirectory = join(directory, "provider-secrets"); + const blob = readdirSync(secretDirectory).find((entry) => entry.endsWith(".bin")); + expect(blob).toBeDefined(); + const path = join(secretDirectory, blob!); + expect(readFileSync(path).includes(Buffer.from(secret))).toBe(false); + const alias = join(secretDirectory, "credential-alias.bin"); + linkSync(path, alias); + expect(() => store.readSync(ref)).toThrow("Provider credential could not be read"); + unlinkSync(alias); + + process.env["LOCAL_STUDIO_PROVIDER_MASTER_KEY"] = masterKey("2"); + const wrongKeyStore = new ProviderSecretStore(directory, true); + expect(() => wrongKeyStore.readSync(ref)).toThrow("Provider credential could not be read"); + + process.env["LOCAL_STUDIO_PROVIDER_MASTER_KEY"] = masterKey("1"); + const bytes = readFileSync(path); + bytes[bytes.length - 1] = bytes[bytes.length - 1]! ^ 0xff; + writeFileSync(path, bytes); + expect(() => store.readSync(ref)).toThrow("Provider credential could not be read"); + }); + + test("imports legacy plaintext, scrubs settings, and survives restart", () => { + const directory = temporaryDirectory(); + process.env["LOCAL_STUDIO_PROVIDER_MASTER_KEY"] = masterKey("3"); + writeFileSync( + join(directory, "studio-settings.json"), + JSON.stringify({ + providers: [ + { + id: "legacy", + name: "Legacy", + base_url: "http://127.0.0.1:8101", + api_key: "legacy-secret", + enabled: true, + }, + ], + }), + ); + const firstStore = new ProviderSecretStore(directory, true); + const first = loadPersistedConfig(directory, firstStore); + const persisted = readFileSync(join(directory, "studio-settings.json"), "utf8"); + const migratedAuthentication = first.providers?.[0]?.authentication; + expect(migratedAuthentication?.type).toBe("api_key"); + const ref = + migratedAuthentication?.type === "api_key" ? migratedAuthentication.secret_ref : undefined; + expect(ref).toMatch(/^provider:legacy:api-key:[a-f\d]{32}$/); + expect(persisted).not.toContain("legacy-secret"); + expect( + Object.hasOwn(JSON.parse(persisted).providers[0] as Record, "api_key"), + ).toBe(false); + + const restartedStore = new ProviderSecretStore(directory, true); + const restarted = loadPersistedConfig(directory, restartedStore); + expect(restarted.providers?.[0]?.authentication).toEqual({ + type: "api_key", + secret_ref: ref, + }); + expect(restartedStore.readSync(ref!)).toBe("legacy-secret"); + }); + + test("keeps credential revisions immutable across an interrupted configuration switch", () => { + const directory = temporaryDirectory(); + process.env["LOCAL_STUDIO_PROVIDER_MASTER_KEY"] = masterKey("5"); + const store = new ProviderSecretStore(directory, true); + const first = newProviderApiKeyReference("revisioned"); + const second = newProviderApiKeyReference("revisioned"); + store.writeSync(first, "first-value"); + store.writeSync(second, "second-value"); + expect(first).not.toBe(second); + expect(store.readSync(first)).toBe("first-value"); + expect(store.readSync(second)).toBe("second-value"); + store.reconcileSync(new Set([first])); + expect(store.readSync(first)).toBe("first-value"); + expect(store.readSync(second)).toBeUndefined(); + }); + + test("rewraps a previous-key envelope with the active key and survives key retirement", () => { + const directory = temporaryDirectory(); + const reference = newProviderApiKeyReference("rotated"); + process.env["LOCAL_STUDIO_PROVIDER_MASTER_KEY"] = masterKey("1"); + process.env["LOCAL_STUDIO_PROVIDER_MASTER_KEY_ID"] = "provider-2026-01"; + const initial = new ProviderSecretStore(directory, true); + initial.writeSync(reference, "rotated-secret"); + const secretDirectory = join(directory, "provider-secrets"); + const blob = join( + secretDirectory, + readdirSync(secretDirectory).find((entry) => entry.endsWith(".bin"))!, + ); + const previousEnvelope = readFileSync(blob); + + process.env["LOCAL_STUDIO_PROVIDER_MASTER_KEY"] = masterKey("2"); + process.env["LOCAL_STUDIO_PROVIDER_MASTER_KEY_ID"] = "provider-2026-07"; + process.env["LOCAL_STUDIO_PROVIDER_PREVIOUS_MASTER_KEYS"] = JSON.stringify({ + "provider-2026-01": masterKey("1"), + }); + const rotated = new ProviderSecretStore(directory, true); + expect(rotated.readSync(reference)).toBe("rotated-secret"); + expect(readFileSync(blob).equals(previousEnvelope)).toBe(false); + + delete process.env["LOCAL_STUDIO_PROVIDER_PREVIOUS_MASTER_KEYS"]; + const retired = new ProviderSecretStore(directory, true); + expect(retired.readSync(reference)).toBe("rotated-secret"); + }); + + test("rewraps the legacy envelope format on authenticated read", () => { + const directory = temporaryDirectory(); + const reference = providerApiKeyReference("legacy-envelope"); + process.env["LOCAL_STUDIO_PROVIDER_MASTER_KEY"] = masterKey("3"); + process.env["LOCAL_STUDIO_PROVIDER_MASTER_KEY_ID"] = "provider-current"; + const secretDirectory = join(directory, "provider-secrets"); + mkdirSync(secretDirectory, { recursive: true }); + const blob = join( + secretDirectory, + `${createHash("sha256").update(reference).digest("hex")}.bin`, + ); + const key = Buffer.from(masterKey("3"), "hex"); + const nonce = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, nonce); + cipher.setAAD(Buffer.from(reference)); + const encrypted = Buffer.concat([cipher.update("legacy-value", "utf8"), cipher.final()]); + writeFileSync(blob, Buffer.concat([Buffer.from([1]), nonce, cipher.getAuthTag(), encrypted]), { + mode: 0o600, + }); + + const store = new ProviderSecretStore(directory, true); + expect(store.readSync(reference)).toBe("legacy-value"); + expect(readFileSync(blob)[0]).toBe(2); + }); + + test("rolls back failed persistence and reconciles orphan blobs", () => { + const directory = temporaryDirectory(); + process.env["LOCAL_STUDIO_PROVIDER_MASTER_KEY"] = masterKey("4"); + const store = new ProviderSecretStore(directory, true); + const activeRef = providerApiKeyReference("active"); + const orphanRef = providerApiKeyReference("orphan"); + store.writeSync(activeRef, "old-secret"); + expect(() => + store.mutateSync([{ ref: activeRef, value: "new-secret" }], () => { + throw new Error("settings write failed"); + }), + ).toThrow("Provider secret transaction failed"); + expect(store.readSync(activeRef)).toBe("old-secret"); + store.writeSync(orphanRef, "orphan-secret"); + store.reconcileSync(new Set([activeRef])); + expect(store.readSync(orphanRef)).toBeUndefined(); + expect(store.readSync(activeRef)).toBe("old-secret"); + }); + + test("commits provider deletion before reconciliation and recovers after interruption", () => { + const directory = temporaryDirectory(); + process.env["LOCAL_STUDIO_PROVIDER_MASTER_KEY"] = masterKey("9"); + const store = new ProviderSecretStore(directory, true); + const reference = newProviderApiKeyReference("deletion-order"); + store.writeSync(reference, "deletion-secret"); + const provider: ProviderConfig = { + id: "deletion-order", + name: "Deletion order", + base_url: "https://gateway.test/v1", + enabled: true, + authentication: { type: "api_key", secret_ref: reference }, + }; + savePersistedConfig(directory, { providers: [provider] }, store); + + class InterruptedReconciliationStore extends ProviderSecretStore { + committed = false; + + override reconcileSync(): void { + const persisted = JSON.parse( + readFileSync(join(directory, "studio-settings.json"), "utf8"), + ) as { providers?: unknown[] }; + this.committed = persisted.providers?.length === 0; + throw new Error("simulated interruption after config commit"); + } + } + + const interruptedStore = new InterruptedReconciliationStore(directory, true); + expect(() => savePersistedConfig(directory, { providers: [] }, interruptedStore)).toThrow( + "simulated interruption after config commit", + ); + expect(interruptedStore.committed).toBe(true); + expect(store.readSync(reference)).toBe("deletion-secret"); + + const restartedStore = new ProviderSecretStore(directory, true); + expect(loadPersistedConfig(directory, restartedStore).providers).toEqual([]); + expect(restartedStore.readSync(reference)).toBeUndefined(); + }); +}); + +describe("provider authentication", () => { + test("deduplicates managed identity refresh and fails closed without leaking tokens", async () => { + let calls = 0; + let fail = false; + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const server = Bun.serve({ + port: 0, + async fetch() { + calls += 1; + await gate; + return fail + ? new Response("managed-secret", { status: 500 }) + : Response.json({ + access_token: "managed-secret", + expires_in: 600, + }); + }, + }); + temporaryServers.push(server); + process.env["LOCAL_STUDIO_MANAGED_IDENTITY_ENDPOINT"] = new URL( + "/metadata/identity/oauth2/token", + server.url, + ).toString(); + const provider = managedIdentityProvider(); + const first = Effect.runPromise(resolveProviderHeaders(provider)); + const second = Effect.runPromise(resolveProviderHeaders(provider)); + await Promise.resolve(); + release!(); + expect(await Promise.all([first, second])).toEqual([ + { Authorization: "Bearer managed-secret" }, + { Authorization: "Bearer managed-secret" }, + ]); + expect(calls).toBe(1); + + fail = true; + await expect( + Effect.runPromise(resolveProviderHeaders(managedIdentityProvider("https://failure.test"))), + ).rejects.toMatchObject({ reason: "identity_unavailable" }); + }); + + test("denies delegated tokens with wrong issuer, audience, or scope", async () => { + const provider: ProviderConfig = { + id: "gateway", + name: "Gateway", + base_url: "https://gateway.test/v1", + enabled: true, + authentication: { + type: "apim_gateway", + issuer_id: "issuer-01", + audience: "api://gateway", + scopes: ["models.invoke"], + }, + }; + const baseClaims = { + sub: "scientist-01", + iss: "https://issuer.test/realm", + exp: Math.floor(Date.now() / 1000) + 600, + }; + await expect( + Effect.runPromise( + resolveProviderHeaders(provider, { + principal: principal({ issuer_id: "other-issuer" }), + verifiedBearerToken: delegatedToken({ + ...baseClaims, + aud: "api://gateway", + scp: "models.invoke", + }), + }), + ), + ).rejects.toMatchObject({ reason: "identity_mismatch" }); + await expect( + Effect.runPromise( + resolveProviderHeaders(provider, { + principal: principal(), + verifiedBearerToken: delegatedToken({ + ...baseClaims, + aud: "api://other", + scp: "models.invoke", + }), + }), + ), + ).rejects.toMatchObject({ reason: "audience_mismatch" }); + await expect( + Effect.runPromise( + resolveProviderHeaders(provider, { + principal: principal(), + verifiedBearerToken: delegatedToken({ + ...baseClaims, + aud: "api://gateway", + scp: "openid", + }), + }), + ), + ).rejects.toMatchObject({ reason: "scope_mismatch" }); + }); + + test("exchanges a signed delegated token once for concurrent APIM calls", async () => { + let calls = 0; + let posted = ""; + const keys = await generateKeyPair("RS256"); + const publicJwk = { ...(await exportJWK(keys.publicKey)), alg: "RS256", kid: "provider-key" }; + const server = Bun.serve({ + port: 0, + async fetch(request): Promise { + const url = new URL(request.url); + if (url.pathname === "/.well-known/openid-configuration") { + return Response.json({ jwks_uri: new URL("/jwks", request.url).toString() }); + } + if (url.pathname === "/jwks") return Response.json({ keys: [publicJwk] }); + calls += 1; + posted = await request.text(); + return Response.json({ + access_token: "exchanged-access-token", + expires_in: 600, + token_type: "Bearer", + }); + }, + }); + temporaryServers.push(server); + const issuer = server.url.toString().replace(/\/$/u, ""); + const subjectToken = await new SignJWT({ + sub: "scientist-01", + scope: "models.invoke", + roles: ["scientist"], + }) + .setProtectedHeader({ alg: "RS256", kid: "provider-key" }) + .setIssuer(issuer) + .setAudience("api://gateway") + .setIssuedAt() + .setExpirationTime("10m") + .sign(keys.privateKey); + const directory = temporaryDirectory(); + process.env["LOCAL_STUDIO_PROVIDER_MASTER_KEY"] = masterKey("6"); + const secretStore = new ProviderSecretStore(directory, true); + const clientSecretRef = newProviderClientSecretReference("gateway-exchange"); + secretStore.writeSync(clientSecretRef, "exchange-client-secret"); + const provider: ProviderConfig = { + id: "gateway-exchange", + name: "Gateway exchange", + base_url: "https://gateway.test/v1", + enabled: true, + authentication: { + type: "apim_gateway", + issuer_id: "issuer-01", + audience: "api://gateway", + scopes: ["models.invoke"], + token_exchange: { + mode: "rfc8693", + token_endpoint: server.url.toString(), + client_id: "local-studio", + client_secret_ref: clientSecretRef, + }, + }, + }; + const identity = await Effect.runPromise( + new EnterpriseTokenVerifier({ + mode: "required_oidc", + session_idle_seconds: 900, + session_absolute_seconds: 3600, + issuers: [ + { + id: "issuer-01", + kind: "keycloak", + issuer, + client_id: "local-studio", + audience: "api://gateway", + scopes: ["models.invoke"], + tenant: "tenant-01", + role_claim: "roles", + group_claim: "groups", + role_mappings: { scientist: ["scientist"] }, + clearance_mappings: { scientist: "C2" }, + }, + ], + }).verify(subjectToken), + ); + const context = { + principal: identity, + verifiedBearerToken: subjectToken, + secretStore, + }; + expect( + await Promise.all([ + Effect.runPromise(resolveProviderHeaders(provider, context)), + Effect.runPromise(resolveProviderHeaders(provider, context)), + ]), + ).toEqual([ + { Authorization: "Bearer exchanged-access-token" }, + { Authorization: "Bearer exchanged-access-token" }, + ]); + expect(calls).toBe(1); + const form = new URLSearchParams(posted); + expect(form.get("subject_token")).toBe(subjectToken); + expect(form.get("client_secret")).toBe("exchange-client-secret"); + expect(form.get("audience")).toBe("api://gateway"); + }); + + test("cancels one token-exchange waiter without cancelling the shared acquisition", async () => { + let calls = 0; + let releaseExchange: (() => void) | undefined; + let markStarted: (() => void) | undefined; + const exchangeStarted = new Promise((resolve) => { + markStarted = resolve; + }); + const exchangeReleased = new Promise((resolve) => { + releaseExchange = resolve; + }); + const server = Bun.serve({ + port: 0, + async fetch(): Promise { + calls += 1; + markStarted?.(); + await exchangeReleased; + return Response.json({ + access_token: "shared-exchanged-token", + expires_in: 600, + token_type: "Bearer", + }); + }, + }); + temporaryServers.push(server); + const issuer = server.url.toString().replace(/\/$/u, ""); + const keys = await generateKeyPair("RS256"); + const subjectToken = await new SignJWT({ + sub: "scientist-01", + scope: "models.invoke", + }) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer(issuer) + .setAudience("api://gateway-cancel") + .setIssuedAt() + .setExpirationTime("10m") + .sign(keys.privateKey); + const provider: ProviderConfig = { + id: "gateway-cancel", + name: "Gateway cancellation", + base_url: "https://gateway.test/v1", + enabled: true, + authentication: { + type: "apim_gateway", + issuer_id: "issuer-01", + audience: "api://gateway-cancel", + scopes: ["models.invoke"], + token_exchange: { + mode: "rfc8693", + token_endpoint: server.url.toString(), + client_id: "local-studio", + }, + }, + }; + const identity = principal({ issuer, subject: "scientist-01" }); + const cancelled = new AbortController(); + const cancelledWaiter = Effect.runPromise( + resolveProviderHeaders(provider, { + principal: identity, + verifiedBearerToken: subjectToken, + signal: cancelled.signal, + }), + ); + const continuingWaiter = Effect.runPromise( + resolveProviderHeaders(provider, { + principal: identity, + verifiedBearerToken: subjectToken, + }), + ); + await exchangeStarted; + cancelled.abort(); + releaseExchange?.(); + await expect(cancelledWaiter).rejects.toMatchObject({ reason: "token_unavailable" }); + expect(await continuingWaiter).toEqual({ + Authorization: "Bearer shared-exchanged-token", + }); + expect(calls).toBe(1); + }); + + test("exchanges client credentials for an apim_client provider", async () => { + let calls = 0; + let posted = ""; + const keys = await generateKeyPair("RS256"); + const server = Bun.serve({ + port: 0, + async fetch(request): Promise { + calls += 1; + posted = await request.text(); + const accessToken = await new SignJWT({ + aud: "api://gateway", + scp: "models.invoke", + }) + .setProtectedHeader({ alg: "RS256" }) + .setIssuedAt() + .setExpirationTime("10m") + .sign(keys.privateKey); + return Response.json({ + access_token: accessToken, + expires_in: 600, + token_type: "Bearer", + }); + }, + }); + temporaryServers.push(server); + const directory = temporaryDirectory(); + process.env["LOCAL_STUDIO_PROVIDER_MASTER_KEY"] = masterKey("a"); + const secretStore = new ProviderSecretStore(directory, true); + const clientSecretReference = newProviderClientSecretReference("client-cred"); + secretStore.writeSync(clientSecretReference, "client-secret-value"); + const provider: ProviderConfig = { + id: "client-cred", + name: "Client credentials", + base_url: "https://gateway.test/v1", + enabled: true, + authentication: { + type: "apim_client", + issuer_id: "issuer-01", + audience: "api://gateway", + scopes: ["models.invoke"], + token_endpoint: server.url.toString(), + client_id: "local-studio", + client_secret_ref: clientSecretReference, + }, + }; + const headers = await Effect.runPromise(resolveProviderHeaders(provider, { secretStore })); + expect(headers["Authorization"]).toMatch(/^Bearer /); + expect(calls).toBe(1); + const form = new URLSearchParams(posted); + expect(form.get("grant_type")).toBe("client_credentials"); + expect(form.get("client_id")).toBe("local-studio"); + expect(form.get("client_secret")).toBe("client-secret-value"); + expect(form.get("scope")).toBe("models.invoke"); + }); +}); + +describe("provider outbound policy", () => { + test("rejects unexpected private resolution and admits explicit private hosts", async () => { + process.env["LOCAL_STUDIO_PROVIDER_HOST_ALLOWLIST"] = "private.test,public.test"; + const privateLookup: ProviderHostnameLookup = () => + Effect.succeed([{ address: "172.18.7.206", family: 4 }]); + await expect( + Effect.runPromise(assertProviderOutboundUrl("https://private.test", privateLookup)), + ).rejects.toThrow("restricted network address"); + process.env["LOCAL_STUDIO_PROVIDER_PRIVATE_HOST_ALLOWLIST"] = "private.test"; + expect( + await Effect.runPromise(assertProviderOutboundUrl("https://private.test", privateLookup)), + ).toBe("https://private.test/v1"); + expect( + await Effect.runPromise( + assertProviderOutboundUrl("http://api.tprime.vlans.ca", privateLookup), + ), + ).toBe("http://api.tprime.vlans.ca/v1"); + expect( + await Effect.runPromise( + assertProviderOutboundUrl("https://public.test", () => + Effect.succeed([{ address: "93.184.216.34", family: 4 }]), + ), + ), + ).toBe("https://public.test/v1"); + }); +}); + +describe("provider cancellation", () => { + test("propagates client abort to model discovery", async () => { + const controller = new AbortController(); + let upstreamSignal: AbortSignal | undefined; + const request = Effect.runPromise( + discoverProviderModels( + { + id: "local", + name: "Local", + base_url: "http://127.0.0.1:8101/v1", + enabled: true, + authentication: { type: "none" }, + }, + (_input, init) => + new Promise((_resolve, reject) => { + upstreamSignal = init?.signal ?? undefined; + upstreamSignal?.addEventListener("abort", () => reject(upstreamSignal?.reason), { + once: true, + }); + }), + { signal: controller.signal }, + ), + ); + await Promise.resolve(); + controller.abort(new Error("client disconnected")); + await expect(request).rejects.toBeDefined(); + expect(upstreamSignal?.aborted).toBe(true); + }); + + test("propagates client abort to streaming upstream fetch", async () => { + const controller = new AbortController(); + let upstreamSignal: AbortSignal | undefined; + let observedAbortResolve: (() => void) | undefined; + const observedAbort = new Promise((resolve) => { + observedAbortResolve = resolve; + }); + globalThis.fetch = ((_input, init) => + new Promise((_resolve, reject) => { + upstreamSignal = init?.signal ?? undefined; + upstreamSignal?.addEventListener( + "abort", + () => { + observedAbortResolve!(); + reject(upstreamSignal?.reason); + }, + { once: true }, + ); + })) as typeof fetch; + const response = buildChatCompletionsStreamResponse({ + upstreamUrl: "http://127.0.0.1:8101/v1/chat/completions", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "model-a", stream: true }), + clientSignal: controller.signal, + matchedRecipe: null, + sourceHeader: null, + sessionId: null, + recordedModel: "model-a", + recordedProvider: "local", + requestStart: performance.now(), + requestProvider: "local", + providerRouting: null, + context: { + logger: { error: () => undefined, warn: () => undefined }, + stores: {}, + } as never, + keepaliveIntervalMs: 60_000, + }); + const reader = response.body!.getReader(); + await reader.read(); + controller.abort(new Error("client disconnected")); + await observedAbort; + const completed = await reader.read(); + expect(upstreamSignal?.aborted).toBe(true); + expect(completed.done).toBe(true); + }); +}); + +describe("provider subscription key", () => { + test("includes the subscription key header alongside the bearer token", async () => { + process.env["LOCAL_STUDIO_PROVIDER_MASTER_KEY"] = masterKey("1"); + process.env["LOCAL_STUDIO_PROVIDER_MASTER_KEY_ID"] = "provider-current"; + const directory = temporaryDirectory(); + const store = new ProviderSecretStore(directory, true); + const reference = newProviderSubscriptionKeyReference("trustnest"); + const apiKeyReference = newProviderApiKeyReference("trustnest"); + store.writeSync(reference, "apim-subscription-secret"); + const provider: ProviderConfig = { + id: "trustnest", + name: "TrustNest", + base_url: "https://api.thalesdigital.io/ai-models/openai", + enabled: true, + authentication: { + type: "api_key", + secret_ref: apiKeyReference, + }, + subscription_key: { + header: "TrustNest-Apim-Subscription-Key", + secret_ref: reference, + }, + }; + store.writeSync(apiKeyReference, "bearer-token"); + const headers = await Effect.runPromise( + resolveProviderHeaders(provider, { secretStore: store }), + ); + expect(headers).toEqual({ + Authorization: "Bearer bearer-token", + "TrustNest-Apim-Subscription-Key": "apim-subscription-secret", + }); + }); + + test("fails closed when the subscription key is missing from the store", async () => { + process.env["LOCAL_STUDIO_PROVIDER_MASTER_KEY"] = masterKey("2"); + const directory = temporaryDirectory(); + const store = new ProviderSecretStore(directory, true); + const reference = newProviderSubscriptionKeyReference("trustnest"); + const provider: ProviderConfig = { + id: "trustnest", + name: "TrustNest", + base_url: "https://api.thalesdigital.io/ai-models/openai", + enabled: true, + authentication: { type: "none" }, + subscription_key: { + header: "TrustNest-Apim-Subscription-Key", + secret_ref: reference, + }, + }; + await expect( + Effect.runPromise(resolveProviderHeaders(provider, { secretStore: store })), + ).rejects.toMatchObject({ reason: "credential_unavailable" }); + }); + + test("uses a direct subscription key for probing without a persisted secret", async () => { + const provider: ProviderConfig = { + id: "trustnest", + name: "TrustNest", + base_url: "https://api.thalesdigital.io/ai-models/openai", + enabled: true, + authentication: { type: "none" }, + }; + const headers = await Effect.runPromise( + resolveProviderHeaders(provider, { + directSubscriptionKey: { header: "TrustNest-Apim-Subscription-Key", value: "probe-secret" }, + }), + ); + expect(headers).toEqual({ "TrustNest-Apim-Subscription-Key": "probe-secret" }); + }); +}); diff --git a/controller/tests/scientific-workbench-contract.test.ts b/controller/tests/scientific-workbench-contract.test.ts new file mode 100644 index 000000000..8eb4e7dc1 --- /dev/null +++ b/controller/tests/scientific-workbench-contract.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, test } from "bun:test"; +import { Schema } from "effect"; +import { + ScientificNotebookCreateSchema, + ScientificNotebookSessionSchema, + ScientificRayJobSubmissionSchema, + validateScientificRayJobSubmission, + type ScientificRayJobSubmission, +} from "../contracts/scientific-workbench"; + +const submission = (): ScientificRayJobSubmission => ({ + id: "submission-01", + project_id: "project-01", + notebook_id: "notebook-01", + compute_lease_id: "lease-01", + experiment_id: "experiment-01", + classification: "C2", + compute_profile: { + id: "gpu-small", + name: "GPU small", + cpu_cores: 8, + memory_gb: 32, + gpu_count: 1, + gpu_resource: "nvidia.com/gpu", + min_workers: 0, + max_workers: 4, + max_runtime_minutes: 240, + idle_timeout_minutes: 30, + network_policy: "deny-by-default", + classification_ceiling: "C2", + }, + environment_image: `registry.internal/workbench/science@sha256:${"c".repeat(64)}`, + environment_digest: `sha256:${"a".repeat(64)}`, + entrypoint: "python train.py", + datasets: [ + { + attachment_id: "attachment-01", + project_id: "project-01", + dataset_id: "dataset-01", + version: "2026-07-27", + digest: `sha256:${"b".repeat(64)}`, + classification: "C2", + access: "read-only", + purpose: "model evaluation", + issued_at: "2026-07-27T15:59:00Z", + lease_expires_at: "2026-07-28T00:00:00Z", + }, + ], + models: [ + { + provider_id: "tensorprime", + model_id: "Qwen3-30B-A3B-Instruct-2507-NVFP4", + qualified_id: "tensorprime/Qwen3-30B-A3B-Instruct-2507-NVFP4", + endpoint_class: "openai-compatible", + tool_mode: "none", + }, + ], + parameters: { temperature: 0 }, + random_seeds: [42], + approval_ids: ["approval-01"], + requested_by: "scientist-01", + requested_at: "2026-07-27T16:00:00Z", +}); + +describe("scientific workbench contracts", () => { + test("requires runtime and document identity for new notebook sessions", () => { + const decoded = Schema.decodeUnknownSync(ScientificNotebookCreateSchema)({ + project_id: "project-01", + owner_id: "scientist-01", + runtime: "node-smolvm", + document_path: "agent-collaboration-node.ipynb", + classification: "C2", + compute_profile_id: "gpu-small", + image_digest: `sha256:${"d".repeat(64)}`, + expires_at: "2026-07-28T16:00:00Z", + }); + + expect(decoded.runtime).toBe("node-smolvm"); + expect(decoded.document_path).toBe("agent-collaboration-node.ipynb"); + }); + + test("accepts Python SmolVM as an explicit C2 notebook runtime", () => { + const decoded = Schema.decodeUnknownSync(ScientificNotebookCreateSchema)({ + project_id: "project-01", + owner_id: "scientist-01", + runtime: "python-smolvm", + document_path: "agent-collaboration-python-smolvm.ipynb", + classification: "C2", + compute_profile_id: "gpu-small", + image_digest: `sha256:${"e".repeat(64)}`, + expires_at: "2026-07-28T16:00:00Z", + }); + + expect(decoded.runtime).toBe("python-smolvm"); + expect(decoded.classification).toBe("C2"); + }); + + test("accepts legacy notebook sessions without runtime identity", () => { + const decoded = Schema.decodeUnknownSync(ScientificNotebookSessionSchema)({ + id: "notebook-legacy", + project_id: "project-01", + owner_id: "scientist-01", + state: "ready", + classification: "C2", + compute_profile_id: "gpu-small", + image_digest: `sha256:${"d".repeat(64)}`, + created_at: "2026-07-27T16:00:00Z", + updated_at: "2026-07-27T16:00:00Z", + expires_at: "2026-07-28T16:00:00Z", + }); + + expect(decoded.runtime).toBeUndefined(); + expect(decoded.document_path).toBeUndefined(); + expect(decoded.owner_principal).toBeUndefined(); + }); + + test("accepts a governed C2 Ray job submission", () => { + const decoded = Schema.decodeUnknownSync(ScientificRayJobSubmissionSchema)(submission()); + + expect(decoded.classification).toBe("C2"); + expect(validateScientificRayJobSubmission(decoded)).toEqual([]); + }); + + test("rejects a writable dataset attachment at the schema boundary", () => { + const base = submission(); + const value = { + ...base, + datasets: [{ ...base.datasets[0], access: "read-write" }], + }; + + expect(() => Schema.decodeUnknownSync(ScientificRayJobSubmissionSchema)(value)).toThrow(); + }); + + test("reports unsafe compute, identity, evidence, and approval values", () => { + const base = submission(); + const dataset = base.datasets[0]; + const model = base.models[0]; + if (dataset === undefined || model === undefined) { + throw new Error("test fixture requires a dataset and model"); + } + const value: ScientificRayJobSubmission = { + ...base, + compute_profile: { ...base.compute_profile, max_workers: -1 }, + environment_digest: "latest", + datasets: [{ ...dataset, digest: "mutable" }], + models: [{ ...model, qualified_id: model.model_id }], + approval_ids: [], + }; + + expect(validateScientificRayJobSubmission(value)).toEqual([ + { + field: "compute_profile.max_workers", + reason: "must be greater than or equal to min_workers", + }, + { + field: "environment_digest", + reason: "must include an algorithm-prefixed digest", + }, + { + field: "datasets.0.digest", + reason: "must include an algorithm-prefixed digest", + }, + { + field: "models.0.qualified_id", + reason: "must equal provider_id/model_id", + }, + { field: "approval_ids", reason: "requires at least one approval" }, + ]); + }); +}); diff --git a/controller/tests/scientific-workbench-enterprise-identity.test.ts b/controller/tests/scientific-workbench-enterprise-identity.test.ts new file mode 100644 index 000000000..c9364290c --- /dev/null +++ b/controller/tests/scientific-workbench-enterprise-identity.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, test } from "bun:test"; +import type { NormalizedPrincipal } from "@local-studio/contracts/enterprise-auth"; +import type { + ScientificExperimentReceipt, + ScientificNotebookSession, +} from "@local-studio/contracts/scientific-workbench"; +import { + bindScientificNotebookOwner, + canAccessScientificNotebook, + canAccessScientificReceipt, + canAccessScientificRayJob, + requireScientificNotebookAccess, + requireScientificNotebookMutationOwner, + requireScientificSubmissionOwner, + scientificActorId, +} from "../src/modules/workbench/enterprise-identity"; + +const principal = (overrides: Partial = {}): NormalizedPrincipal => ({ + subject: "subject-1", + issuer: "https://identity.example.test/realms/science", + issuer_id: "keycloak", + tenant: "science", + display_name: "Scientist", + roles: ["scientist"], + entitlements: ["notebook:read", "notebook:execute", "ray:admit"], + clearance: "C2", + issued_at: 1, + expires_at: 2, + ...overrides, +}); + +const notebook = (owner_id = "subject-1", scoped = true): ScientificNotebookSession => ({ + id: "notebook-1", + project_id: "project-1", + owner_id, + ...(scoped + ? { + owner_principal: { + subject: owner_id, + issuer: "https://identity.example.test/realms/science", + issuer_id: "keycloak", + tenant: "science", + clearance: "C2" as const, + }, + } + : {}), + runtime: "python-smolvm", + document_path: "notebook.ipynb", + state: "ready", + classification: "C2", + compute_profile_id: "gpu-small", + image_digest: `sha256:${"a".repeat(64)}`, + created_at: "2026-07-29T00:00:00.000Z", + updated_at: "2026-07-29T00:00:00.000Z", + expires_at: "2026-07-30T00:00:00.000Z", +}); + +describe("scientific enterprise identity", () => { + test("derives actor and owner identity from the validated enterprise principal", () => { + expect(scientificActorId(principal(), "forged-browser-user")).toBe("subject-1"); + expect(bindScientificNotebookOwner(principal(), "subject-1")).toBe("subject-1"); + expect(() => bindScientificNotebookOwner(principal(), "forged-browser-user")).toThrow(); + }); + + test("preserves loopback-local actor compatibility", () => { + expect(scientificActorId(undefined, " scientist-1 ")).toBe("scientist-1"); + expect(bindScientificNotebookOwner(undefined, " scientist-1 ")).toBe("scientist-1"); + }); + + test("limits mutations and submissions to the immutable subject", () => { + expect(requireScientificNotebookMutationOwner(principal(), notebook())).toEqual(notebook()); + expect(() => + requireScientificNotebookMutationOwner(principal(), notebook("subject-2")), + ).toThrow(); + expect(() => requireScientificSubmissionOwner(principal(), "subject-2")).toThrow(); + }); + + test("permits explicit platform administration without rewriting ownership", () => { + const admin = principal({ roles: ["platform_admin"] }); + expect(requireScientificNotebookMutationOwner(admin, notebook("subject-2")).owner_id).toBe( + "subject-2", + ); + }); + + test("keeps platform administration inside the issuing tenant", () => { + const admin = principal({ roles: ["platform_admin"] }); + expect( + canAccessScientificNotebook(admin, { + ...notebook("subject-2"), + owner_principal: { + ...notebook("subject-2").owner_principal!, + tenant: "other-science", + }, + }), + ).toBe(false); + expect( + canAccessScientificNotebook( + principal({ + issuer: "https://other.example.test/realms/science", + issuer_id: "other", + }), + notebook(), + ), + ).toBe(false); + expect( + canAccessScientificNotebook(admin, { + ...notebook("subject-2"), + owner_principal: { + ...notebook("subject-2").owner_principal!, + issuer: "https://other.example.test/realms/science", + }, + }), + ).toBe(false); + }); + + test("keeps legacy records owner-only and scopes Ray access to admission identity", () => { + const admin = principal({ roles: ["platform_admin"] }); + expect(canAccessScientificNotebook(admin, notebook("subject-2", false))).toBe(false); + expect( + canAccessScientificRayJob(principal(), { + id: "job-1", + state: "queued", + submission: { + id: "job-1", + project_id: "project-1", + notebook_id: "notebook-1", + compute_lease_id: "lease-1", + experiment_id: "experiment-1", + classification: "C2", + compute_profile: { + id: "gpu-small", + name: "GPU small", + cpu_cores: 1, + memory_gb: 2, + gpu_count: 0, + gpu_resource: null, + min_workers: 0, + max_workers: 1, + max_runtime_minutes: 5, + idle_timeout_minutes: 5, + network_policy: "deny-by-default", + classification_ceiling: "C2", + }, + environment_image: `registry.example.test/science@sha256:${"a".repeat(64)}`, + environment_digest: `sha256:${"a".repeat(64)}`, + entrypoint: "python main.py", + datasets: [], + models: [], + parameters: {}, + random_seeds: [], + approval_ids: ["approval-1"], + requested_by: "forged-browser-user", + requested_at: "2026-07-29T00:00:00.000Z", + }, + admission_principal: { + subject: "subject-1", + issuer: "https://identity.example.test/realms/science", + issuer_id: "keycloak", + tenant: "science", + clearance: "C2", + }, + resource: { + apiVersion: "ray.io/v1", + kind: "RayJob", + metadata: { name: "job-1", namespace: "project-1", labels: {}, annotations: {} }, + spec: { + entrypoint: "python main.py", + shutdownAfterJobFinishes: true, + ttlSecondsAfterFinished: 3600, + rayClusterSpec: { + headGroupSpec: { + rayStartParams: {}, + template: { spec: { automountServiceAccountToken: false, containers: [] } }, + }, + workerGroupSpecs: [], + }, + }, + }, + admitted_at: "2026-07-29T00:00:00.000Z", + }), + ).toBe(true); + expect(() => requireScientificNotebookAccess(principal(), notebook("subject-2"))).toThrowError( + expect.objectContaining({ + status: 404, + detail: "Notebook not found", + }), + ); + const legacyReceipt = { + principal: { + subject: "subject-1", + issuer_id: "keycloak", + tenant: "science", + clearance: "C2", + }, + } as ScientificExperimentReceipt; + expect(canAccessScientificReceipt(principal(), legacyReceipt)).toBe(true); + expect(canAccessScientificReceipt(principal({ issuer_id: "other" }), legacyReceipt)).toBe( + false, + ); + }); +}); diff --git a/controller/tests/scientific-workbench-reconciler.test.ts b/controller/tests/scientific-workbench-reconciler.test.ts new file mode 100644 index 000000000..b7871846c --- /dev/null +++ b/controller/tests/scientific-workbench-reconciler.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, test } from "bun:test"; +import type { + ScientificNotebookSession, + ScientificRayJobSubmission, +} from "@local-studio/contracts/scientific-workbench"; +import { Effect } from "effect"; +import { ScientificWorkbenchStore } from "../src/modules/workbench/store"; +import { createScientificRayJobRecord } from "../src/modules/workbench/service"; +import type { ScientificRayJobRecord } from "../src/modules/workbench/types"; +import { reconcilePass } from "../src/modules/workbench/reconciler"; +import type { KubeRayGateway } from "../src/modules/workbench/kuberay-gateway"; +import type { AppContext } from "../src/app-context"; + +const notebook = (): ScientificNotebookSession => ({ + id: "notebook-reconcile", + project_id: "project-reconcile", + owner_id: "scientist-01", + runtime: "node-smolvm", + document_path: "agent-collaboration-node.ipynb", + state: "ready", + classification: "C2", + compute_profile_id: "gpu-small", + image_digest: `sha256:${"d".repeat(64)}`, + created_at: "2026-07-27T16:00:00Z", + updated_at: "2026-07-27T16:00:00Z", + expires_at: "2026-07-28T16:00:00Z", +}); + +const submission = (): ScientificRayJobSubmission => ({ + id: "submission-reconcile", + project_id: "project-reconcile", + notebook_id: "notebook-reconcile", + compute_lease_id: "lease-01", + experiment_id: "Experiment_01", + classification: "C2", + compute_profile: { + id: "gpu-small", + name: "GPU small", + cpu_cores: 8, + memory_gb: 32, + gpu_count: 1, + gpu_resource: "nvidia.com/gpu", + min_workers: 1, + max_workers: 4, + max_runtime_minutes: 240, + idle_timeout_minutes: 30, + network_policy: "deny-by-default", + classification_ceiling: "C2", + }, + environment_image: `registry.internal/workbench/science@sha256:${"c".repeat(64)}`, + environment_digest: `sha256:${"a".repeat(64)}`, + entrypoint: "python train.py", + datasets: [], + models: [], + parameters: { temperature: 0 }, + random_seeds: [42], + approval_ids: ["approval-01"], + requested_by: "scientist-01", + requested_at: "2026-07-27T16:00:00Z", +}); + +type FakeGatewayOverrides = { + reconcileResult?: Partial; + failTimes?: number; +}; + +const fakeGateway = (overrides: FakeGatewayOverrides = {}): KubeRayGateway => { + let attempts = 0; + const failTimes = overrides.failTimes ?? 0; + return { + reconcile: (record: ScientificRayJobRecord, now: string) => { + attempts += 1; + if (attempts <= failTimes) { + return Effect.fail(new Error(`transient KubeRay failure ${attempts}`)); + } + const result: ScientificRayJobRecord = { + ...record, + reconciled_at: now, + ...(overrides.reconcileResult ?? {}), + }; + return Effect.succeed(result); + }, + } as unknown as KubeRayGateway; +}; + +const fakeContext = ( + store: ScientificWorkbenchStore, + gateway: KubeRayGateway | null, +): Pick => { + const logs: { level: string; message: string }[] = []; + return { + stores: { scientificWorkbenchStore: store } as AppContext["stores"], + kubeRayGateway: gateway, + logger: { + debug: (m: string) => logs.push({ level: "debug", message: m }), + info: (m: string) => logs.push({ level: "info", message: m }), + warn: (m: string) => logs.push({ level: "warn", message: m }), + error: (m: string) => logs.push({ level: "error", message: m }), + shutdown: () => Effect.void, + } as AppContext["logger"], + }; +}; + +describe("workbench reconciler", () => { + test("reconciles non-terminal jobs and skips terminal and queued jobs", async () => { + const store = new ScientificWorkbenchStore(":memory:"); + const nb = notebook(); + const sub = submission(); + await Effect.runPromise(store.saveNotebook(nb)); + + const queued = createScientificRayJobRecord(sub, "2026-07-27T16:01:00Z"); + const running: ScientificRayJobRecord = { ...queued, id: "job-running", state: "running" }; + const succeeded: ScientificRayJobRecord = { + ...queued, + id: "job-succeeded", + state: "succeeded", + }; + await Effect.runPromise(store.saveRayJob({ ...sub, id: "job-running" }, running)); + await Effect.runPromise(store.saveRayJob({ ...sub, id: "job-succeeded" }, succeeded)); + await Effect.runPromise(store.saveRayJob(sub, queued)); + + const gateway = fakeGateway({ reconcileResult: { state: "succeeded" } }); + const context = fakeContext(store, gateway); + + await Effect.runPromise(reconcilePass(context as AppContext)); + + const reconciledRunning = await Effect.runPromise(store.getRayJob("job-running")); + expect(reconciledRunning?.state).toBe("succeeded"); + const stillSucceeded = await Effect.runPromise(store.getRayJob("job-succeeded")); + expect(stillSucceeded?.state).toBe("succeeded"); + const stillQueued = await Effect.runPromise(store.getRayJob(sub.id)); + expect(stillQueued?.state).toBe("queued"); + await Effect.runPromise(store.close()); + }); + + test("retries transient KubeRay failures within the bounded budget", async () => { + const store = new ScientificWorkbenchStore(":memory:"); + const nb = notebook(); + const sub = submission(); + await Effect.runPromise(store.saveNotebook(nb)); + const running: ScientificRayJobRecord = { + ...createScientificRayJobRecord(sub, "2026-07-27T16:01:00Z"), + state: "running", + }; + await Effect.runPromise(store.saveRayJob(sub, running)); + + const gateway = fakeGateway({ failTimes: 2, reconcileResult: { state: "succeeded" } }); + const context = fakeContext(store, gateway); + + await Effect.runPromise(reconcilePass(context as AppContext, { retryBaseMs: 1, retryMax: 3 })); + + const reconciled = await Effect.runPromise(store.getRayJob(sub.id)); + expect(reconciled?.state).toBe("succeeded"); + await Effect.runPromise(store.close()); + }); + + test("logs a warning and continues when a job exceeds the retry budget", async () => { + const store = new ScientificWorkbenchStore(":memory:"); + const nb = notebook(); + const sub = submission(); + await Effect.runPromise(store.saveNotebook(nb)); + const running: ScientificRayJobRecord = { + ...createScientificRayJobRecord(sub, "2026-07-27T16:01:00Z"), + state: "running", + }; + await Effect.runPromise(store.saveRayJob(sub, running)); + + const gateway = fakeGateway({ failTimes: 99 }); + const context = fakeContext(store, gateway); + + await Effect.runPromise(reconcilePass(context as AppContext, { retryBaseMs: 1, retryMax: 2 })); + + const stillRunning = await Effect.runPromise(store.getRayJob(sub.id)); + expect(stillRunning?.state).toBe("running"); + await Effect.runPromise(store.close()); + }); + + test("no-ops when the KubeRay gateway is unavailable", async () => { + const store = new ScientificWorkbenchStore(":memory:"); + const nb = notebook(); + const sub = submission(); + await Effect.runPromise(store.saveNotebook(nb)); + const running: ScientificRayJobRecord = { + ...createScientificRayJobRecord(sub, "2026-07-27T16:01:00Z"), + state: "running", + }; + await Effect.runPromise(store.saveRayJob(sub, running)); + + const context = fakeContext(store, null); + await Effect.runPromise(reconcilePass(context as AppContext)); + + const stillRunning = await Effect.runPromise(store.getRayJob(sub.id)); + expect(stillRunning?.state).toBe("running"); + await Effect.runPromise(store.close()); + }); +}); diff --git a/controller/tests/scientific-workbench-service.test.ts b/controller/tests/scientific-workbench-service.test.ts new file mode 100644 index 000000000..162a2b35e --- /dev/null +++ b/controller/tests/scientific-workbench-service.test.ts @@ -0,0 +1,590 @@ +import { describe, expect, test } from "bun:test"; +import type { + ScientificNotebookSession, + ScientificRayJobSubmission, +} from "@local-studio/contracts/scientific-workbench"; +import type { NotebookInteractionEvent } from "@local-studio/contracts/notebook-agent"; +import type { NormalizedPrincipal } from "@local-studio/contracts/enterprise-auth"; +import { Effect } from "effect"; +import { ScientificWorkbenchStore } from "../src/modules/workbench/store"; +import { + admitScientificRayJob, + createScientificExperimentReceipt, + createScientificRayJobRecord, + discoverScientificModelCatalog, + issueScientificComputeLease, + issueScientificDatasetAttachment, + transitionScientificNotebook, +} from "../src/modules/workbench/service"; + +const notebook = (): ScientificNotebookSession => ({ + id: "notebook-01", + project_id: "project-01", + owner_id: "scientist-01", + runtime: "node-smolvm", + document_path: "agent-collaboration-node.ipynb", + state: "ready", + classification: "C2", + compute_profile_id: "gpu-small", + image_digest: `sha256:${"d".repeat(64)}`, + created_at: "2026-07-27T16:00:00Z", + updated_at: "2026-07-27T16:00:00Z", + expires_at: "2026-07-28T16:00:00Z", +}); + +const notebookRevision = `sha256:${"1".repeat(64)}`; +const notebookInteractions: NotebookInteractionEvent[] = [ + { + id: "interaction-01", + notebook_id: "notebook-01", + project_id: "project-01", + actor_id: "scientist-01", + operation: "execute", + revision_before: notebookRevision, + revision_after: notebookRevision, + cell_index: 0, + approval_id: "approval-01", + occurred_at: "2026-07-27T16:05:00Z", + }, +]; +const enterprisePrincipal: NormalizedPrincipal = { + subject: "scientist-01", + issuer: "https://identity.example.test/realms/science", + issuer_id: "keycloak", + tenant: "science", + display_name: "Scientist", + roles: ["scientist"], + entitlements: ["notebook:read", "notebook:execute", "ray:admit"], + clearance: "C2", + issued_at: 1, + expires_at: 2, +}; + +const submission = (): ScientificRayJobSubmission => ({ + id: "submission-01", + project_id: "project-01", + notebook_id: "notebook-01", + compute_lease_id: "lease-01", + experiment_id: "Experiment_01", + classification: "C2", + compute_profile: { + id: "gpu-small", + name: "GPU small", + cpu_cores: 8, + memory_gb: 32, + gpu_count: 1, + gpu_resource: "nvidia.com/gpu", + min_workers: 1, + max_workers: 4, + max_runtime_minutes: 240, + idle_timeout_minutes: 30, + network_policy: "deny-by-default", + classification_ceiling: "C2", + }, + environment_image: `registry.internal/workbench/science@sha256:${"c".repeat(64)}`, + environment_digest: `sha256:${"a".repeat(64)}`, + entrypoint: "python train.py", + datasets: [ + { + attachment_id: "attachment-01", + project_id: "project-01", + dataset_id: "dataset-01", + version: "2026-07-27", + digest: `sha256:${"b".repeat(64)}`, + classification: "C2", + access: "read-only", + purpose: "model evaluation", + issued_at: "2026-07-27T15:59:00Z", + lease_expires_at: "2026-07-28T00:00:00Z", + }, + ], + models: [ + { + provider_id: "tensorprime", + model_id: "qwen3-next-80b-a3b-nvfp4", + qualified_id: "tensorprime/qwen3-next-80b-a3b-nvfp4", + endpoint_class: "openai-compatible", + tool_mode: "none", + }, + ], + parameters: { temperature: 0 }, + random_seeds: [42], + approval_ids: ["approval-01"], + requested_by: "scientist-01", + requested_at: "2026-07-27T16:00:00Z", +}); + +const governance = () => { + const value = submission(); + return { + computeLease: { + id: "lease-01", + project_id: "project-01", + notebook_id: "notebook-01", + profile_id: "gpu-small", + profile: value.compute_profile, + classification: "C2" as const, + state: "admitted" as const, + requested_at: "2026-07-27T15:59:00Z", + expires_at: "2026-07-28T00:00:00Z", + }, + datasetAttachments: new Map( + value.datasets.map((attachment) => [attachment.attachment_id, attachment]), + ), + modelCatalog: new Map([["tensorprime", new Set(["qwen3-next-80b-a3b-nvfp4"])]]), + now: "2026-07-27T16:00:00Z", + }; +}; + +describe("scientific workbench service", () => { + test("admits a governed submission and generates a constrained RayJob", () => { + const value = submission(); + + expect(() => + admitScientificRayJob(value, notebook(), new Set(["tensorprime"]), governance()), + ).not.toThrow(); + + const record = createScientificRayJobRecord(value, "2026-07-27T16:01:00Z", enterprisePrincipal); + const pod = record.resource.spec.rayClusterSpec.headGroupSpec.template.spec; + + expect(record.resource.metadata.name).toBe("experiment-experiment-01"); + expect(record.resource.metadata.namespace).toBe("workbench-project-01"); + expect(pod.automountServiceAccountToken).toBe(false); + expect(pod.containers[0]?.image).toBe(value.environment_image); + expect(pod.containers[0]?.env).toContainEqual({ + name: "LOCAL_STUDIO_ENTERPRISE_SUBJECT", + value: "scientist-01", + }); + expect(record.resource.spec.rayClusterSpec.workerGroupSpecs[0]?.maxReplicas).toBe(4); + }); + + test("rejects a model provider that is not configured", () => { + try { + admitScientificRayJob(submission(), notebook(), new Set(), governance()); + throw new Error("expected admission to fail"); + } catch (error) { + expect((error as { detail?: string }).detail).toBe( + 'Model provider "tensorprime" is not configured', + ); + } + }); + + test("rejects submission while its notebook is still provisioning", () => { + const pending = { ...notebook(), state: "provisioning" as const }; + + try { + admitScientificRayJob(submission(), pending, new Set(["tensorprime"]), governance()); + throw new Error("expected admission to fail"); + } catch (error) { + expect((error as { detail?: string }).detail).toBe( + 'Notebook "notebook-01" is not ready for job submission', + ); + } + }); + + test("persists notebooks and admitted RayJob documents", async () => { + const store = new ScientificWorkbenchStore(":memory:"); + const notebookValue = notebook(); + const submissionValue = submission(); + const record = createScientificRayJobRecord(submissionValue, "2026-07-27T16:01:00Z"); + + await Effect.runPromise(store.saveNotebook(notebookValue)); + await Effect.runPromise(store.saveRayJob(submissionValue, record)); + + expect(await Effect.runPromise(store.getNotebook(notebookValue.id))).toEqual(notebookValue); + expect(await Effect.runPromise(store.listRayJobs(notebookValue.project_id))).toEqual([record]); + expect(await Effect.runPromise(store.getRayJob(record.id))).toEqual(record); + await Effect.runPromise(store.close()); + }); + + test("persists controller-issued compute and dataset leases", async () => { + const store = new ScientificWorkbenchStore(":memory:"); + const admission = governance(); + const attachment = submission().datasets[0]!; + + await Effect.runPromise(store.saveComputeLease(admission.computeLease)); + await Effect.runPromise(store.saveDatasetAttachment(attachment)); + + expect(await Effect.runPromise(store.getComputeLease("lease-01"))).toEqual( + admission.computeLease, + ); + expect(await Effect.runPromise(store.getDatasetAttachment("attachment-01"))).toEqual( + attachment, + ); + await Effect.runPromise(store.close()); + }); + + test("issues server-owned lease and read-only dataset identities", () => { + const value = submission(); + const lease = issueScientificComputeLease( + { + project_id: value.project_id, + notebook_id: value.notebook_id, + profile: value.compute_profile, + classification: "C2", + expires_at: "2026-07-28T00:00:00Z", + }, + notebook(), + "2026-07-27T16:00:00Z", + ); + const attachment = issueScientificDatasetAttachment( + { + project_id: value.project_id, + dataset_id: "dataset-01", + version: "2026-07-27", + digest: `sha256:${"b".repeat(64)}`, + classification: "C2", + purpose: "model evaluation", + lease_expires_at: "2026-07-28T00:00:00Z", + }, + "2026-07-27T16:00:00Z", + ); + + expect(lease.id).not.toBe(""); + expect(lease.state).toBe("admitted"); + expect(lease.profile).toEqual(value.compute_profile); + expect(attachment.attachment_id).not.toBe(""); + expect(attachment.access).toBe("read-only"); + }); + + test("rejects expired compute and dataset authority", () => { + const expiredLease = governance(); + expiredLease.computeLease.expires_at = "2026-07-27T15:00:00Z"; + expect(() => + admitScientificRayJob(submission(), notebook(), new Set(["tensorprime"]), expiredLease), + ).toThrow(); + + const expiredDataset = governance(); + const attachment = { + ...submission().datasets[0]!, + lease_expires_at: "2026-07-27T15:00:00Z", + }; + expiredDataset.datasetAttachments = new Map([[attachment.attachment_id, attachment]]); + const value = { ...submission(), datasets: [attachment] }; + expect(() => + admitScientificRayJob(value, notebook(), new Set(["tensorprime"]), expiredDataset), + ).toThrow(); + }); + + test("rejects a model absent from the authoritative provider catalog", () => { + const admission = governance(); + admission.modelCatalog = new Map([["tensorprime", new Set(["gemma-4-26b-nvfp4"])]]); + expect(() => + admitScientificRayJob(submission(), notebook(), new Set(["tensorprime"]), admission), + ).toThrow(); + }); + + test("enforces the notebook lifecycle graph", () => { + const provisioning = { + ...notebook(), + state: "provisioning" as const, + }; + const ready = transitionScientificNotebook(provisioning, "ready", "2026-07-27T16:02:00Z"); + + expect(ready.state).toBe("ready"); + try { + transitionScientificNotebook({ ...ready, state: "archived" }, "active", ready.updated_at); + throw new Error("expected transition to fail"); + } catch (error) { + expect((error as { detail?: string }).detail).toBe( + "Notebook cannot transition from archived to active", + ); + } + }); + + test("creates and persists a terminal experiment receipt from measured evidence", async () => { + const store = new ScientificWorkbenchStore(":memory:"); + const notebookValue = notebook(); + const submissionValue = submission(); + const job = { + ...createScientificRayJobRecord(submissionValue, "2026-07-27T16:01:00Z", enterprisePrincipal), + state: "succeeded" as const, + submitted_at: "2026-07-27T16:02:00Z", + reconciled_at: "2026-07-27T16:12:00Z", + cluster: { + uid: "ray-job-uid", + resource_version: "4", + job_status: "SUCCEEDED", + deployment_status: "Complete", + message: null, + started_at: "2026-07-27T16:03:00Z", + ended_at: "2026-07-27T16:11:00Z", + resource_usage: { + cpu_seconds: 600, + gpu_seconds: 480, + peak_memory_gb: 24, + }, + artifact_digests: [`sha256:${"e".repeat(64)}`], + policy_decision_ids: ["policy-decision-01"], + apim_correlation_ids: ["apim-correlation-01"], + agent_ids: ["foundry/research-agent"], + }, + }; + const receipt = createScientificExperimentReceipt( + job, + notebookValue, + notebookRevision, + notebookInteractions, + { + artifact_digests: [`sha256:${"e".repeat(64)}`], + policy_decision_ids: ["policy-decision-01"], + resource_usage: { + cpu_seconds: 600, + gpu_seconds: 480, + peak_memory_gb: 24, + }, + }, + "receipt-signing-key-with-32-bytes-minimum", + { + ...enterprisePrincipal, + subject: "platform-admin", + roles: ["platform_admin"], + }, + [ + { + id: "foundry-evidence-01", + submission_id: submissionValue.id, + principal: { + subject: enterprisePrincipal.subject, + issuer: enterprisePrincipal.issuer, + issuer_id: enterprisePrincipal.issuer_id, + tenant: enterprisePrincipal.tenant, + clearance: enterprisePrincipal.clearance, + }, + kind: "agent", + provider_id: "foundry", + resource_id: "research-agent", + correlation_id: "foundry-correlation-01", + observed_at: "2026-07-27T16:06:00Z", + }, + ], + ); + + expect(receipt.ray_job_id).toBe("ray-job-uid"); + expect(receipt.state).toBe("succeeded"); + expect(receipt.notebook_digest).toBe(notebookValue.image_digest); + expect(receipt.notebook_revision).toBe(notebookRevision); + expect(receipt.notebook_interaction_count).toBe(1); + expect(receipt.notebook_interaction_digest).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(receipt.completed_at).toBe("2026-07-27T16:11:00Z"); + expect(receipt.receipt_digest).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(receipt.receipt_signature).toMatch(/^hmac-sha256:[a-f0-9]{64}$/); + expect(receipt.evidence_source).toBe("controller-reconciled"); + expect(receipt.apim_correlation_ids).toEqual(["apim-correlation-01", "foundry-correlation-01"]); + expect(receipt.principal).toEqual({ + subject: "scientist-01", + issuer: "https://identity.example.test/realms/science", + issuer_id: "keycloak", + tenant: "science", + clearance: "C2", + }); + expect(receipt.agents).toEqual([{ provider_id: "foundry", agent_id: "research-agent" }]); + expect(receipt.foundry_invocations).toEqual([ + { + kind: "agent", + provider_id: "foundry", + resource_id: "research-agent", + correlation_id: "foundry-correlation-01", + principal: { + subject: "scientist-01", + issuer: "https://identity.example.test/realms/science", + issuer_id: "keycloak", + tenant: "science", + clearance: "C2", + }, + }, + ]); + + await Effect.runPromise(store.saveReceipt(submissionValue.project_id, receipt)); + await Effect.runPromise( + store.saveFoundryInvocationEvidence({ + id: "foundry-evidence-01", + submission_id: submissionValue.id, + principal: { + subject: enterprisePrincipal.subject, + issuer: enterprisePrincipal.issuer, + issuer_id: enterprisePrincipal.issuer_id, + tenant: enterprisePrincipal.tenant, + clearance: enterprisePrincipal.clearance, + }, + kind: "model", + provider_id: "foundry", + resource_id: "model-01", + correlation_id: "correlation-01", + observed_at: "2026-07-27T16:05:00Z", + }), + ); + expect(await Effect.runPromise(store.getReceipt(receipt.id))).toEqual(receipt); + expect(await Effect.runPromise(store.getReceiptBySubmission(submissionValue.id))).toEqual( + receipt, + ); + expect( + await Effect.runPromise(store.listFoundryInvocationEvidence(submissionValue.id)), + ).toEqual([ + expect.objectContaining({ + id: "foundry-evidence-01", + correlation_id: "correlation-01", + }), + ]); + await Effect.runPromise( + store.saveFoundryInvocationEvidence({ + id: "foundry-evidence-01", + submission_id: submissionValue.id, + principal: { + subject: "platform-admin", + issuer: enterprisePrincipal.issuer, + issuer_id: enterprisePrincipal.issuer_id, + tenant: enterprisePrincipal.tenant, + clearance: "C2", + }, + kind: "agent", + provider_id: "forged-provider", + resource_id: "forged-agent", + correlation_id: "forged-correlation", + observed_at: "2026-07-27T16:07:00Z", + }), + ); + expect( + await Effect.runPromise(store.listFoundryInvocationEvidence(submissionValue.id)), + ).toEqual([ + expect.objectContaining({ + principal: expect.objectContaining({ subject: enterprisePrincipal.subject }), + kind: "model", + provider_id: "foundry", + resource_id: "model-01", + correlation_id: "correlation-01", + }), + ]); + await Effect.runPromise(store.close()); + }); + + test("uses controller evidence instead of caller receipt claims", () => { + const value = submission(); + const record = createScientificRayJobRecord(value, "2026-07-27T16:01:00Z", enterprisePrincipal); + const job = { + ...record, + state: "succeeded" as const, + reconciled_at: "2026-07-27T16:11:00Z", + cluster: { + uid: "uid-01", + resource_version: "7", + job_status: "SUCCEEDED", + deployment_status: "Complete", + message: null, + started_at: "2026-07-27T16:02:00Z", + ended_at: "2026-07-27T16:10:00Z", + resource_usage: { cpu_seconds: 12, gpu_seconds: 8, peak_memory_gb: 4 }, + artifact_digests: [`sha256:${"f".repeat(64)}`], + policy_decision_ids: ["measured-policy"], + }, + }; + const receipt = createScientificExperimentReceipt( + job, + notebook(), + notebookRevision, + notebookInteractions, + { + artifact_digests: [`sha256:${"0".repeat(64)}`], + policy_decision_ids: ["caller-policy"], + resource_usage: { cpu_seconds: 999, gpu_seconds: 999, peak_memory_gb: 999 }, + }, + "receipt-signing-key-with-32-bytes-minimum", + ); + + expect(receipt.policy_decision_ids).toEqual(["measured-policy"]); + expect(receipt.artifact_digests).toEqual([`sha256:${"f".repeat(64)}`]); + expect(receipt.resource_usage.cpu_seconds).toBe(12); + expect(() => + createScientificExperimentReceipt( + job, + notebook(), + notebookRevision, + notebookInteractions, + { + artifact_digests: [], + policy_decision_ids: [], + resource_usage: { cpu_seconds: 0, gpu_seconds: 0, peak_memory_gb: 0 }, + }, + "receipt-signing-key-with-32-bytes-minimum", + enterprisePrincipal, + [ + { + id: "cross-tenant-evidence", + submission_id: value.id, + principal: { + subject: "scientist-2", + issuer: enterprisePrincipal.issuer, + issuer_id: enterprisePrincipal.issuer_id, + tenant: "other", + clearance: "C2", + }, + kind: "model", + provider_id: "foundry", + resource_id: "model-01", + correlation_id: "correlation-02", + observed_at: "2026-07-27T16:06:00Z", + }, + ], + ), + ).toThrow(); + }); + + test("discovers authoritative model IDs without exposing provider secrets", async () => { + let authorization = ""; + const catalog = await Effect.runPromise( + discoverScientificModelCatalog( + [ + { + id: "tensorprime", + name: "TensorPrime", + base_url: "http://api.tprime.vlans.ca/v1", + enabled: true, + authentication: { + type: "api_key", + secret_ref: "provider:tensorprime:api-key", + }, + }, + ], + async (input, init) => { + authorization = new Headers(init?.headers).get("authorization") ?? ""; + expect(String(input)).toBe("http://api.tprime.vlans.ca/v1/models"); + return Response.json({ + data: [{ id: "gemma-4-26b-nvfp4" }, { id: "qwen3-next-80b-a3b-nvfp4" }], + }); + }, + { directApiKey: "placeholder" }, + ), + ); + + expect(catalog.get("tensorprime")).toEqual( + new Set(["gemma-4-26b-nvfp4", "qwen3-next-80b-a3b-nvfp4"]), + ); + expect(authorization).toBe("Bearer placeholder"); + }); + + test("refuses an experiment receipt before the RayJob reaches a terminal state", () => { + try { + createScientificExperimentReceipt( + createScientificRayJobRecord(submission(), "2026-07-27T16:01:00Z"), + notebook(), + notebookRevision, + notebookInteractions, + { + artifact_digests: [], + policy_decision_ids: [], + resource_usage: { + cpu_seconds: 0, + gpu_seconds: 0, + peak_memory_gb: 0, + }, + }, + "receipt-signing-key-with-32-bytes-minimum", + ); + throw new Error("expected receipt generation to fail"); + } catch (error) { + expect((error as { detail?: string }).detail).toBe( + "Experiment receipt requires a terminal RayJob", + ); + } + }); +}); diff --git a/controller/tests/scientist-onboarding.integration.test.ts b/controller/tests/scientist-onboarding.integration.test.ts new file mode 100644 index 000000000..7aa54e94d --- /dev/null +++ b/controller/tests/scientist-onboarding.integration.test.ts @@ -0,0 +1,1318 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { Effect, Layer, ManagedRuntime } from "effect"; +import { Hono } from "hono"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import type { AppContext } from "../src/app-context"; +import type { Config } from "../src/config/env"; +import { isHttpStatus } from "../src/core/errors"; +import { + controllerRuntimeMiddleware, + type ControllerEnvironment, +} from "../src/http/effect-handler"; +import { registerStudioRoutes } from "../src/modules/studio/routes"; +import { registerExperimentTrackingRoutes } from "../src/modules/workbench/experiment-routes"; +import { ExperimentTrackingStore } from "../src/modules/workbench/experiment-store"; +import { ControllerSettingsStore } from "../src/stores/controller-settings-store"; +import { ProviderSecretStore } from "../src/services/provider-secret-store"; + +const runtimes: Array<{ dispose: () => Promise }> = []; +const experimentStores: ExperimentTrackingStore[] = []; +const tempDirs: string[] = []; + +afterEach(async () => { + for (const runtime of runtimes.splice(0)) await runtime.dispose(); + for (const store of experimentStores.splice(0)) await Effect.runPromise(store.close()); + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +const makeContext = (): { context: AppContext; dataDir: string; notebookRoot: string } => { + const dataDir = mkdtempSync(join(tmpdir(), "scientist-e2e-")); + tempDirs.push(dataDir); + const notebookRoot = join(dataDir, "notebooks"); + mkdirSync(notebookRoot, { recursive: true }); + + const experimentTrackingStore = new ExperimentTrackingStore( + join(dataDir, "experiments.db"), + ); + experimentStores.push(experimentTrackingStore); + + const controllerSettingsStore = new ControllerSettingsStore( + join(dataDir, "settings.db"), + ); + + const providerSecretStore = new ProviderSecretStore(dataDir, false); + + const config: Config = { + host: "127.0.0.1", + port: 0, + inference_host: "localhost", + inference_port: 8000, + data_dir: dataDir, + db_path: join(dataDir, "controller.db"), + models_dir: join(dataDir, "models"), + strict_openai_models: false, + providers: [], + notebook_root: notebookRoot, + notebook_python: "python3", + notebook_smolvm: "smolvm", + notebook_node_image: join(dataDir, "node-image.tar"), + notebook_python_image: join(dataDir, "python-image.tar"), + }; + + const noop = () => {}; + const stubStore = { listEffect: () => Effect.succeed([]) }; + + const context = { + config, + logger: { info: noop, warn: noop, error: noop, debug: noop, child: () => ({ info: noop, warn: noop, error: noop, debug: noop }) }, + eventManager: { publish: noop, subscribe: () => ({ unsubscribe: noop }) }, + providerSecretStore, + stores: { + experimentTrackingStore, + controllerSettingsStore, + rigStore: stubStore, + recipeStore: stubStore, + downloadStore: stubStore, + peakMetricsStore: stubStore, + lifetimeMetricsStore: stubStore, + inferenceRequestStore: stubStore, + controllerRequestStore: stubStore, + scientificWorkbenchStore: { close: () => Effect.succeed(undefined) }, + }, + } as unknown as AppContext; + + return { context, dataDir, notebookRoot }; +}; + +const makeApp = (context: AppContext) => { + const runtime = ManagedRuntime.make(Layer.empty) as unknown as { + dispose: () => Promise; + }; + runtimes.push(runtime); + const app = new Hono(); + app.use("*", controllerRuntimeMiddleware(runtime as never)); + registerStudioRoutes(app, context); + registerExperimentTrackingRoutes(app, context); + app.onError((error, ctx) => + isHttpStatus(error) + ? ctx.json({ detail: error.detail }, error.status as 400 | 403 | 404 | 500) + : ctx.json({ detail: "Internal Server Error" }, 500), + ); + return app; +}; + +const request = ( + app: Hono, + path: string, + init: RequestInit = {}, +) => + app.fetch( + new Request(`http://127.0.0.1${path}`, { + ...init, + headers: { "Content-Type": "application/json", ...init.headers }, + }), + ); + +const jsonBody = (data: unknown) => JSON.stringify(data); + +describe("Scientist onboarding end-to-end", () => { + test("mode picker → intake → profile persisted → template → project created → experiment tracked", async () => { + const { context, notebookRoot } = makeContext(); + const app = makeApp(context); + + // ── Step 1: Settings expose notebook_root ────────────────────────── + const settingsRes = await request(app, "/studio/settings"); + expect(settingsRes.status).toBe(200); + const settings = (await settingsRes.json()) as { notebook_root: string }; + expect(settings.notebook_root).toBe(notebookRoot); + + // ── Step 2: Scientist profile starts empty ───────────────────────── + const emptyProfileRes = await request(app, "/studio/scientist-profile"); + expect(emptyProfileRes.status).toBe(200); + expect((await emptyProfileRes.json()) as { profile: unknown }).toEqual({ + profile: null, + }); + + // ── Step 3: Save scientist profile (intake form submit) ──────────── + const profilePayload = { + research_field: "biology", + specialization: "Bioinformatics", + data_types: ["text", "genomic"], + goals: ["literature_review", "data_analysis", "experiment_pipeline"], + compute_preference: "local-smolvm", + experience_level: "some_code", + process_steps: [ + { id: "s1", label: "Load data", step_type: "data_collection", order: 1 }, + { id: "s2", label: "Clean data", step_type: "data_cleaning", order: 2 }, + { id: "s3", label: "Analyze", step_type: "analysis", order: 3 }, + ], + }; + const saveProfileRes = await request(app, "/studio/scientist-profile", { + method: "PUT", + body: jsonBody(profilePayload), + }); + expect(saveProfileRes.status).toBe(200); + const savedProfile = (await saveProfileRes.json()) as { + profile: { research_field: string; specialization: string; created_at: string }; + }; + expect(savedProfile.profile.research_field).toBe("biology"); + expect(savedProfile.profile.specialization).toBe("Bioinformatics"); + expect(savedProfile.profile.created_at).toBeTruthy(); + + // ── Step 4: Retrieve profile — persists across requests ──────────── + const getProfileRes = await request(app, "/studio/scientist-profile"); + expect(getProfileRes.status).toBe(200); + const retrieved = (await getProfileRes.json()) as { + profile: { research_field: string; goals: string[]; process_steps: unknown[] }; + }; + expect(retrieved.profile.research_field).toBe("biology"); + expect(retrieved.profile.goals).toContain("experiment_pipeline"); + expect(retrieved.profile.process_steps).toHaveLength(3); + + // ── Step 5: List project templates ───────────────────────────────── + const templatesRes = await request(app, "/studio/project-templates"); + expect(templatesRes.status).toBe(200); + const { templates } = (await templatesRes.json()) as { + templates: Array<{ id: string; name: string; notebook_cells: unknown[] }>; + }; + expect(templates.length).toBeGreaterThanOrEqual(3); + const templateIds = templates.map((t) => t.id); + expect(templateIds).toContain("literature-review"); + expect(templateIds).toContain("data-analysis"); + expect(templateIds).toContain("experiment-pipeline"); + + // ── Step 6: Materialize a template (project creation) ────────────── + // Use no project_path — should default to notebook_root/template_name + const materializeRes = await request( + app, + "/studio/project-templates/experiment-pipeline/materialize", + { method: "POST", body: jsonBody({}) }, + ); + expect(materializeRes.status).toBe(200); + const materialized = (await materializeRes.json()) as { + project_path: string; + notebook_path: string; + agent_context_path: string; + template_id: string; + template_name: string; + }; + expect(materialized.template_id).toBe("experiment-pipeline"); + expect(materialized.template_name).toBe("Experiment Pipeline"); + expect(materialized.project_path).toBe(join(notebookRoot, "Experiment Pipeline")); + + // Verify files exist on disk + expect(existsSync(materialized.notebook_path)).toBe(true); + expect(existsSync(materialized.agent_context_path)).toBe(true); + + // Verify notebook is valid nbformat 4 with content from the template + const notebook = JSON.parse( + readFileSync(materialized.notebook_path, "utf8"), + ) as { nbformat: number; cells: Array<{ cell_type: string; source: string[] }> }; + expect(notebook.nbformat).toBe(4); + expect(notebook.cells.length).toBeGreaterThan(0); + const firstCellSource = notebook.cells[0]?.source.join("") ?? ""; + expect(firstCellSource).toContain("Experiment Pipeline"); + + // Verify agent context has the template's prompt + const agentContext = readFileSync(materialized.agent_context_path, "utf8"); + expect(agentContext).toContain("experiment pipeline assistant"); + + // ── Step 7: Create a custom project via process-expression flow ──── + const customProjectName = "My Climate Analysis"; + const customCells = [ + { cell_type: "markdown" as const, source: "# My Climate Analysis\n\nGenerated from workflow." }, + { cell_type: "markdown" as const, source: "## Step: Load temperature data" }, + { cell_type: "code" as const, source: "import pandas as pd\ndf = pd.read_csv('temp.csv')" }, + { cell_type: "markdown" as const, source: "## Step: Analyze trends" }, + { cell_type: "code" as const, source: "from scipy import stats\nresult = stats.linregress(df['year'], df['temp'])" }, + ]; + const customAgentPrompt = "You are a research assistant for My Climate Analysis.\nThe workflow: 1. Load temperature data 2. Analyze trends."; + + const customRes = await request(app, "/studio/projects/custom", { + method: "POST", + body: jsonBody({ + project_name: customProjectName, + notebook_cells: customCells, + agent_prompt: customAgentPrompt, + }), + }); + expect(customRes.status).toBe(200); + const customProject = (await customRes.json()) as { + project_path: string; + notebook_path: string; + agent_context_path: string; + template_id: string; + template_name: string; + }; + expect(customProject.template_id).toBe("custom"); + expect(customProject.template_name).toBe(customProjectName); + expect(customProject.project_path).toBe(join(notebookRoot, customProjectName)); + + // Verify the custom notebook contains the user's actual workflow steps + const customNotebook = JSON.parse( + readFileSync(customProject.notebook_path, "utf8"), + ) as { cells: Array<{ cell_type: string; source: string[] }> }; + const customSources = customNotebook.cells.map((c) => c.source.join("")); + expect(customSources.some((s) => s.includes("Load temperature data"))).toBe(true); + expect(customSources.some((s) => s.includes("Analyze trends"))).toBe(true); + expect(customSources.some((s) => s.includes("linregress"))).toBe(true); + + // Verify agent context has the custom prompt + const customAgentContext = readFileSync(customProject.agent_context_path, "utf8"); + expect(customAgentContext).toContain("My Climate Analysis"); + expect(customAgentContext).toContain("Load temperature data"); + + // ── Step 8: Create an experiment for the custom project ──────────── + const projectId = customProject.project_path; + const createExpRes = await request(app, "/experiments", { + method: "POST", + body: jsonBody({ + project_id: projectId, + name: "Linear regression baseline", + parameters: { learning_rate: 0.001, batch_size: 32 }, + notes: "First run with default parameters", + }), + }); + expect(createExpRes.status).toBe(201); + const { experiment } = (await createExpRes.json()) as { + experiment: { + id: string; + project_id: string; + name: string; + status: string; + parameters: Record; + metrics: Record; + artifacts: unknown[]; + }; + }; + expect(experiment.id).toBeTruthy(); + expect(experiment.project_id).toBe(projectId); + expect(experiment.name).toBe("Linear regression baseline"); + expect(experiment.status).toBe("running"); + expect(experiment.parameters).toEqual({ learning_rate: 0.001, batch_size: 32 }); + expect(experiment.metrics).toEqual({}); + expect(experiment.artifacts).toEqual([]); + + // ── Step 9: Update experiment with results (succeeded) ───────────── + const updateRes = await request(app, `/experiments/${experiment.id}`, { + method: "PATCH", + body: jsonBody({ + status: "succeeded", + metrics: { r_squared: 0.87, mse: 0.043, slope: 0.015 }, + artifacts: [ + { name: "regression_plot.png", kind: "plot", path: "experiments/regression_plot.png" }, + { name: "model_coefficients.json", kind: "data", path: "experiments/coeffs.json" }, + ], + completed_at: new Date().toISOString(), + }), + }); + expect(updateRes.status).toBe(200); + const updated = (await updateRes.json()) as { + experiment: { + id: string; + status: string; + metrics: Record; + artifacts: Array<{ name: string; kind: string }>; + completed_at: string; + }; + }; + expect(updated.experiment.status).toBe("succeeded"); + expect(updated.experiment.metrics["r_squared"]).toBe(0.87); + expect(updated.experiment.metrics["mse"]).toBe(0.043); + expect(updated.experiment.artifacts).toHaveLength(2); + expect(updated.experiment.artifacts[0]?.name).toBe("regression_plot.png"); + expect(updated.experiment.completed_at).toBeTruthy(); + + // ── Step 10: Create a child experiment (lineage tracking) ────────── + const childExpRes = await request(app, "/experiments", { + method: "POST", + body: jsonBody({ + project_id: projectId, + name: "Tuned regression", + parameters: { learning_rate: 0.01, batch_size: 64 }, + parent_experiment_id: experiment.id, + notes: "Tuned hyperparameters based on baseline", + }), + }); + expect(childExpRes.status).toBe(201); + const { experiment: childExp } = (await childExpRes.json()) as { + experiment: { id: string; parent_experiment_id: string }; + }; + expect(childExp.parent_experiment_id).toBe(experiment.id); + + // ── Step 11: Retrieve lineage ────────────────────────────────────── + const lineageRes = await request(app, `/experiments/${childExp.id}/lineage`); + expect(lineageRes.status).toBe(200); + const { lineage } = (await lineageRes.json()) as { + lineage: Array<{ id: string; name: string }>; + }; + expect(lineage).toHaveLength(2); + expect(lineage[0]?.id).toBe(experiment.id); + expect(lineage[0]?.name).toBe("Linear regression baseline"); + expect(lineage[1]?.id).toBe(childExp.id); + expect(lineage[1]?.name).toBe("Tuned regression"); + + // ── Step 12: List experiments for the project ────────────────────── + const listRes = await request( + app, + `/experiments?project_id=${encodeURIComponent(projectId)}`, + ); + expect(listRes.status).toBe(200); + const { experiments } = (await listRes.json()) as { + experiments: Array<{ id: string; name: string; status: string }>; + }; + expect(experiments).toHaveLength(2); + const experimentNames = experiments.map((e) => e.name); + expect(experimentNames).toContain("Linear regression baseline"); + expect(experimentNames).toContain("Tuned regression"); + + // ── Step 13: Get a single experiment ─────────────────────────────── + const getExpRes = await request(app, `/experiments/${experiment.id}`); + expect(getExpRes.status).toBe(200); + const { experiment: fetchedExp } = (await getExpRes.json()) as { + experiment: { id: string; status: string; metrics: { r_squared: number } }; + }; + expect(fetchedExp.id).toBe(experiment.id); + expect(fetchedExp.status).toBe("succeeded"); + expect(fetchedExp.metrics["r_squared"]).toBe(0.87); + + // ── Step 14: Delete the child experiment ─────────────────────────── + const deleteRes = await request(app, `/experiments/${childExp.id}`, { + method: "DELETE", + }); + expect(deleteRes.status).toBe(200); + expect((await deleteRes.json()) as { success: boolean }).toEqual({ success: true }); + + // Verify it's gone + const getDeletedRes = await request(app, `/experiments/${childExp.id}`); + expect(getDeletedRes.status).toBe(404); + + // ── Step 15: Verify project directory structure on disk ──────────── + const projectDir = customProject.project_path; + expect(statSync(projectDir).isDirectory()).toBe(true); + expect(statSync(join(projectDir, "notebook.ipynb")).isFile()).toBe(true); + expect(statSync(join(projectDir, ".agent-context.md")).isFile()).toBe(true); + + // Template project should also exist + const templateProjectDir = materialized.project_path; + expect(statSync(templateProjectDir).isDirectory()).toBe(true); + expect(statSync(join(templateProjectDir, "notebook.ipynb")).isFile()).toBe(true); + }); + + test("custom project with explicit path creates at the specified location", async () => { + const { context, dataDir } = makeContext(); + const app = makeApp(context); + + const explicitPath = join(dataDir, "custom-location"); + const res = await request(app, "/studio/projects/custom", { + method: "POST", + body: jsonBody({ + project_name: "Explicit Path Project", + project_path: explicitPath, + notebook_cells: [ + { cell_type: "markdown", source: "# Explicit" }, + { cell_type: "code", source: "print('hello')" }, + ], + agent_prompt: "You are a helper.", + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { project_path: string }; + expect(body.project_path).toBe(resolve(explicitPath)); + expect(existsSync(join(body.project_path, "notebook.ipynb"))).toBe(true); + }); + + test("materialize template with explicit path creates at the specified location", async () => { + const { context, dataDir } = makeContext(); + const app = makeApp(context); + + const explicitPath = join(dataDir, "template-location"); + const res = await request( + app, + "/studio/project-templates/data-analysis/materialize", + { + method: "POST", + body: jsonBody({ project_path: explicitPath }), + }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + project_path: string; + template_id: string; + notebook_path: string; + }; + expect(body.template_id).toBe("data-analysis"); + expect(body.project_path).toBe(resolve(explicitPath)); + expect(existsSync(body.notebook_path)).toBe(true); + }); + + test("custom project rejects empty notebook cells", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const res = await request(app, "/studio/projects/custom", { + method: "POST", + body: jsonBody({ + project_name: "Empty", + notebook_cells: [], + agent_prompt: "You are a helper.", + }), + }); + expect(res.status).toBe(400); + }); + + test("materialize non-existent template returns 404", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const res = await request( + app, + "/studio/project-templates/nonexistent/materialize", + { method: "POST", body: jsonBody({}) }, + ); + expect(res.status).toBe(404); + }); + + test("experiment update on non-existent experiment returns 404", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const res = await request(app, "/experiments/nonexistent-id", { + method: "PATCH", + body: jsonBody({ status: "succeeded" }), + }); + expect(res.status).toBe(404); + }); + + test("experiment create rejects empty name", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const res = await request(app, "/experiments", { + method: "POST", + body: jsonBody({ project_id: "proj-1", name: "" }), + }); + expect(res.status).toBe(400); + }); + + // ── Profile validation edge cases ──────────────────────────────────── + + test("profile rejects empty data_types array", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const res = await request(app, "/studio/scientist-profile", { + method: "PUT", + body: jsonBody({ + research_field: "biology", + data_types: [], + goals: ["data_analysis"], + compute_preference: "local-smolvm", + experience_level: "some_code", + }), + }); + expect(res.status).toBe(400); + }); + + test("profile rejects empty goals array", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const res = await request(app, "/studio/scientist-profile", { + method: "PUT", + body: jsonBody({ + research_field: "biology", + data_types: ["text"], + goals: [], + compute_preference: "local-smolvm", + experience_level: "some_code", + }), + }); + expect(res.status).toBe(400); + }); + + test("profile rejects invalid research_field", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const res = await request(app, "/studio/scientist-profile", { + method: "PUT", + body: jsonBody({ + research_field: "astrology", + data_types: ["text"], + goals: ["data_analysis"], + compute_preference: "local-smolvm", + experience_level: "some_code", + }), + }); + expect(res.status).toBe(400); + }); + + test("profile rejects invalid experience_level", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const res = await request(app, "/studio/scientist-profile", { + method: "PUT", + body: jsonBody({ + research_field: "biology", + data_types: ["text"], + goals: ["data_analysis"], + compute_preference: "local-smolvm", + experience_level: "guru", + }), + }); + expect(res.status).toBe(400); + }); + + test("profile rejects missing required field (compute_preference)", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const res = await request(app, "/studio/scientist-profile", { + method: "PUT", + body: jsonBody({ + research_field: "biology", + data_types: ["text"], + goals: ["data_analysis"], + experience_level: "some_code", + }), + }); + expect(res.status).toBe(400); + }); + + test("profile update overwrites previous profile", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + // Save initial profile + await request(app, "/studio/scientist-profile", { + method: "PUT", + body: jsonBody({ + research_field: "biology", + specialization: "Genomics", + data_types: ["text", "genomic"], + goals: ["literature_review"], + compute_preference: "local-smolvm", + experience_level: "no_code", + }), + }); + + // Overwrite with different field + const updateRes = await request(app, "/studio/scientist-profile", { + method: "PUT", + body: jsonBody({ + research_field: "physics", + specialization: "Quantum", + data_types: ["sensor", "time_series"], + goals: ["data_analysis", "hypothesis_testing"], + compute_preference: "remote", + experience_level: "expert", + }), + }); + expect(updateRes.status).toBe(200); + const updated = (await updateRes.json()) as { + profile: { research_field: string; specialization: string }; + }; + expect(updated.profile.research_field).toBe("physics"); + expect(updated.profile.specialization).toBe("Quantum"); + + // Verify the overwrite persisted + const getRes = await request(app, "/studio/scientist-profile"); + const retrieved = (await getRes.json()) as { + profile: { research_field: string; goals: string[]; data_types: string[] }; + }; + expect(retrieved.profile.research_field).toBe("physics"); + expect(retrieved.profile.goals).toContain("hypothesis_testing"); + expect(retrieved.profile.data_types).toContain("sensor"); + }); + + test("profile with process_steps persists and retrieves steps in order", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const steps = [ + { id: "s1", label: "Collect", step_type: "data_collection", order: 1 }, + { id: "s2", label: "Clean", step_type: "data_cleaning", order: 2 }, + { id: "s3", label: "Explore", step_type: "exploration", order: 3 }, + { id: "s4", label: "Model", step_type: "modeling", order: 4 }, + { id: "s5", label: "Report", step_type: "reporting", order: 5 }, + ]; + const saveRes = await request(app, "/studio/scientist-profile", { + method: "PUT", + body: jsonBody({ + research_field: "climate", + data_types: ["time_series", "spatial"], + goals: ["experiment_pipeline", "model_training"], + compute_preference: "remote", + experience_level: "expert", + process_steps: steps, + }), + }); + expect(saveRes.status).toBe(200); + + const getRes = await request(app, "/studio/scientist-profile"); + const { profile } = (await getRes.json()) as { + profile: { process_steps: Array<{ id: string; label: string; order: number }> }; + }; + expect(profile.process_steps).toHaveLength(5); + expect(profile.process_steps[0]?.id).toBe("s1"); + expect(profile.process_steps[4]?.id).toBe("s5"); + expect(profile.process_steps.map((s) => s.order)).toEqual([1, 2, 3, 4, 5]); + }); + + // ── Template edge cases ────────────────────────────────────────────── + + test("materialize with custom project_name overrides template name", async () => { + const { context, notebookRoot } = makeContext(); + const app = makeApp(context); + + const res = await request( + app, + "/studio/project-templates/literature-review/materialize", + { + method: "POST", + body: jsonBody({ project_name: "My Custom Literature Review" }), + }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + project_path: string; + template_id: string; + template_name: string; + }; + expect(body.template_id).toBe("literature-review"); + expect(body.template_name).toBe("Literature Review"); + // project_path should use the custom name, not the template name + expect(body.project_path).toBe(join(notebookRoot, "My Custom Literature Review")); + }); + + test("materialize all four templates produces valid notebooks", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + for (const templateId of ["literature-review", "data-analysis", "experiment-pipeline", "blank"]) { + const res = await request( + app, + `/studio/project-templates/${templateId}/materialize`, + { method: "POST", body: jsonBody({}) }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + notebook_path: string; + agent_context_path: string; + template_id: string; + }; + expect(body.template_id).toBe(templateId); + + const notebook = JSON.parse( + readFileSync(body.notebook_path, "utf8"), + ) as { nbformat: number; cells: Array<{ cell_type: string }> }; + expect(notebook.nbformat).toBe(4); + expect(notebook.cells.length).toBeGreaterThan(0); + + const agentContext = readFileSync(body.agent_context_path, "utf8"); + expect(agentContext.length).toBeGreaterThan(0); + } + }); + + test("get individual template by id returns full template", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const res = await request(app, "/studio/project-templates/data-analysis"); + expect(res.status).toBe(200); + const { template } = (await res.json()) as { + template: { + id: string; + name: string; + notebook_cells: unknown[]; + agent_prompt: string; + recommended_goals: string[]; + }; + }; + expect(template.id).toBe("data-analysis"); + expect(template.name).toBe("Data Analysis"); + expect(template.notebook_cells.length).toBeGreaterThan(0); + expect(template.agent_prompt.length).toBeGreaterThan(0); + expect(template.recommended_goals).toContain("data_analysis"); + }); + + test("get non-existent template by id returns 404", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const res = await request(app, "/studio/project-templates/nonexistent"); + expect(res.status).toBe(404); + }); + + // ── Custom project edge cases ──────────────────────────────────────── + + test("custom project with only markdown cells creates valid notebook", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const res = await request(app, "/studio/projects/custom", { + method: "POST", + body: jsonBody({ + project_name: "Markdown Only", + notebook_cells: [ + { cell_type: "markdown", source: "# Title" }, + { cell_type: "markdown", source: "## Section" }, + { cell_type: "markdown", source: "Some text" }, + ], + agent_prompt: "You are a helper.", + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { notebook_path: string }; + const notebook = JSON.parse( + readFileSync(body.notebook_path, "utf8"), + ) as { cells: Array<{ cell_type: string }> }; + expect(notebook.cells).toHaveLength(3); + expect(notebook.cells.every((c) => c.cell_type === "markdown")).toBe(true); + }); + + test("custom project with many cells creates valid notebook", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const manyCells = Array.from({ length: 50 }, (_, i) => ({ + cell_type: i % 2 === 0 ? ("code" as const) : ("markdown" as const), + source: `# Cell ${i}\nprint(${i})`, + })); + const res = await request(app, "/studio/projects/custom", { + method: "POST", + body: jsonBody({ + project_name: "Many Cells", + notebook_cells: manyCells, + agent_prompt: "You are a helper.", + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { notebook_path: string }; + const notebook = JSON.parse( + readFileSync(body.notebook_path, "utf8"), + ) as { cells: unknown[] }; + expect(notebook.cells).toHaveLength(50); + }); + + test("custom project rejects missing project_name", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const res = await request(app, "/studio/projects/custom", { + method: "POST", + body: jsonBody({ + notebook_cells: [{ cell_type: "code", source: "print(1)" }], + agent_prompt: "You are a helper.", + }), + }); + expect(res.status).toBe(400); + }); + + test("custom project rejects missing agent_prompt", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const res = await request(app, "/studio/projects/custom", { + method: "POST", + body: jsonBody({ + project_name: "No Prompt", + notebook_cells: [{ cell_type: "code", source: "print(1)" }], + }), + }); + expect(res.status).toBe(400); + }); + + test("custom project rejects invalid cell_type", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const res = await request(app, "/studio/projects/custom", { + method: "POST", + body: jsonBody({ + project_name: "Bad Cell", + notebook_cells: [{ cell_type: "raw", source: "print(1)" }], + agent_prompt: "You are a helper.", + }), + }); + expect(res.status).toBe(400); + }); + + test("custom project rejects malformed body (not JSON)", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const res = await request(app, "/studio/projects/custom", { + method: "POST", + body: "not json", + }); + expect(res.status).toBe(400); + }); + + test("materialize template overwrites existing project directory", async () => { + const { context, dataDir } = makeContext(); + const app = makeApp(context); + + const projectPath = join(dataDir, "overwrite-test"); + // First materialize + const res1 = await request( + app, + "/studio/project-templates/blank/materialize", + { method: "POST", body: jsonBody({ project_path: projectPath }) }, + ); + expect(res1.status).toBe(200); + + // Second materialize to same path — should overwrite, not error + const res2 = await request( + app, + "/studio/project-templates/data-analysis/materialize", + { method: "POST", body: jsonBody({ project_path: projectPath }) }, + ); + expect(res2.status).toBe(200); + const body2 = (await res2.json()) as { template_id: string; notebook_path: string }; + expect(body2.template_id).toBe("data-analysis"); + + // Verify the notebook now has data-analysis content, not blank + const notebook = JSON.parse( + readFileSync(body2.notebook_path, "utf8"), + ) as { cells: Array<{ source: string[] }> }; + const firstSource = notebook.cells[0]?.source.join("") ?? ""; + expect(firstSource).toContain("Data Analysis"); + }); + + // ── Experiment lifecycle edge cases ────────────────────────────────── + + test("multiple experiments for same project are all listed", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const projectId = "proj-batch"; + const created: string[] = []; + for (let i = 0; i < 5; i++) { + const res = await request(app, "/experiments", { + method: "POST", + body: jsonBody({ + project_id: projectId, + name: `Experiment ${i}`, + parameters: { iteration: i }, + }), + }); + expect(res.status).toBe(201); + const { experiment } = (await res.json()) as { experiment: { id: string } }; + created.push(experiment.id); + } + + const listRes = await request( + app, + `/experiments?project_id=${encodeURIComponent(projectId)}`, + ); + const { experiments } = (await listRes.json()) as { + experiments: Array<{ id: string; name: string }>; + }; + expect(experiments).toHaveLength(5); + // All created experiment IDs should be present (order depends on + // created_at which may tie at same millisecond) + const listedIds = new Set(experiments.map((e) => e.id)); + for (const id of created) { + expect(listedIds.has(id)).toBe(true); + } + }); + + test("experiment list without project_id returns all experiments", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + // Create experiments for different projects + await request(app, "/experiments", { + method: "POST", + body: jsonBody({ project_id: "proj-a", name: "A1" }), + }); + await request(app, "/experiments", { + method: "POST", + body: jsonBody({ project_id: "proj-b", name: "B1" }), + }); + + const listRes = await request(app, "/experiments"); + expect(listRes.status).toBe(200); + const { experiments } = (await listRes.json()) as { + experiments: Array<{ name: string }>; + }; + expect(experiments).toHaveLength(2); + }); + + test("experiment list for non-existent project returns empty array", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const res = await request( + app, + `/experiments?project_id=${encodeURIComponent("non-existent")}`, + ); + expect(res.status).toBe(200); + const { experiments } = (await res.json()) as { experiments: unknown[] }; + expect(experiments).toEqual([]); + }); + + test("experiment status transitions: running → succeeded → (re-open) running", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + // Create + const createRes = await request(app, "/experiments", { + method: "POST", + body: jsonBody({ project_id: "proj-1", name: "Lifecycle test" }), + }); + const { experiment } = (await createRes.json()) as { experiment: { id: string } }; + + // running → succeeded + const succeedRes = await request(app, `/experiments/${experiment.id}`, { + method: "PATCH", + body: jsonBody({ status: "succeeded", completed_at: new Date().toISOString() }), + }); + expect(succeedRes.status).toBe(200); + const succeeded = (await succeedRes.json()) as { + experiment: { status: string; completed_at: string }; + }; + expect(succeeded.experiment.status).toBe("succeeded"); + expect(succeeded.experiment.completed_at).toBeTruthy(); + + // succeeded → running (re-open; completed_at is preserved since the + // update schema only accepts string | undefined, not null) + const reopenRes = await request(app, `/experiments/${experiment.id}`, { + method: "PATCH", + body: jsonBody({ status: "running" }), + }); + expect(reopenRes.status).toBe(200); + const reopened = (await reopenRes.json()) as { + experiment: { status: string; completed_at: string }; + }; + expect(reopened.experiment.status).toBe("running"); + // completed_at is preserved from the succeeded update (not cleared) + expect(reopened.experiment.completed_at).toBeTruthy(); + }); + + test("experiment failed status with error notes", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const createRes = await request(app, "/experiments", { + method: "POST", + body: jsonBody({ project_id: "proj-1", name: "Failed experiment" }), + }); + const { experiment } = (await createRes.json()) as { experiment: { id: string } }; + + const failRes = await request(app, `/experiments/${experiment.id}`, { + method: "PATCH", + body: jsonBody({ + status: "failed", + notes: "OOM at epoch 15, GPU memory exhausted", + completed_at: new Date().toISOString(), + }), + }); + expect(failRes.status).toBe(200); + const failed = (await failRes.json()) as { + experiment: { status: string; notes: string }; + }; + expect(failed.experiment.status).toBe("failed"); + expect(failed.experiment.notes).toContain("OOM"); + }); + + test("experiment cancelled status", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const createRes = await request(app, "/experiments", { + method: "POST", + body: jsonBody({ project_id: "proj-1", name: "Cancelled experiment" }), + }); + const { experiment } = (await createRes.json()) as { experiment: { id: string } }; + + const cancelRes = await request(app, `/experiments/${experiment.id}`, { + method: "PATCH", + body: jsonBody({ status: "cancelled" }), + }); + expect(cancelRes.status).toBe(200); + const cancelled = (await cancelRes.json()) as { + experiment: { status: string }; + }; + expect(cancelled.experiment.status).toBe("cancelled"); + }); + + test("experiment update with artifacts containing all kinds", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const createRes = await request(app, "/experiments", { + method: "POST", + body: jsonBody({ project_id: "proj-1", name: "Artifacts test" }), + }); + const { experiment } = (await createRes.json()) as { experiment: { id: string } }; + + const artifactKinds = ["model", "data", "plot", "report", "log", "other"] as const; + const artifacts = artifactKinds.map((kind, i) => ({ + name: `artifact-${kind}.bin`, + kind, + path: `outputs/${kind}-${i}.bin`, + digest: `sha256:${"a".repeat(64)}`, + size_bytes: 1024 * (i + 1), + })); + + const updateRes = await request(app, `/experiments/${experiment.id}`, { + method: "PATCH", + body: jsonBody({ artifacts }), + }); + expect(updateRes.status).toBe(200); + const updated = (await updateRes.json()) as { + experiment: { artifacts: Array<{ kind: string; name: string; size_bytes: number }> }; + }; + expect(updated.experiment.artifacts).toHaveLength(6); + expect(updated.experiment.artifacts.map((a) => a.kind)).toEqual([...artifactKinds]); + }); + + test("deep lineage chain (5 generations)", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const projectId = "proj-lineage"; + let parentId: string | undefined; + + // Create a chain of 5 experiments, each parented to the previous + for (let i = 0; i < 5; i++) { + const res = await request(app, "/experiments", { + method: "POST", + body: jsonBody({ + project_id: projectId, + name: `Generation ${i}`, + parent_experiment_id: parentId, + }), + }); + const { experiment } = (await res.json()) as { experiment: { id: string } }; + parentId = experiment.id; + } + + // Retrieve lineage from the last (youngest) experiment + const leafId = parentId!; + const lineageRes = await request(app, `/experiments/${leafId}/lineage`); + expect(lineageRes.status).toBe(200); + const { lineage } = (await lineageRes.json()) as { + lineage: Array<{ name: string }>; + }; + expect(lineage).toHaveLength(5); + // Lineage is ordered root → leaf + expect(lineage[0]?.name).toBe("Generation 0"); + expect(lineage[4]?.name).toBe("Generation 4"); + }); + + test("lineage for experiment with no parent returns single-element chain", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const createRes = await request(app, "/experiments", { + method: "POST", + body: jsonBody({ project_id: "proj-1", name: "Root experiment" }), + }); + const { experiment } = (await createRes.json()) as { experiment: { id: string } }; + + const lineageRes = await request(app, `/experiments/${experiment.id}/lineage`); + expect(lineageRes.status).toBe(200); + const { lineage } = (await lineageRes.json()) as { + lineage: Array<{ id: string }>; + }; + expect(lineage).toHaveLength(1); + expect(lineage[0]?.id).toBe(experiment.id); + }); + + test("lineage for non-existent experiment returns empty array", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const res = await request(app, "/experiments/non-existent-id/lineage"); + expect(res.status).toBe(200); + const { lineage } = (await res.json()) as { lineage: unknown[] }; + expect(lineage).toEqual([]); + }); + + test("delete experiment that doesn't exist returns 404", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const res = await request(app, "/experiments/non-existent", { method: "DELETE" }); + expect(res.status).toBe(404); + }); + + test("partial experiment update only changes provided fields", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + // Create with parameters and notes + const createRes = await request(app, "/experiments", { + method: "POST", + body: jsonBody({ + project_id: "proj-1", + name: "Partial update test", + parameters: { lr: 0.001, epochs: 10 }, + notes: "Initial notes", + }), + }); + const { experiment } = (await createRes.json()) as { experiment: { id: string } }; + + // Update only status — parameters and notes should be preserved + const updateRes = await request(app, `/experiments/${experiment.id}`, { + method: "PATCH", + body: jsonBody({ status: "succeeded" }), + }); + expect(updateRes.status).toBe(200); + const updated = (await updateRes.json()) as { + experiment: { + status: string; + parameters: { lr: number; epochs: number }; + notes: string; + metrics: Record; + }; + }; + expect(updated.experiment.status).toBe("succeeded"); + expect(updated.experiment.parameters).toEqual({ lr: 0.001, epochs: 10 }); + expect(updated.experiment.notes).toBe("Initial notes"); + expect(updated.experiment.metrics).toEqual({}); + }); + + test("experiment update replaces metrics entirely on update", async () => { + const { context } = makeContext(); + const app = makeApp(context); + + const createRes = await request(app, "/experiments", { + method: "POST", + body: jsonBody({ project_id: "proj-1", name: "Metrics replace test" }), + }); + const { experiment } = (await createRes.json()) as { experiment: { id: string } }; + + // Set initial metrics + await request(app, `/experiments/${experiment.id}`, { + method: "PATCH", + body: jsonBody({ metrics: { accuracy: 0.85, loss: 0.15 } }), + }); + + // Replace with different metrics + const updateRes = await request(app, `/experiments/${experiment.id}`, { + method: "PATCH", + body: jsonBody({ metrics: { f1: 0.92, precision: 0.90 } }), + }); + const updated = (await updateRes.json()) as { + experiment: { metrics: Record }; + }; + expect(updated.experiment.metrics).toEqual({ f1: 0.92, precision: 0.90 }); + expect(updated.experiment.metrics["accuracy"]).toBeUndefined(); + }); + + // ── Full lifecycle: profile → template → project → experiment → cleanup ── + + test("complete lifecycle: profile guides template choice, project tracks experiments", async () => { + const { context, notebookRoot } = makeContext(); + const app = makeApp(context); + + // 1. Scientist with biology + experiment_pipeline goal + await request(app, "/studio/scientist-profile", { + method: "PUT", + body: jsonBody({ + research_field: "biology", + specialization: "Computational Biology", + data_types: ["tabular", "genomic"], + goals: ["experiment_pipeline", "model_training"], + compute_preference: "local-jupyter", + experience_level: "some_code", + }), + }); + + // 2. Choose experiment-pipeline template (matches goals) + const templateRes = await request(app, "/studio/project-templates/experiment-pipeline"); + expect(templateRes.status).toBe(200); + const { template } = (await templateRes.json()) as { + template: { recommended_goals: string[] }; + }; + expect(template.recommended_goals).toContain("experiment_pipeline"); + + // 3. Materialize the template + const materializeRes = await request( + app, + "/studio/project-templates/experiment-pipeline/materialize", + { method: "POST", body: jsonBody({ project_name: "Bio Experiments" }) }, + ); + expect(materializeRes.status).toBe(200); + const project = (await materializeRes.json()) as { project_path: string }; + + // 4. Run multiple experiments with lineage + const baselineRes = await request(app, "/experiments", { + method: "POST", + body: jsonBody({ + project_id: project.project_path, + name: "Baseline gene expression", + parameters: { model: "rf", n_estimators: 100 }, + }), + }); + const { experiment: baseline } = (await baselineRes.json()) as { + experiment: { id: string }; + }; + + const tunedRes = await request(app, "/experiments", { + method: "POST", + body: jsonBody({ + project_id: project.project_path, + name: "Tuned gene expression", + parameters: { model: "xgboost", n_estimators: 500 }, + parent_experiment_id: baseline.id, + }), + }); + const { experiment: tuned } = (await tunedRes.json()) as { + experiment: { id: string }; + }; + + // 5. Record results + await request(app, `/experiments/${baseline.id}`, { + method: "PATCH", + body: jsonBody({ + status: "succeeded", + metrics: { accuracy: 0.82, f1: 0.79 }, + artifacts: [{ name: "model.pkl", kind: "model" }], + completed_at: new Date().toISOString(), + }), + }); + await request(app, `/experiments/${tuned.id}`, { + method: "PATCH", + body: jsonBody({ + status: "succeeded", + metrics: { accuracy: 0.91, f1: 0.89 }, + artifacts: [ + { name: "model.pkl", kind: "model" }, + { name: "confusion_matrix.png", kind: "plot" }, + ], + completed_at: new Date().toISOString(), + }), + }); + + // 6. Verify lineage and listing + const lineageRes = await request(app, `/experiments/${tuned.id}/lineage`); + const { lineage } = (await lineageRes.json()) as { + lineage: Array<{ name: string; status: string; metrics: Record }>; + }; + expect(lineage).toHaveLength(2); + expect(lineage[1]?.metrics["accuracy"]).toBe(0.91); + + const listRes = await request( + app, + `/experiments?project_id=${encodeURIComponent(project.project_path)}`, + ); + const { experiments } = (await listRes.json()) as { + experiments: Array<{ status: string }>; + }; + expect(experiments).toHaveLength(2); + expect(experiments.every((e) => e.status === "succeeded")).toBe(true); + + // 7. Verify project files exist on disk + expect(existsSync(join(notebookRoot, "Bio Experiments", "notebook.ipynb"))).toBe(true); + expect(existsSync(join(notebookRoot, "Bio Experiments", ".agent-context.md"))).toBe(true); + }); +}); diff --git a/controller/tests/studio-starter-presets.test.ts b/controller/tests/studio-starter-presets.test.ts new file mode 100644 index 000000000..524dccdee --- /dev/null +++ b/controller/tests/studio-starter-presets.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { STUDIO_STARTER_PRESETS } from "../src/modules/studio/configs"; + +describe("STUDIO_STARTER_PRESETS", () => { + test("every preset has a unique id", () => { + const ids = STUDIO_STARTER_PRESETS.map((preset) => preset.id); + assert.equal(new Set(ids).size, ids.length, "preset ids must be unique"); + }); + + test("every remote preset has a base_url and authentication", () => { + for (const preset of STUDIO_STARTER_PRESETS) { + if (preset.kind !== "remote") continue; + assert.ok(preset.remote, `${preset.id} is missing remote config`); + assert.ok(preset.remote!.base_url, `${preset.id} is missing base_url`); + assert.ok( + preset.remote!.authentication, + `${preset.id} is missing authentication`, + ); + } + }); + + test("local-llm-server preset is keyless and discovers models at setup", () => { + const preset = STUDIO_STARTER_PRESETS.find((preset) => preset.id === "local-llm-server"); + assert.ok(preset, "local-llm-server preset is missing"); + assert.equal(preset.kind, "remote"); + const remote = preset.remote!; + assert.equal(remote.authentication, "none"); + assert.equal(remote.model, ""); + assert.ok( + remote.base_url.startsWith("http://localhost:"), + "local-llm-server should target localhost", + ); + }); + + test("trustnest-apim preset carries Entra ID client-credentials defaults", () => { + const preset = STUDIO_STARTER_PRESETS.find((preset) => preset.id === "trustnest-apim"); + assert.ok(preset, "trustnest-apim preset is missing"); + assert.equal(preset.kind, "remote"); + const remote = preset.remote!; + assert.equal(remote.authentication, "apim_client"); + assert.equal(remote.model, ""); + assert.equal( + remote.audience, + "api://c94dc58f-d839-4fdf-b0a4-22442c7baf50", + ); + assert.ok( + remote.token_endpoint?.includes("login.microsoftonline.com"), + "token_endpoint should point to Entra ID", + ); + assert.ok( + remote.scopes?.length, + "trustnest-apim should declare at least one OAuth scope", + ); + assert.equal( + remote.subscription_key_header, + "TrustNest-Apim-Subscription-Key", + ); + assert.equal(remote.path_style, "openai"); + }); + + test("tensorprime-gemma4 preset is keyless and discovers models at setup", () => { + const preset = STUDIO_STARTER_PRESETS.find((preset) => preset.id === "tensorprime-gemma4"); + assert.ok(preset, "tensorprime-gemma4 preset is missing"); + assert.equal(preset.kind, "remote"); + const remote = preset.remote!; + assert.equal(remote.authentication, "none"); + assert.equal(remote.model, ""); + assert.equal(remote.base_url, "http://api.tprime.vlans.ca"); + }); + + test("tensorprime-litellm preset is keyless and discovers models at setup", () => { + const preset = STUDIO_STARTER_PRESETS.find((preset) => preset.id === "tensorprime-litellm"); + assert.ok(preset, "tensorprime-litellm preset is missing"); + assert.equal(preset.kind, "remote"); + const remote = preset.remote!; + assert.equal(remote.authentication, "none"); + assert.equal(remote.model, ""); + assert.equal(remote.base_url, "http://api.tprime.vlans.ca"); + assert.ok( + preset.tags.includes("multi-model"), + "tensorprime-litellm should be tagged multi-model", + ); + }); +}); diff --git a/controller/tsconfig.json b/controller/tsconfig.json index 204f0e032..29d1ae41d 100644 --- a/controller/tsconfig.json +++ b/controller/tsconfig.json @@ -18,7 +18,8 @@ "skipLibCheck": true, "resolveJsonModule": true, "paths": { - "@local-studio/contracts/*": ["./contracts/*.ts"] + "@local-studio/contracts/*": ["./contracts/*.ts"], + "@local-studio/agent-runtime/*": ["../services/agent-runtime/src/*.ts"] } }, "include": ["src/**/*.ts", "scripts/**/*.ts", "contracts/**/*.ts", "tests/**/*.ts"] diff --git a/deploy/azure/apim-preview/README.md b/deploy/azure/apim-preview/README.md new file mode 100644 index 000000000..71d2bb55c --- /dev/null +++ b/deploy/azure/apim-preview/README.md @@ -0,0 +1,7 @@ +# Preview AI Gateway evaluation + +This directory is intentionally isolated from the standard API Management release package. + +The preview gateway-wide runtime-key model does not satisfy the required per-user Entra or Keycloak authorization boundary. No preview deployment artifact is promoted from this directory. Evaluation results must separately prove subject, tenant, role, C2 clearance, revocation, model and agent allowlists, and credential removal before this profile can be reconsidered. + +The standard package validator rejects Bicep, deployment parameter, shell, and JavaScript deployment artifacts in this directory. diff --git a/deploy/azure/apim/README.md b/deploy/azure/apim/README.md new file mode 100644 index 000000000..d8f07c3b0 --- /dev/null +++ b/deploy/azure/apim/README.md @@ -0,0 +1,74 @@ +# Standard APIM deployment + + + + CI, license + + +This package deploys the governed `/ai/v1` API into an existing standard Azure API Management service. It imports a non-current API revision, revision-scoped named values, Key Vault references, the API policy, revision-scoped Foundry and Azure AI Content Safety backends, diagnostics, and managed-identity role assignments. + +The package does not create APIM, Microsoft Foundry, Content Safety, Key Vault, or Application Insights. Those resources remain deployment-owned. The target APIM service must be in the deployment resource group. Referenced resources may be in other resource groups or subscriptions visible to the deployment principal. + +## Inputs + +Copy `infra/main.parameters.example.json` outside the repository and replace every example value. The contract is defined by `parameters.schema.json` and enforced by `scripts/validate.mjs`; undeclared parameters, malformed allowlists, duplicate roles, issuer drift, tenant drift, unbounded quotas, endpoint substitution, and versioned, cross-vault, or unused secret references fail validation. `appInsightsLoggerResourceId` identifies an existing Application Insights logger under the APIM service. `keyVaultNamedValues` is an object whose keys are policy-consumed APIM named-value names and whose values are unversioned `https://.vault.azure.net/secrets/` identifiers; the parameter is secure and the example is deliberately empty. + +The deployment principal needs read and child-resource write access on APIM plus role-assignment write access at the Foundry account, Content Safety account, and Key Vault scopes. APIM receives: + +- Foundry User on the Foundry account for model and project-agent invocation. +- Cognitive Services User on Content Safety. +- Key Vault Secrets User only when Key Vault-backed named values are declared. + +## Local validation + +Install the Azure CLI Bicep component, then run: + +```sh +az bicep install +node deploy/azure/apim/scripts/validate.mjs +``` + +This compiles Bicep, checks XML and schema-bound parameter completeness, runs positive and negative hermetic APIM contracts, proves two revisions render to disjoint named-value and backend identities, and rejects deployable artifacts in the preview profile. The hermetic suite proves local configuration and policy structure only; it does not execute the policy gateway. + +## Azure validation and deployment + +Azure deployment remains gated by the repository deployment workflow. After an Azure preparation plan exists and validation is authorized: + +```sh +deploy/azure/apim/scripts/validate-azure.sh +deploy/azure/apim/scripts/deploy.sh +``` + +Identity preparation is explicit and separate from validation and deployment: + +```sh +deploy/azure/apim/scripts/enable-system-identity.sh +``` + +The preparation command preserves an existing user-assigned identity, enables the system-assigned identity, and waits for its principal. Validation and deployment fail rather than mutate identity when it is absent. Preflight verifies the supported APIM SKU, APIM logger, Foundry and Content Safety resource kinds, and Key Vault RBAC mode. + +For the first API revision only, set `bootstrapRevision` to `true`. This creates the first current revision at the otherwise unused `/ai/v1` path. The deploy script rejects bootstrap mode once an API exists and rejects a non-bootstrap deployment when no current API exists. Every subsequent deployment keeps the new revision non-current. + +Every revision receives a `${apiId}-${apiRevision}-` configuration prefix. Bicep rewrites policy named-value references to that snapshot and deploys uniquely named Foundry and Content Safety backends, so preparing a revision cannot mutate the configuration serving the current revision. Retain a revision's named values and backends for the entire rollback window. Remove them only through a separately reviewed retirement change after that revision can no longer be promoted. + +## Promotion and rollback + +Promote only a revision that passed live negative authorization, content-safety, streaming, telemetry, and managed-identity checks. Re-running promotion for the current revision is a no-op: + +```sh +deploy/azure/apim/scripts/promote-revision.sh local-studio-ai +``` + +Rollback requires a time-bounded approval manifest that binds the policy template and parameter SHA-256 digests to the target revision. Run rollback from the approved revision artifact checkout, copy `rollback-manifest.example.json` outside the repository, fill its approval fields and exact digests, and retain the approving change record. The required checks must record negative authorization, content safety, managed identity, streaming, telemetry, and configuration pairing: + +```sh +deploy/azure/apim/scripts/rollback-revision.sh +``` + +Promotion and rollback create APIM releases; neither silently infers a target revision. The rollback script rejects expired approvals and digest drift, then confirms the selected revision became current. After rollback, repeat the live denied-issuer, denied-audience, denied-role, denied-clearance, denied-tenant, model allowlist, agent allowlist, Content Safety, managed-identity, streaming, quota, diagnostics, and correlation checks before restoring client traffic. + +## Security and diagnostics + +The gateway replaces caller correlation values with its own request identifier, validates both supported token types before reading authorization claims, enforces body size before parsing, denies unadmitted models and agents before Content Safety or Foundry calls, removes inbound bearer, proxy, API-key, subscription-key, function-key, and cookie credentials, acquires a distinct Foundry token with managed identity, and routes only through the deployed TLS-validating Foundry backend. Policy-owned denial responses carry the gateway correlation identifier and emit warning traces with immutable subject and tenant identifiers. Application Insights diagnostics capture no request or response bodies, client IPs, authorization headers, API keys, or backend tokens. + +Anyone who can edit an APIM policy can indirectly use the APIM managed identity. Limit API and policy write permissions, review role assignments, and require the same revision evidence used for promotion. The Foundry backend, model allowlist, agent allowlist, issuer mappings, and APIM named values are a single promotion unit. diff --git a/deploy/azure/apim/api.openapi.yaml b/deploy/azure/apim/api.openapi.yaml new file mode 100644 index 000000000..46b84bab4 --- /dev/null +++ b/deploy/azure/apim/api.openapi.yaml @@ -0,0 +1,91 @@ +openapi: 3.0.3 +info: + title: Local Studio governed AI gateway + version: 1.0.0 +servers: + - url: https://gateway.example.com/ai/v1 +paths: + /models: + get: + operationId: models-list + responses: + "200": + description: Allowlist-filtered model catalog + /chat/completions: + post: + operationId: chat-completions + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ModelRequest" + responses: + "200": + description: Chat completion + /responses: + post: + operationId: responses-create + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ModelRequest" + responses: + "200": + description: Model response + /agents: + get: + operationId: agents-list + responses: + "200": + description: Allowlist-filtered project-agent catalog + /agents/{agentId}/invoke: + post: + operationId: agent-invoke + parameters: + - in: path + name: agentId + required: true + schema: + type: string + minLength: 1 + maxLength: 128 + pattern: "^[A-Za-z0-9][A-Za-z0-9._-]*$" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [input] + additionalProperties: false + properties: + input: {} + conversation_id: + type: string + maxLength: 256 + responses: + "200": + description: Agent response +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + schemas: + ModelRequest: + type: object + required: [model] + additionalProperties: true + properties: + model: + type: string + minLength: 1 + maxLength: 256 + stream: + type: boolean +security: + - bearerAuth: [] diff --git a/deploy/azure/apim/diagnostics.example.json b/deploy/azure/apim/diagnostics.example.json new file mode 100644 index 000000000..20c703189 --- /dev/null +++ b/deploy/azure/apim/diagnostics.example.json @@ -0,0 +1,45 @@ +{ + "alwaysLog": "allErrors", + "backend": { + "request": { + "body": { + "bytes": 0 + }, + "headers": ["x-correlation-id"] + }, + "response": { + "body": { + "bytes": 0 + }, + "headers": [ + "content-type", + "x-correlation-id", + "x-ms-input-tokens", + "x-ms-output-tokens", + "x-ms-total-tokens" + ] + } + }, + "frontend": { + "request": { + "body": { + "bytes": 0 + }, + "headers": ["x-correlation-id"] + }, + "response": { + "body": { + "bytes": 0 + }, + "headers": ["content-type", "x-correlation-id", "retry-after", "x-ratelimit-remaining"] + } + }, + "httpCorrelationProtocol": "W3C", + "logClientIp": false, + "metrics": true, + "sampling": { + "percentage": 100, + "samplingType": "fixed" + }, + "verbosity": "information" +} diff --git a/deploy/azure/apim/infra/main.bicep b/deploy/azure/apim/infra/main.bicep new file mode 100644 index 000000000..4a0542157 --- /dev/null +++ b/deploy/azure/apim/infra/main.bicep @@ -0,0 +1,229 @@ +targetScope = 'resourceGroup' + +@minLength(1) +param apimServiceName string + +@minLength(1) +param apiId string = 'local-studio-ai' + +@minLength(1) +param apiRevision string + +@maxLength(256) +param apiRevisionDescription string + +param bootstrapRevision bool = false + +param namedValues object + +@secure() +param keyVaultNamedValues object = {} + +param foundrySubscriptionId string = subscription().subscriptionId + +@minLength(1) +param foundryResourceGroupName string + +@minLength(2) +param foundryAccountName string + +param contentSafetySubscriptionId string = subscription().subscriptionId + +@minLength(1) +param contentSafetyResourceGroupName string + +@minLength(2) +param contentSafetyAccountName string + +@minLength(1) +param contentSafetyEndpoint string + +param keyVaultSubscriptionId string = subscription().subscriptionId + +@minLength(1) +param keyVaultResourceGroupName string + +@minLength(1) +param keyVaultName string + +@minLength(1) +param appInsightsLoggerResourceId string + +var apiName = '${apiId};rev=${apiRevision}' +var apiOpenApi = loadTextContent('../api.openapi.yaml') +var apiPolicy = loadTextContent('../policy.xml') +var diagnostics = loadJsonContent('../diagnostics.example.json') +var snapshotPrefix = '${apiId}-${apiRevision}-' +var contentSafetyBackendId = '${snapshotPrefix}content-safety' +var foundryBackendId = '${snapshotPrefix}foundry' +var policyNamedValues = union(namedValues, { + 'content-safety-backend-id': contentSafetyBackendId + 'foundry-backend-id': foundryBackendId +}) +var policyNamedValueNames = concat( + map(items(policyNamedValues), item => item.key), + map(items(keyVaultNamedValues), item => item.key) +) +var apiPolicySnapshot = reduce( + policyNamedValueNames, + apiPolicy, + (current, name) => replace(current, '{{${name}}}', '{{${snapshotPrefix}${name}}}') +) +var foundryRoleDefinitionId = '53ca6127-db72-4b80-b1b0-d745d6d5456d' +var contentSafetyRoleDefinitionId = 'a97b65f3-24c7-4388-baec-2e87135dc908' +var keyVaultReaderRoleDefinitionId = '4633458b-17de-408a-b874-0445c86b69e6' + +resource apim 'Microsoft.ApiManagement/service@2024-05-01' existing = { + name: apimServiceName +} + +resource api 'Microsoft.ApiManagement/service/apis@2024-05-01' = { + parent: apim + name: apiName + properties: { + apiRevision: apiRevision + apiRevisionDescription: apiRevisionDescription + apiType: 'http' + displayName: 'Local Studio governed AI gateway' + format: 'openapi' + isCurrent: bootstrapRevision + path: 'ai/v1' + protocols: [ + 'https' + ] + subscriptionRequired: false + type: 'http' + value: apiOpenApi + } +} + +resource plainNamedValues 'Microsoft.ApiManagement/service/namedValues@2024-05-01' = [ + for item in items(policyNamedValues): { + parent: apim + name: '${snapshotPrefix}${item.key}' + properties: { + displayName: item.key + secret: false + tags: [ + 'local-studio' + 'governed-ai' + ] + value: string(item.value) + } + } +] + +resource secretNamedValues 'Microsoft.ApiManagement/service/namedValues@2024-05-01' = [ + for item in items(keyVaultNamedValues): { + parent: apim + name: '${snapshotPrefix}${item.key}' + properties: { + displayName: item.key + keyVault: { + secretIdentifier: string(item.value) + } + secret: true + tags: [ + 'local-studio' + 'key-vault' + ] + } + } +] + +resource contentSafetyBackend 'Microsoft.ApiManagement/service/backends@2024-05-01' = { + parent: apim + name: contentSafetyBackendId + properties: { + credentials: { + authorization: { + parameter: 'https://cognitiveservices.azure.com' + scheme: 'ManagedIdentity' + } + } + description: 'Azure AI Content Safety through APIM system-assigned managed identity' + protocol: 'http' + title: 'Foundry content safety' + tls: { + validateCertificateChain: true + validateCertificateName: true + } + type: 'Single' + url: contentSafetyEndpoint + } +} + +resource foundryBackend 'Microsoft.ApiManagement/service/backends@2024-05-01' = { + parent: apim + name: foundryBackendId + properties: { + description: 'Microsoft Foundry project endpoint' + protocol: 'http' + title: 'Foundry project' + tls: { + validateCertificateChain: true + validateCertificateName: true + } + type: 'Single' + url: string(namedValues['foundry-project-endpoint']) + } +} + +resource apiPolicyResource 'Microsoft.ApiManagement/service/apis/policies@2024-05-01' = { + parent: api + name: 'policy' + properties: { + format: 'rawxml' + value: apiPolicySnapshot + } + dependsOn: [ + plainNamedValues + secretNamedValues + contentSafetyBackend + foundryBackend + ] +} + +resource apiDiagnostic 'Microsoft.ApiManagement/service/apis/diagnostics@2024-05-01' = { + parent: api + name: 'applicationinsights' + properties: union(diagnostics, { + loggerId: appInsightsLoggerResourceId + }) +} + +module foundryInvocationRole './modules/cognitive-role-assignment.bicep' = { + scope: resourceGroup(foundrySubscriptionId, foundryResourceGroupName) + name: 'foundry-invocation-${uniqueString(foundrySubscriptionId, foundryResourceGroupName, foundryAccountName, apimServiceName)}' + params: { + accountName: foundryAccountName + principalId: apim.identity.principalId + roleDefinitionId: foundryRoleDefinitionId + } +} + +module contentSafetyInvocationRole './modules/cognitive-role-assignment.bicep' = { + scope: resourceGroup(contentSafetySubscriptionId, contentSafetyResourceGroupName) + name: 'content-safety-${uniqueString(contentSafetySubscriptionId, contentSafetyResourceGroupName, contentSafetyAccountName, apimServiceName)}' + params: { + accountName: contentSafetyAccountName + principalId: apim.identity.principalId + roleDefinitionId: contentSafetyRoleDefinitionId + } +} + +module keyVaultSecretRole './modules/key-vault-role-assignment.bicep' = if (length(items(keyVaultNamedValues)) > 0) { + scope: resourceGroup(keyVaultSubscriptionId, keyVaultResourceGroupName) + name: 'key-vault-${uniqueString(keyVaultSubscriptionId, keyVaultResourceGroupName, keyVaultName, apimServiceName)}' + params: { + keyVaultName: keyVaultName + principalId: apim.identity.principalId + roleDefinitionId: keyVaultReaderRoleDefinitionId + } +} + +output apiId string = api.id +output apiRevision string = apiRevision +output apimPrincipalId string = apim.identity.principalId +output gatewayPath string = '/ai/v1' +output configurationSnapshotPrefix string = snapshotPrefix diff --git a/deploy/azure/apim/infra/main.parameters.example.json b/deploy/azure/apim/infra/main.parameters.example.json new file mode 100644 index 000000000..7885ecaca --- /dev/null +++ b/deploy/azure/apim/infra/main.parameters.example.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "apiRevision": { + "value": "2" + }, + "apiRevisionDescription": { + "value": "Governed Foundry gateway" + }, + "apimServiceName": { + "value": "replace-apim-name" + }, + "appInsightsLoggerResourceId": { + "value": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/replace-rg/providers/Microsoft.ApiManagement/service/replace-apim-name/loggers/applicationinsights" + }, + "bootstrapRevision": { + "value": false + }, + "contentSafetyAccountName": { + "value": "replace-content-safety" + }, + "contentSafetyEndpoint": { + "value": "https://replace-content-safety.cognitiveservices.azure.com" + }, + "contentSafetyResourceGroupName": { + "value": "replace-content-safety-rg" + }, + "foundryAccountName": { + "value": "replace-foundry-account" + }, + "foundryResourceGroupName": { + "value": "replace-foundry-rg" + }, + "keyVaultName": { + "value": "replace-key-vault" + }, + "keyVaultNamedValues": { + "value": {} + }, + "keyVaultResourceGroupName": { + "value": "replace-key-vault-rg" + }, + "namedValues": { + "value": { + "accepted-tenant": "00000000-0000-0000-0000-000000000000", + "allowed-agents": "research-assistant", + "allowed-models": "gpt-4.1", + "agent-operation-roles": "LocalStudio.Scientist,LocalStudio.AgentAdmin,LocalStudio.PlatformAdmin", + "apim-api-audience": "api://local-studio", + "clearance-claim": "local_studio_clearance", + "content-safety-threshold": "4", + "entra-issuer": "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0", + "entra-role-claim": "roles", + "entra-tenant-id": "00000000-0000-0000-0000-000000000000", + "foundry-project-endpoint": "https://replace-foundry-account.services.ai.azure.com/api/projects/project", + "keycloak-issuer": "https://keycloak.example.com/realms/local-studio", + "keycloak-openid-configuration": "https://keycloak.example.com/realms/local-studio/.well-known/openid-configuration", + "keycloak-role-claim": "local_studio_roles", + "model-operation-roles": "LocalStudio.Scientist,LocalStudio.Operator,LocalStudio.AgentAdmin,LocalStudio.PlatformAdmin", + "request-max-bytes": "1048576", + "request-quota-calls": "60", + "token-quota-per-minute": "50000" + } + } + } +} diff --git a/deploy/azure/apim/infra/modules/cognitive-role-assignment.bicep b/deploy/azure/apim/infra/modules/cognitive-role-assignment.bicep new file mode 100644 index 000000000..0e79342b6 --- /dev/null +++ b/deploy/azure/apim/infra/modules/cognitive-role-assignment.bicep @@ -0,0 +1,24 @@ +targetScope = 'resourceGroup' + +@minLength(2) +param accountName string + +param principalId string + +param roleDefinitionId string + +resource account 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = { + name: accountName +} + +resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: account + name: guid(account.id, principalId, roleDefinitionId) + properties: { + principalId: principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionId) + } +} + +output roleAssignmentId string = roleAssignment.id diff --git a/deploy/azure/apim/infra/modules/key-vault-role-assignment.bicep b/deploy/azure/apim/infra/modules/key-vault-role-assignment.bicep new file mode 100644 index 000000000..c1e5833d2 --- /dev/null +++ b/deploy/azure/apim/infra/modules/key-vault-role-assignment.bicep @@ -0,0 +1,24 @@ +targetScope = 'resourceGroup' + +@minLength(1) +param keyVaultName string + +param principalId string + +param roleDefinitionId string + +resource keyVault 'Microsoft.KeyVault/vaults@2024-11-01' existing = { + name: keyVaultName +} + +resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: keyVault + name: guid(keyVault.id, principalId, roleDefinitionId) + properties: { + principalId: principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionId) + } +} + +output roleAssignmentId string = roleAssignment.id diff --git a/deploy/azure/apim/parameters.example.json b/deploy/azure/apim/parameters.example.json new file mode 100644 index 000000000..28b5edf52 --- /dev/null +++ b/deploy/azure/apim/parameters.example.json @@ -0,0 +1,20 @@ +{ + "accepted-tenant": "00000000-0000-0000-0000-000000000000", + "allowed-agents": "research-assistant", + "allowed-models": "gpt-4.1", + "agent-operation-roles": "LocalStudio.Scientist,LocalStudio.AgentAdmin,LocalStudio.PlatformAdmin", + "apim-api-audience": "api://local-studio", + "clearance-claim": "local_studio_clearance", + "content-safety-threshold": "4", + "entra-issuer": "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0", + "entra-role-claim": "roles", + "entra-tenant-id": "00000000-0000-0000-0000-000000000000", + "foundry-project-endpoint": "https://replace-foundry-account.services.ai.azure.com/api/projects/project", + "keycloak-issuer": "https://keycloak.example.com/realms/local-studio", + "keycloak-openid-configuration": "https://keycloak.example.com/realms/local-studio/.well-known/openid-configuration", + "keycloak-role-claim": "local_studio_roles", + "model-operation-roles": "LocalStudio.Scientist,LocalStudio.Operator,LocalStudio.AgentAdmin,LocalStudio.PlatformAdmin", + "request-max-bytes": "1048576", + "request-quota-calls": "60", + "token-quota-per-minute": "50000" +} diff --git a/deploy/azure/apim/parameters.schema.json b/deploy/azure/apim/parameters.schema.json new file mode 100644 index 000000000..53af1d079 --- /dev/null +++ b/deploy/azure/apim/parameters.schema.json @@ -0,0 +1,125 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://local-studio.invalid/schemas/azure-apim-deployment-parameters.json", + "title": "Standard APIM deployment parameters", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "contentVersion", "parameters"], + "properties": { + "$schema": { + "const": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#" + }, + "contentVersion": { + "const": "1.0.0.0" + }, + "parameters": { + "type": "object", + "additionalProperties": false, + "required": [ + "apiRevision", + "apiRevisionDescription", + "apimServiceName", + "appInsightsLoggerResourceId", + "bootstrapRevision", + "contentSafetyAccountName", + "contentSafetyEndpoint", + "contentSafetyResourceGroupName", + "foundryAccountName", + "foundryResourceGroupName", + "keyVaultName", + "keyVaultNamedValues", + "keyVaultResourceGroupName", + "namedValues" + ], + "properties": { + "apiId": { + "$ref": "#/$defs/stringParameter" + }, + "apiRevision": { + "$ref": "#/$defs/stringParameter" + }, + "apiRevisionDescription": { + "$ref": "#/$defs/stringParameter" + }, + "apimServiceName": { + "$ref": "#/$defs/stringParameter" + }, + "appInsightsLoggerResourceId": { + "$ref": "#/$defs/stringParameter" + }, + "bootstrapRevision": { + "$ref": "#/$defs/booleanParameter" + }, + "contentSafetyAccountName": { + "$ref": "#/$defs/stringParameter" + }, + "contentSafetyEndpoint": { + "$ref": "#/$defs/stringParameter" + }, + "contentSafetyResourceGroupName": { + "$ref": "#/$defs/stringParameter" + }, + "contentSafetySubscriptionId": { + "$ref": "#/$defs/stringParameter" + }, + "foundryAccountName": { + "$ref": "#/$defs/stringParameter" + }, + "foundryResourceGroupName": { + "$ref": "#/$defs/stringParameter" + }, + "foundrySubscriptionId": { + "$ref": "#/$defs/stringParameter" + }, + "keyVaultName": { + "$ref": "#/$defs/stringParameter" + }, + "keyVaultNamedValues": { + "$ref": "#/$defs/objectParameter" + }, + "keyVaultResourceGroupName": { + "$ref": "#/$defs/stringParameter" + }, + "keyVaultSubscriptionId": { + "$ref": "#/$defs/stringParameter" + }, + "namedValues": { + "$ref": "#/$defs/objectParameter" + } + } + } + }, + "$defs": { + "booleanParameter": { + "type": "object", + "additionalProperties": false, + "required": ["value"], + "properties": { + "value": { + "type": "boolean" + } + } + }, + "objectParameter": { + "type": "object", + "additionalProperties": false, + "required": ["value"], + "properties": { + "value": { + "type": "object" + } + } + }, + "stringParameter": { + "type": "object", + "additionalProperties": false, + "required": ["value"], + "properties": { + "value": { + "type": "string", + "minLength": 1 + } + } + } + } +} diff --git a/deploy/azure/apim/policy.xml b/deploy/azure/apim/policy.xml new file mode 100644 index 000000000..a2e2f262a --- /dev/null +++ b/deploy/azure/apim/policy.xml @@ -0,0 +1,226 @@ + + + + + + @((string)context.Variables["correlation-id"]) + + + + + + {{apim-api-audience}} + + + + + + + + + {{apim-api-audience}} + + + {{keycloak-issuer}} + + + + + + + + + + + + @("denied reason=tenant correlation=" + (string)context.Variables["correlation-id"]) + + + + + + + @((string)context.Variables["correlation-id"]) + + {"error":"Tenant authorization denied"} + + + + + @("denied reason=clearance correlation=" + (string)context.Variables["correlation-id"]) + + + + + + + @((string)context.Variables["correlation-id"]) + + {"error":"C2 clearance required"} + + + + + + + + @("denied reason=role correlation=" + (string)context.Variables["correlation-id"]) + + + + + + + @((string)context.Variables["correlation-id"]) + + {"error":"Operation authorization denied"} + + + + + + + + + + + + + + + + @("denied reason=model correlation=" + (string)context.Variables["correlation-id"]) + + + + + + + @((string)context.Variables["correlation-id"]) + + {"error":"Model is not admitted"} + + + + + + + + + + + + + + + + + + + @("denied reason=agent correlation=" + (string)context.Variables["correlation-id"]) + + + + + + + @((string)context.Variables["correlation-id"]) + + {"error":"Agent is not admitted"} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + v1 + + + + + @{ var body = (JObject)context.Variables["agent-input"]; body["agent_reference"] = new JObject(new JProperty("name", context.Request.MatchedParameters["agentId"]), new JProperty("type", "agent_reference")); body.Remove("conversation_id"); return body.ToString(); } + + + + + + + + + + @("denied reason=operation correlation=" + (string)context.Variables["correlation-id"]) + + + + + + + @((string)context.Variables["correlation-id"]) + + + + + + @("admitted operation=" + context.Operation.Id + " correlation=" + (string)context.Variables["correlation-id"]) + + + + + + + + + + + + + @("Bearer " + (string)context.Variables["foundry-token"]) + + + + + + + + + @(context.Variables.GetValueOrDefault<string>("correlation-id", context.RequestId.ToString())) + + + + @{ var body = context.Response.Body.As<JObject>(preserveContent: true); var source = body["data"] as JArray ?? body["value"] as JArray ?? new JArray(); var allowed = (context.Operation.Id == "models-list" ? "{{allowed-models}}" : "{{allowed-agents}}").Split(','); body["data"] = new JArray(source.Where(item => allowed.Contains(item["id"]?.ToString() ?? item["name"]?.ToString() ?? ""))); body.Remove("value"); return body.ToString(); } + + + + + + + @(context.Variables.GetValueOrDefault<string>("correlation-id", context.RequestId.ToString())) + + + + diff --git a/deploy/azure/apim/rollback-manifest.example.json b/deploy/azure/apim/rollback-manifest.example.json new file mode 100644 index 000000000..01781e3f2 --- /dev/null +++ b/deploy/azure/apim/rollback-manifest.example.json @@ -0,0 +1,17 @@ +{ + "api_id": "local-studio-ai", + "approved_revision": "2", + "approval_reference": "replace-approved-change-record", + "approved_at": "2099-01-01T00:00:00Z", + "approval_expires_at": "2099-01-01T01:00:00Z", + "policy_sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "parameters_sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "required_checks": [ + "configuration-pairing", + "content-safety", + "managed-identity", + "negative-authorization", + "streaming", + "telemetry" + ] +} diff --git a/deploy/azure/apim/scripts/deploy.sh b/deploy/azure/apim/scripts/deploy.sh new file mode 100755 index 000000000..c2896c83b --- /dev/null +++ b/deploy/azure/apim/scripts/deploy.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 3 ]]; then + echo "usage: deploy.sh " >&2 + exit 64 +fi + +resource_group=$1 +apim_service=$2 +parameters_file=$3 +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +template_file="${script_dir}/../infra/main.bicep" +"${script_dir}/preflight-azure.sh" "${resource_group}" "${apim_service}" "${parameters_file}" + +az deployment group create \ + --resource-group "${resource_group}" \ + --template-file "${template_file}" \ + --parameters "@${parameters_file}" \ + --parameters apimServiceName="${apim_service}" \ + --name "local-studio-apim-$(date -u +%Y%m%dT%H%M%SZ)" diff --git a/deploy/azure/apim/scripts/enable-system-identity.sh b/deploy/azure/apim/scripts/enable-system-identity.sh new file mode 100755 index 000000000..b7cc728f5 --- /dev/null +++ b/deploy/azure/apim/scripts/enable-system-identity.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "usage: enable-system-identity.sh " >&2 + exit 64 +fi + +resource_group=$1 +apim_service=$2 +principal_id=$(az apim show --resource-group "${resource_group}" --name "${apim_service}" --query identity.principalId --output tsv) + +if [[ -n "${principal_id}" ]]; then + printf '%s\n' "${principal_id}" + exit 0 +fi + +identity_type=$(az apim show --resource-group "${resource_group}" --name "${apim_service}" --query identity.type --output tsv) +desired_identity_type=SystemAssigned +[[ "${identity_type}" == "UserAssigned" ]] && desired_identity_type="SystemAssigned, UserAssigned" +az apim update --resource-group "${resource_group}" --name "${apim_service}" --set "identity.type=${desired_identity_type}" --output none + +for _ in {1..10}; do + principal_id=$(az apim show --resource-group "${resource_group}" --name "${apim_service}" --query identity.principalId --output tsv) + if [[ -n "${principal_id}" ]]; then + printf '%s\n' "${principal_id}" + exit 0 + fi + sleep 3 +done + +echo "APIM system-assigned identity was not provisioned" >&2 +exit 1 diff --git a/deploy/azure/apim/scripts/preflight-azure.sh b/deploy/azure/apim/scripts/preflight-azure.sh new file mode 100755 index 000000000..a61f9fe2c --- /dev/null +++ b/deploy/azure/apim/scripts/preflight-azure.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 3 ]]; then + echo "usage: preflight-azure.sh " >&2 + exit 64 +fi + +resource_group=$1 +apim_service=$2 +parameters_file=$3 +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +node "${script_dir}/validate.mjs" "${parameters_file}" "${apim_service}" + +current_subscription=$(az account show --query id --output tsv) +apim=$(az apim show --resource-group "${resource_group}" --name "${apim_service}" --output json) +principal_id=$(jq -r '.identity.principalId // empty' <<<"${apim}") +[[ -n "${principal_id}" ]] || { echo "APIM system-assigned identity is required" >&2; exit 1; } +apim_sku=$(jq -r '.sku.name' <<<"${apim}") +[[ "${apim_sku}" =~ ^(Developer|Basic|BasicV2|Standard|StandardV2|Premium|PremiumV2)$ ]] || { echo "APIM SKU ${apim_sku} does not support the required Content Safety policy" >&2; exit 1; } + +api_id=$(jq -r '.parameters.apiId.value // "local-studio-ai"' "${parameters_file}") +bootstrap_revision=$(jq -r '.parameters.bootstrapRevision.value' "${parameters_file}") +if az apim api show --resource-group "${resource_group}" --service-name "${apim_service}" --api-id "${api_id}" --output none 2>/dev/null; then + api_exists=true +else + api_exists=false +fi +if [[ "${api_exists}" == "true" && "${bootstrap_revision}" == "true" ]]; then + echo "bootstrapRevision cannot replace an existing current API" >&2 + exit 1 +fi +if [[ "${api_exists}" == "false" && "${bootstrap_revision}" != "true" ]]; then + echo "The first API revision must explicitly set bootstrapRevision=true" >&2 + exit 1 +fi + +logger_id=$(jq -r '.parameters.appInsightsLoggerResourceId.value' "${parameters_file}") +expected_logger_prefix="/subscriptions/${current_subscription}/resourceGroups/${resource_group}/providers/Microsoft.ApiManagement/service/${apim_service}/loggers/" +normalized_logger_id=$(printf '%s' "${logger_id}" | tr '[:upper:]' '[:lower:]') +normalized_logger_prefix=$(printf '%s' "${expected_logger_prefix}" | tr '[:upper:]' '[:lower:]') +[[ "${normalized_logger_id}" == "${normalized_logger_prefix}"* ]] || { echo "Application Insights logger must belong to the target APIM service" >&2; exit 1; } +logger_type=$(az resource show --ids "${logger_id}" --query properties.loggerType --output tsv) +[[ "${logger_type}" == "applicationInsights" ]] || { echo "APIM logger must use Application Insights" >&2; exit 1; } + +foundry_subscription=$(jq -r --arg fallback "${current_subscription}" '.parameters.foundrySubscriptionId.value // $fallback' "${parameters_file}") +foundry_group=$(jq -r '.parameters.foundryResourceGroupName.value' "${parameters_file}") +foundry_name=$(jq -r '.parameters.foundryAccountName.value' "${parameters_file}") +foundry_id="/subscriptions/${foundry_subscription}/resourceGroups/${foundry_group}/providers/Microsoft.CognitiveServices/accounts/${foundry_name}" +foundry_kind=$(az resource show --ids "${foundry_id}" --query kind --output tsv) +[[ "${foundry_kind}" == "AIServices" ]] || { echo "Foundry account must have kind AIServices" >&2; exit 1; } + +content_subscription=$(jq -r --arg fallback "${current_subscription}" '.parameters.contentSafetySubscriptionId.value // $fallback' "${parameters_file}") +content_group=$(jq -r '.parameters.contentSafetyResourceGroupName.value' "${parameters_file}") +content_name=$(jq -r '.parameters.contentSafetyAccountName.value' "${parameters_file}") +content_id="/subscriptions/${content_subscription}/resourceGroups/${content_group}/providers/Microsoft.CognitiveServices/accounts/${content_name}" +content_kind=$(az resource show --ids "${content_id}" --query kind --output tsv) +[[ "${content_kind}" == "ContentSafety" ]] || { echo "Content Safety account must have kind ContentSafety" >&2; exit 1; } + +secret_count=$(jq '.parameters.keyVaultNamedValues.value | length' "${parameters_file}") +if (( secret_count > 0 )); then + vault_subscription=$(jq -r --arg fallback "${current_subscription}" '.parameters.keyVaultSubscriptionId.value // $fallback' "${parameters_file}") + vault_group=$(jq -r '.parameters.keyVaultResourceGroupName.value' "${parameters_file}") + vault_name=$(jq -r '.parameters.keyVaultName.value' "${parameters_file}") + vault_rbac=$(az keyvault show --subscription "${vault_subscription}" --resource-group "${vault_group}" --name "${vault_name}" --query properties.enableRbacAuthorization --output tsv) + [[ "${vault_rbac}" == "true" ]] || { echo "Key Vault must use Azure RBAC authorization" >&2; exit 1; } +fi diff --git a/deploy/azure/apim/scripts/promote-revision.sh b/deploy/azure/apim/scripts/promote-revision.sh new file mode 100755 index 000000000..cb2b36b3e --- /dev/null +++ b/deploy/azure/apim/scripts/promote-revision.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 4 || $# -gt 5 ]]; then + echo "usage: promote-revision.sh [notes]" >&2 + exit 64 +fi + +resource_group=$1 +apim_service=$2 +api_id=$3 +revision=$4 +notes=${5:-"Promote Local Studio governed AI gateway revision ${revision}"} +release_id="local-studio-${revision}-$(date -u +%Y%m%dT%H%M%SZ)" +[[ "${api_id}" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "api-id contains unsafe characters" >&2; exit 64; } +[[ "${revision}" =~ ^[A-Za-z0-9._-]{1,100}$ ]] || { echo "revision contains unsafe characters" >&2; exit 64; } +revisions=$(az apim api revision list --resource-group "${resource_group}" --service-name "${apim_service}" --api-id "${api_id}" --output json) +available=$(jq --arg revision "${revision}" '[.[] | select(.apiRevision == $revision)] | length' <<<"${revisions}") +current=$(jq --arg revision "${revision}" '[.[] | select(.apiRevision == $revision and .isCurrent == true)] | length' <<<"${revisions}") + +if [[ "${available}" != "1" ]]; then + echo "APIM revision ${revision} does not exist exactly once" >&2 + exit 1 +fi +if [[ "${current}" == "1" ]]; then + echo "APIM revision ${revision} is already current" + exit 0 +fi + +az apim api release create \ + --resource-group "${resource_group}" \ + --service-name "${apim_service}" \ + --api-id "${api_id}" \ + --api-revision "${revision}" \ + --release-id "${release_id}" \ + --notes "${notes}" diff --git a/deploy/azure/apim/scripts/prove-revision-isolation.mjs b/deploy/azure/apim/scripts/prove-revision-isolation.mjs new file mode 100644 index 000000000..12783b675 --- /dev/null +++ b/deploy/azure/apim/scripts/prove-revision-isolation.mjs @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const parametersPath = process.argv[2] + ? resolve(process.argv[2]) + : join(packageDirectory, "infra/main.parameters.example.json"); +const policyPath = join(packageDirectory, "policy.xml"); +const bicepPath = join(packageDirectory, "infra/main.bicep"); +const parameters = JSON.parse(readFileSync(parametersPath, "utf8")); +const policy = readFileSync(policyPath, "utf8"); +const bicep = readFileSync(bicepPath, "utf8"); +const valueOf = (name, fallback) => parameters.parameters[name]?.value ?? fallback; +const apiId = valueOf("apiId", "local-studio-ai"); +const configuredRevision = valueOf("apiRevision"); +const namedValues = valueOf("namedValues"); +const secretNames = Object.keys(valueOf("keyVaultNamedValues", {})); + +const sha256 = (value) => createHash("sha256").update(value).digest("hex"); + +const renderRevision = (revision) => { + const prefix = `${apiId}-${revision}-`; + const backends = [`${prefix}content-safety`, `${prefix}foundry`]; + const values = { + ...namedValues, + "content-safety-backend-id": backends[0], + "foundry-backend-id": backends[1], + }; + const names = [...Object.keys(values), ...secretNames]; + const renderedPolicy = names.reduce( + (current, name) => current.replaceAll(`{{${name}}}`, `{{${prefix}${name}}}`), + policy, + ); + const references = [...renderedPolicy.matchAll(/\{\{([a-z0-9-._]+)\}\}/gu)].map( + (match) => match[1], + ); + assert.ok(references.length > 0); + assert.ok(references.every((name) => name.startsWith(prefix))); + assert.ok(backends.every((name) => name.length <= 80)); + assert.ok(names.every((name) => `${prefix}${name}`.length <= 256)); + return { + prefix, + backends, + namedValues: names.map((name) => `${prefix}${name}`).sort(), + renderedPolicy, + }; +}; + +const nextRevision = configuredRevision === "isolation-next" ? "isolation-after" : "isolation-next"; +const configured = renderRevision(configuredRevision); +const candidate = renderRevision(nextRevision); +const configuredResources = new Set([...configured.backends, ...configured.namedValues]); +const sharedResources = [...candidate.backends, ...candidate.namedValues].filter((name) => + configuredResources.has(name), +); + +assert.deepEqual(sharedResources, []); +assert.notEqual(configured.renderedPolicy, candidate.renderedPolicy); +assert.match(bicep, /var apiPolicySnapshot = reduce\(/u); +assert.match(bicep, /name: '\$\{snapshotPrefix\}\$\{item\.key\}'/u); +assert.match(bicep, /var foundryBackendId = '\$\{snapshotPrefix\}foundry'/u); +assert.match(bicep, /var contentSafetyBackendId = '\$\{snapshotPrefix\}content-safety'/u); + +process.stdout.write( + `${JSON.stringify( + { + schema: "local-studio.apim-revision-isolation/v1", + parameters: basename(parametersPath), + template_sha256: sha256(policy), + configured: { + revision: configuredRevision, + prefix: configured.prefix, + backend_ids: configured.backends, + named_value_count: configured.namedValues.length, + rendered_policy_sha256: sha256(configured.renderedPolicy), + }, + candidate: { + revision: nextRevision, + prefix: candidate.prefix, + backend_ids: candidate.backends, + named_value_count: candidate.namedValues.length, + rendered_policy_sha256: sha256(candidate.renderedPolicy), + }, + shared_mutable_resources: sharedResources, + }, + null, + 2, + )}\n`, +); diff --git a/deploy/azure/apim/scripts/rollback-revision.sh b/deploy/azure/apim/scripts/rollback-revision.sh new file mode 100755 index 000000000..1b41d8fe0 --- /dev/null +++ b/deploy/azure/apim/scripts/rollback-revision.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 4 ]]; then + echo "usage: rollback-revision.sh " >&2 + exit 64 +fi + +resource_group=$1 +apim_service=$2 +rollback_manifest=$3 +parameters_file=$4 +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +policy_file="${script_dir}/../policy.xml" + +node "${script_dir}/validate.mjs" \ + "${parameters_file}" \ + "${apim_service}" \ + --configuration-only + +node "${script_dir}/validate-rollback.mjs" \ + "${rollback_manifest}" \ + "${parameters_file}" \ + "${policy_file}" + +api_id=$(jq -r '.api_id' "${rollback_manifest}") +revision=$(jq -r '.approved_revision' "${rollback_manifest}") +approval_reference=$(jq -r '.approval_reference' "${rollback_manifest}") + +"${script_dir}/promote-revision.sh" \ + "${resource_group}" \ + "${apim_service}" \ + "${api_id}" \ + "${revision}" \ + "Rollback Local Studio governed AI gateway to revision ${revision}; approval ${approval_reference}" + +current=$(az apim api revision list \ + --resource-group "${resource_group}" \ + --service-name "${apim_service}" \ + --api-id "${api_id}" \ + --query "[?apiRevision=='${revision}' && isCurrent].apiRevision | [0]" \ + --output tsv) +[[ "${current}" == "${revision}" ]] || { echo "Rollback revision did not become current" >&2; exit 1; } diff --git a/deploy/azure/apim/scripts/validate-azure.sh b/deploy/azure/apim/scripts/validate-azure.sh new file mode 100755 index 000000000..a7a96ba7d --- /dev/null +++ b/deploy/azure/apim/scripts/validate-azure.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 3 ]]; then + echo "usage: validate-azure.sh " >&2 + exit 64 +fi + +resource_group=$1 +apim_service=$2 +parameters_file=$3 +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +template_file="${script_dir}/../infra/main.bicep" +"${script_dir}/preflight-azure.sh" "${resource_group}" "${apim_service}" "${parameters_file}" + +az deployment group validate \ + --resource-group "${resource_group}" \ + --template-file "${template_file}" \ + --parameters "@${parameters_file}" \ + --parameters apimServiceName="${apim_service}" \ + --validation-level Provider + +az deployment group what-if \ + --resource-group "${resource_group}" \ + --template-file "${template_file}" \ + --parameters "@${parameters_file}" \ + --parameters apimServiceName="${apim_service}" diff --git a/deploy/azure/apim/scripts/validate-rollback.mjs b/deploy/azure/apim/scripts/validate-rollback.mjs new file mode 100644 index 000000000..b104087aa --- /dev/null +++ b/deploy/azure/apim/scripts/validate-rollback.mjs @@ -0,0 +1,84 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; + +if (process.argv.length !== 5) { + throw new Error("usage: validate-rollback.mjs "); +} + +const [, , manifestPath, parametersPath, policyPath] = process.argv; +const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); +const requiredFields = [ + "api_id", + "approved_revision", + "approval_reference", + "approved_at", + "approval_expires_at", + "policy_sha256", + "parameters_sha256", + "required_checks", +]; + +if ( + !manifest || + typeof manifest !== "object" || + Array.isArray(manifest) || + Object.keys(manifest).sort().join(",") !== requiredFields.sort().join(",") +) { + throw new Error("Rollback manifest fields do not match the approved contract"); +} + +if (!/^[A-Za-z0-9._-]+$/u.test(manifest.api_id)) { + throw new Error("Rollback api_id contains unsafe characters"); +} +if (!/^[A-Za-z0-9._-]{1,100}$/u.test(manifest.approved_revision)) { + throw new Error("Rollback approved_revision contains unsafe characters"); +} +if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{2,255}$/u.test(manifest.approval_reference)) { + throw new Error("Rollback approval_reference is invalid"); +} + +const approvedAt = Date.parse(manifest.approved_at); +const expiresAt = Date.parse(manifest.approval_expires_at); +const now = Date.now(); +if ( + !Number.isFinite(approvedAt) || + !Number.isFinite(expiresAt) || + approvedAt > now + 300_000 || + expiresAt <= now || + expiresAt - approvedAt > 86_400_000 +) { + throw new Error("Rollback approval window is invalid or expired"); +} + +const requiredChecks = [ + "configuration-pairing", + "content-safety", + "managed-identity", + "negative-authorization", + "streaming", + "telemetry", +]; +if ( + !Array.isArray(manifest.required_checks) || + manifest.required_checks.slice().sort().join(",") !== requiredChecks.sort().join(",") +) { + throw new Error("Rollback manifest does not carry every required verification gate"); +} + +const sha256 = (path) => createHash("sha256").update(readFileSync(path)).digest("hex"); +const expectedPolicy = sha256(policyPath); +const expectedParameters = sha256(parametersPath); +const parameters = JSON.parse(readFileSync(parametersPath, "utf8")); +const parameterApiId = parameters?.parameters?.apiId?.value ?? "local-studio-ai"; +const parameterRevision = parameters?.parameters?.apiRevision?.value; +if (manifest.api_id !== parameterApiId || manifest.approved_revision !== parameterRevision) { + throw new Error("Rollback target does not match the approved parameter document"); +} +if (manifest.policy_sha256 !== expectedPolicy) { + throw new Error("Rollback policy digest does not match the approved manifest"); +} +if (manifest.parameters_sha256 !== expectedParameters) { + throw new Error("Rollback parameter digest does not match the approved manifest"); +} + +process.stdout.write("Validated approved rollback manifest\n"); diff --git a/deploy/azure/apim/scripts/validate.mjs b/deploy/azure/apim/scripts/validate.mjs new file mode 100644 index 000000000..f756f4b4e --- /dev/null +++ b/deploy/azure/apim/scripts/validate.mjs @@ -0,0 +1,442 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptsDirectory = dirname(fileURLToPath(import.meta.url)); +const packageDirectory = resolve(scriptsDirectory, ".."); +const repositoryDirectory = resolve(packageDirectory, "../../.."); +const previewDirectory = resolve(packageDirectory, "../apim-preview"); +const policyPath = join(packageDirectory, "policy.xml"); +const schemaPath = join(packageDirectory, "parameters.schema.json"); +const legacyParametersPath = join(packageDirectory, "parameters.example.json"); +const parametersPath = process.argv[2] + ? resolve(process.argv[2]) + : join(packageDirectory, "infra/main.parameters.example.json"); +const bicepPath = join(packageDirectory, "infra/main.bicep"); +const policy = readFileSync(policyPath, "utf8"); +const bicep = readFileSync(bicepPath, "utf8"); +const schema = JSON.parse(readFileSync(schemaPath, "utf8")); +const parameters = JSON.parse(readFileSync(parametersPath, "utf8")); +const parameterValues = parameters?.parameters; +const schemaParameterProperties = schema?.properties?.parameters?.properties; +const schemaRequiredParameters = schema?.properties?.parameters?.required; + +if ( + !schemaParameterProperties || + typeof schemaParameterProperties !== "object" || + Array.isArray(schemaParameterProperties) || + !Array.isArray(schemaRequiredParameters) +) { + throw new Error("Deployment parameter schema is malformed"); +} + +if ( + parameters?.$schema !== + "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#" || + parameters?.contentVersion !== "1.0.0.0" || + Object.keys(parameters).some( + (name) => !["$schema", "contentVersion", "parameters"].includes(name), + ) +) { + throw new Error("Deployment parameter envelope does not match parameters.schema.json"); +} + +if (!parameterValues || typeof parameterValues !== "object" || Array.isArray(parameterValues)) { + throw new Error("Deployment parameters must contain a parameters object"); +} + +const undeclaredParameters = Object.keys(parameterValues).filter( + (name) => !(name in schemaParameterProperties), +); +if (undeclaredParameters.length > 0) { + throw new Error(`Undeclared deployment parameters: ${undeclaredParameters.join(", ")}`); +} + +const valueOf = (name) => { + const entry = parameterValues[name]; + if ( + !entry || + typeof entry !== "object" || + Array.isArray(entry) || + Object.keys(entry).length !== 1 || + !("value" in entry) + ) { + throw new Error(`Deployment parameter ${name} must have a value`); + } + return entry.value; +}; + +for (const name of schemaRequiredParameters) { + valueOf(name); +} + +for (const [name, entry] of Object.entries(parameterValues)) { + const reference = schemaParameterProperties[name]?.$ref; + const definitionName = + typeof reference === "string" + ? reference.match(/^#\/\$defs\/([A-Za-z]+Parameter)$/u)?.[1] + : null; + const expectedType = definitionName + ? schema?.$defs?.[definitionName]?.properties?.value?.type + : null; + const value = valueOf(name); + const actualType = Array.isArray(value) ? "array" : typeof value; + if ( + !expectedType || + actualType !== expectedType || + (expectedType === "object" && (value === null || Array.isArray(value))) || + (expectedType === "string" && value.length === 0) + ) { + throw new Error(`Deployment parameter ${name} must match its schema type`); + } +} + +const namedValues = valueOf("namedValues"); +const keyVaultNamedValues = valueOf("keyVaultNamedValues"); + +if (!namedValues || typeof namedValues !== "object" || Array.isArray(namedValues)) { + throw new Error("namedValues must be an object"); +} +if ( + !keyVaultNamedValues || + typeof keyVaultNamedValues !== "object" || + Array.isArray(keyVaultNamedValues) +) { + throw new Error("keyVaultNamedValues must be an object"); +} +if (typeof valueOf("bootstrapRevision") !== "boolean") { + throw new Error("bootstrapRevision must be a boolean"); +} + +const apiRevision = valueOf("apiRevision"); +if (typeof apiRevision !== "string" || !/^[A-Za-z0-9._-]{1,100}$/u.test(apiRevision)) { + throw new Error("apiRevision must use 1-100 safe identifier characters"); +} +const apiId = parameterValues.apiId?.value ?? "local-studio-ai"; +if ( + typeof apiId !== "string" || + apiId.length > 80 || + !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(apiId) +) { + throw new Error("apiId must use safe identifier characters"); +} +const snapshotPrefix = `${apiId}-${apiRevision}-`; +for (const backendId of [`${snapshotPrefix}content-safety`, `${snapshotPrefix}foundry`]) { + if (backendId.length > 80) { + throw new Error("apiId and apiRevision produce an oversized APIM backend identifier"); + } +} +if ( + typeof valueOf("apiRevisionDescription") !== "string" || + valueOf("apiRevisionDescription").length < 1 || + valueOf("apiRevisionDescription").length > 256 +) { + throw new Error("apiRevisionDescription must contain 1 through 256 characters"); +} + +const validateParameterString = (name, pattern, minimumLength, maximumLength) => { + const value = valueOf(name); + if ( + typeof value !== "string" || + value.length < minimumLength || + value.length > maximumLength || + !pattern.test(value) + ) { + throw new Error(`Deployment parameter ${name} is invalid`); + } +}; + +validateParameterString("apimServiceName", /^[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/u, 1, 50); +for (const name of [ + "contentSafetyResourceGroupName", + "foundryResourceGroupName", + "keyVaultResourceGroupName", +]) { + validateParameterString(name, /^(?!.*\.$)[A-Za-z0-9_().-]+$/u, 1, 90); +} +for (const name of ["contentSafetyAccountName", "foundryAccountName"]) { + validateParameterString(name, /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/u, 2, 64); +} +validateParameterString("keyVaultName", /^[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/u, 3, 24); +for (const name of [ + "contentSafetySubscriptionId", + "foundrySubscriptionId", + "keyVaultSubscriptionId", +]) { + if (parameterValues[name]) { + validateParameterString( + name, + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu, + 36, + 36, + ); + } +} + +const expectedApimService = process.argv[3] ?? valueOf("apimServiceName"); +if ( + typeof expectedApimService !== "string" || + expectedApimService.length > 50 || + !/^[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/u.test(expectedApimService) +) { + throw new Error("Target APIM service name is invalid"); +} +const loggerResourceId = valueOf("appInsightsLoggerResourceId"); +const loggerMatch = + typeof loggerResourceId === "string" + ? loggerResourceId.match( + /^\/subscriptions\/[^/]+\/resourceGroups\/[^/]+\/providers\/Microsoft\.ApiManagement\/service\/([^/]+)\/loggers\/([^/]+)$/iu, + ) + : null; +if (!loggerMatch || loggerMatch[1].toLowerCase() !== expectedApimService.toLowerCase()) { + throw new Error( + "appInsightsLoggerResourceId must identify a logger under the target APIM service", + ); +} + +const parseUrl = (value, label) => { + try { + return new URL(value); + } catch { + throw new Error(`${label} must be an absolute URL`); + } +}; + +const contentSafetyEndpoint = parseUrl(valueOf("contentSafetyEndpoint"), "contentSafetyEndpoint"); +if ( + contentSafetyEndpoint.protocol !== "https:" || + contentSafetyEndpoint.hostname.toLowerCase() !== + `${valueOf("contentSafetyAccountName")}.cognitiveservices.azure.com`.toLowerCase() || + contentSafetyEndpoint.username !== "" || + contentSafetyEndpoint.password !== "" || + contentSafetyEndpoint.port !== "" || + !["", "/"].includes(contentSafetyEndpoint.pathname) || + contentSafetyEndpoint.search !== "" || + contentSafetyEndpoint.hash !== "" +) { + throw new Error("contentSafetyEndpoint must be an origin-only Azure Cognitive Services URL"); +} + +const placeholders = [...policy.matchAll(/\{\{([a-z0-9-]+)\}\}/gu)].map((match) => match[1]); +const generatedNamedValueNames = new Set(["content-safety-backend-id", "foundry-backend-id"]); +for (const name of generatedNamedValueNames) { + if (name in namedValues || name in keyVaultNamedValues) { + throw new Error(`Named value ${name} is generated by the deployment`); + } +} +const configuredNamedValueNames = new Set([ + ...Object.keys(namedValues), + ...Object.keys(keyVaultNamedValues), + ...generatedNamedValueNames, +]); +const missing = [...new Set(placeholders)].filter((name) => !configuredNamedValueNames.has(name)); +const bicepNamedValues = [...bicep.matchAll(/namedValues\['([a-z0-9-]+)'\]/gu)].map( + (match) => match[1], +); +const consumedNamedValues = new Set([...placeholders, ...bicepNamedValues]); +const unused = Object.keys(namedValues).filter((name) => !consumedNamedValues.has(name)); + +if (missing.length > 0) throw new Error(`Missing named values: ${missing.join(", ")}`); +if (unused.length > 0) throw new Error(`Unused named values: ${unused.join(", ")}`); +for (const [name, value] of Object.entries(namedValues)) { + if ( + !/^[A-Za-z0-9-._]{1,256}$/u.test(name) || + `${snapshotPrefix}${name}`.length > 256 || + typeof value !== "string" || + value === "" + ) { + throw new Error(`Named value ${name} must have a valid name and nonempty string value`); + } +} + +const foundryProjectEndpoint = parseUrl( + namedValues["foundry-project-endpoint"], + "foundry-project-endpoint", +); +if ( + foundryProjectEndpoint.protocol !== "https:" || + foundryProjectEndpoint.hostname.toLowerCase() !== + `${valueOf("foundryAccountName")}.services.ai.azure.com`.toLowerCase() || + foundryProjectEndpoint.username !== "" || + foundryProjectEndpoint.password !== "" || + foundryProjectEndpoint.port !== "" || + !/^\/api\/projects\/[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/u.test(foundryProjectEndpoint.pathname) || + foundryProjectEndpoint.search !== "" || + foundryProjectEndpoint.hash !== "" +) { + throw new Error("foundry-project-endpoint must identify one Microsoft Foundry project"); +} + +const tenantId = namedValues["entra-tenant-id"]; +const acceptedTenant = namedValues["accepted-tenant"]; +const entraIssuer = parseUrl(namedValues["entra-issuer"], "entra-issuer"); +if ( + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(tenantId) || + acceptedTenant.toLowerCase() !== tenantId.toLowerCase() || + entraIssuer.href !== `https://login.microsoftonline.com/${tenantId}/v2.0` +) { + throw new Error("Accepted tenant and Entra issuer must exactly match the configured tenant"); +} + +const keycloakIssuer = parseUrl(namedValues["keycloak-issuer"], "keycloak-issuer"); +const keycloakDiscovery = parseUrl( + namedValues["keycloak-openid-configuration"], + "keycloak-openid-configuration", +); +if ( + keycloakIssuer.protocol !== "https:" || + keycloakIssuer.username !== "" || + keycloakIssuer.password !== "" || + keycloakDiscovery.username !== "" || + keycloakDiscovery.password !== "" || + keycloakDiscovery.origin !== keycloakIssuer.origin || + keycloakDiscovery.pathname !== + `${keycloakIssuer.pathname.replace(/\/$/u, "")}/.well-known/openid-configuration` || + keycloakIssuer.search !== "" || + keycloakIssuer.hash !== "" || + keycloakDiscovery.search !== "" || + keycloakDiscovery.hash !== "" +) { + throw new Error("Keycloak discovery must be HTTPS and derived from the exact issuer"); +} + +const parseBoundedInteger = (name, minimum, maximum) => { + const raw = namedValues[name]; + if (!/^[1-9][0-9]*$/u.test(raw)) { + throw new Error(`${name} must be an integer from ${minimum} through ${maximum}`); + } + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw new Error(`${name} must be an integer from ${minimum} through ${maximum}`); + } + return value; +}; + +parseBoundedInteger("request-max-bytes", 1024, 10_485_760); +parseBoundedInteger("request-quota-calls", 1, 100_000); +parseBoundedInteger("token-quota-per-minute", 1, 100_000_000); +if (!/^[0-7]$/u.test(namedValues["content-safety-threshold"])) { + throw new Error("content-safety-threshold must be an integer from 0 through 7"); +} + +const validateIdentifier = (name, pattern) => { + const value = namedValues[name]; + if (typeof value !== "string" || !pattern.test(value)) { + throw new Error(`${name} is not a valid identifier`); + } +}; + +for (const name of ["clearance-claim", "entra-role-claim", "keycloak-role-claim"]) { + validateIdentifier(name, /^[A-Za-z_][A-Za-z0-9_.:-]{0,127}$/u); +} + +const validateCsv = (name, pattern, maximumItems) => { + const value = namedValues[name]; + const entries = typeof value === "string" ? value.split(",") : []; + if ( + entries.length < 1 || + entries.length > maximumItems || + entries.some((entry) => !pattern.test(entry)) || + new Set(entries).size !== entries.length + ) { + throw new Error(`${name} must contain unique comma-separated identifiers`); + } +}; + +validateCsv("allowed-agents", /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u, 100); +validateCsv("allowed-models", /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/u, 100); +validateCsv("agent-operation-roles", /^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u, 20); +validateCsv("model-operation-roles", /^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u, 20); + +const apiAudience = parseUrl(namedValues["apim-api-audience"], "apim-api-audience"); +if ( + !["api:", "https:"].includes(apiAudience.protocol) || + apiAudience.username !== "" || + apiAudience.password !== "" || + apiAudience.hostname === "" || + apiAudience.search !== "" || + apiAudience.hash !== "" +) { + throw new Error("apim-api-audience must be an api or HTTPS URI without query or fragment"); +} +for (const [name, secretIdentifier] of Object.entries(keyVaultNamedValues)) { + if (!/^[A-Za-z0-9-._]{1,256}$/u.test(name) || `${snapshotPrefix}${name}`.length > 256) { + throw new Error(`Key Vault named value ${name} has an invalid name`); + } + if (name in namedValues) throw new Error(`Named value ${name} is declared as plain and secret`); + const secretUrl = parseUrl(secretIdentifier, `Key Vault named value ${name}`); + const segments = secretUrl.pathname.split("/").filter(Boolean); + if ( + secretUrl.protocol !== "https:" || + secretUrl.hostname.toLowerCase() !== + `${valueOf("keyVaultName")}.vault.azure.net`.toLowerCase() || + secretUrl.username !== "" || + secretUrl.password !== "" || + secretUrl.port !== "" || + segments.length !== 2 || + segments[0] !== "secrets" || + secretUrl.search !== "" || + secretUrl.hash !== "" + ) { + throw new Error(`Key Vault named value ${name} must use an unversioned Azure secret URL`); + } +} +const unusedSecretNames = Object.keys(keyVaultNamedValues).filter( + (name) => !placeholders.includes(name), +); +if (unusedSecretNames.length > 0) { + throw new Error(`Unused Key Vault named values: ${unusedSecretNames.join(", ")}`); +} + +const previewEntries = readdirSync(previewDirectory, { recursive: true, withFileTypes: true }); +if ( + previewEntries.length !== 1 || + !previewEntries[0].isFile() || + previewEntries[0].name !== "README.md" +) { + throw new Error( + `Preview profile must contain only README.md: ${previewEntries.map(({ name }) => name).join(", ")}`, + ); +} + +const legacyNamedValues = JSON.parse(readFileSync(legacyParametersPath, "utf8")); +const sortedKeys = (value) => JSON.stringify(Object.keys(value).sort()); +if (sortedKeys(legacyNamedValues) !== sortedKeys(namedValues)) { + throw new Error("Legacy and deployable named-value names have drifted"); +} + +if (process.argv.includes("--configuration-only")) { + process.stdout.write(`Validated APIM configuration with ${basename(parametersPath)}\n`); + process.exit(0); +} + +execFileSync("xmllint", ["--noout", policyPath], { stdio: "inherit" }); +execFileSync("node", ["--test", "deploy/azure/apim/tests/hermetic-contract.test.mjs"], { + cwd: repositoryDirectory, + stdio: "inherit", +}); +execFileSync("bun", ["test", "controller/tests/apim-policy-contract.test.ts"], { + cwd: repositoryDirectory, + stdio: "inherit", +}); +execFileSync("node", ["deploy/azure/apim/scripts/prove-revision-isolation.mjs", parametersPath], { + cwd: repositoryDirectory, + stdio: "inherit", +}); + +const outputDirectory = mkdtempSync(join(tmpdir(), "local-studio-apim-")); +try { + execFileSync( + "az", + ["bicep", "build", "--file", bicepPath, "--outfile", join(outputDirectory, "main.json")], + { stdio: "inherit" }, + ); +} finally { + rmSync(outputDirectory, { force: true, recursive: true }); +} + +process.stdout.write( + `Validated ${basename(packageDirectory)} stable deployment package with ${basename(parametersPath)}\n`, +); diff --git a/deploy/azure/apim/tests/hermetic-contract.test.mjs b/deploy/azure/apim/tests/hermetic-contract.test.mjs new file mode 100644 index 000000000..3acf190b4 --- /dev/null +++ b/deploy/azure/apim/tests/hermetic-contract.test.mjs @@ -0,0 +1,269 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const validatorPath = join(packageDirectory, "scripts/validate.mjs"); +const rollbackValidatorPath = join(packageDirectory, "scripts/validate-rollback.mjs"); +const examplePath = join(packageDirectory, "infra/main.parameters.example.json"); +const policy = readFileSync(join(packageDirectory, "policy.xml"), "utf8"); +const bicep = readFileSync(join(packageDirectory, "infra/main.bicep"), "utf8"); +const example = JSON.parse(readFileSync(examplePath, "utf8")); + +const clone = (value) => JSON.parse(JSON.stringify(value)); +const sha256 = (path) => createHash("sha256").update(readFileSync(path)).digest("hex"); + +const runConfigurationValidation = (document) => { + const directory = mkdtempSync(join(tmpdir(), "local-studio-apim-contract-")); + const path = join(directory, "parameters.json"); + writeFileSync(path, JSON.stringify(document)); + const result = spawnSync( + process.execPath, + [validatorPath, path, document.parameters.apimServiceName.value, "--configuration-only"], + { encoding: "utf8" }, + ); + rmSync(directory, { force: true, recursive: true }); + return result; +}; + +const expectDenied = (mutate, expected) => { + const document = clone(example); + mutate(document); + const result = runConfigurationValidation(document); + assert.notEqual(result.status, 0); + assert.match(`${result.stdout}${result.stderr}`, expected); +}; + +const approvedRollbackManifest = () => { + const approvedAt = new Date(); + return { + api_id: "local-studio-ai", + approved_revision: example.parameters.apiRevision.value, + approval_reference: "change:approved-123", + approved_at: approvedAt.toISOString(), + approval_expires_at: new Date(approvedAt.getTime() + 3_600_000).toISOString(), + policy_sha256: sha256(join(packageDirectory, "policy.xml")), + parameters_sha256: sha256(examplePath), + required_checks: [ + "configuration-pairing", + "content-safety", + "managed-identity", + "negative-authorization", + "streaming", + "telemetry", + ], + }; +}; + +const runRollbackValidation = (manifest) => { + const directory = mkdtempSync(join(tmpdir(), "local-studio-apim-rollback-")); + const manifestPath = join(directory, "rollback.json"); + writeFileSync(manifestPath, JSON.stringify(manifest)); + const result = spawnSync( + process.execPath, + [rollbackValidatorPath, manifestPath, examplePath, join(packageDirectory, "policy.xml")], + { encoding: "utf8" }, + ); + rmSync(directory, { force: true, recursive: true }); + return result; +}; + +const createFakeAzure = (revision) => { + const directory = mkdtempSync(join(tmpdir(), "local-studio-apim-azure-")); + const executable = join(directory, "az"); + const log = join(directory, "calls.jsonl"); + writeFileSync( + executable, + `#!/usr/bin/env node +import { appendFileSync } from "node:fs"; +const args = process.argv.slice(2); +appendFileSync(process.env.APIM_FAKE_AZ_LOG, JSON.stringify(args) + "\\n"); +if (args.slice(0, 4).join(" ") === "apim api revision list") { + process.stdout.write(args.includes("--query") ? process.env.APIM_FAKE_REVISION + "\\n" : JSON.stringify([{ apiRevision: process.env.APIM_FAKE_REVISION, isCurrent: false }])); + process.exit(0); +} +if (args.slice(0, 4).join(" ") === "apim api release create") process.exit(0); +process.stderr.write("unexpected az invocation\\n"); +process.exit(2); +`, + ); + chmodSync(executable, 0o755); + return { directory, log, revision }; +}; + +const runAzureScript = (fake, script, args) => + spawnSync("bash", [join(packageDirectory, "scripts", script), ...args], { + encoding: "utf8", + env: { + ...process.env, + APIM_FAKE_AZ_LOG: fake.log, + APIM_FAKE_REVISION: fake.revision, + PATH: `${fake.directory}:${process.env.PATH}`, + }, + }); + +test("accepts the checked-in secret-free deployment contract", () => { + const result = runConfigurationValidation(example); + assert.equal(result.status, 0, `${result.stdout}${result.stderr}`); + assert.match(result.stdout, /Validated APIM configuration/u); +}); + +test("accepts deployment-owned values without weakening the schema", () => { + const document = clone(example); + document.parameters.apimServiceName.value = "governed-apim"; + document.parameters.appInsightsLoggerResourceId.value = + "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/governed-rg/providers/Microsoft.ApiManagement/service/governed-apim/loggers/applicationinsights"; + document.parameters.contentSafetyAccountName.value = "governed-safety"; + document.parameters.contentSafetyEndpoint.value = + "https://governed-safety.cognitiveservices.azure.com"; + document.parameters.foundryAccountName.value = "governed-foundry"; + document.parameters.keyVaultName.value = "governed-vault"; + const values = document.parameters.namedValues.value; + values["accepted-tenant"] = "11111111-1111-1111-1111-111111111111"; + values["entra-tenant-id"] = "11111111-1111-1111-1111-111111111111"; + values["entra-issuer"] = + "https://login.microsoftonline.com/11111111-1111-1111-1111-111111111111/v2.0"; + values["foundry-project-endpoint"] = + "https://governed-foundry.services.ai.azure.com/api/projects/research"; + values["allowed-models"] = "gpt-4.1,gpt-5"; + const result = runConfigurationValidation(document); + assert.equal(result.status, 0, `${result.stdout}${result.stderr}`); +}); + +test("rejects undeclared deployment parameters", () => { + expectDenied((document) => { + document.parameters.unreviewed = { value: "denied" }; + }, /Undeclared deployment parameters/u); + expectDenied((document) => { + document.parameters.foundrySubscriptionId = { value: false }; + }, /foundrySubscriptionId must match its schema type/u); +}); + +test("rejects issuer and tenant drift", () => { + expectDenied((document) => { + document.parameters.namedValues.value["accepted-tenant"] = + "11111111-1111-1111-1111-111111111111"; + }, /Accepted tenant and Entra issuer/u); + expectDenied((document) => { + document.parameters.namedValues.value["keycloak-openid-configuration"] = + "https://other.example.com/realms/local-studio/.well-known/openid-configuration"; + }, /Keycloak discovery/u); +}); + +test("rejects malformed allowlists and role sets", () => { + expectDenied((document) => { + document.parameters.namedValues.value["allowed-models"] = "gpt-4.1,"; + }, /allowed-models must contain unique/u); + expectDenied((document) => { + document.parameters.namedValues.value["agent-operation-roles"] = + "LocalStudio.Scientist,LocalStudio.Scientist"; + }, /agent-operation-roles must contain unique/u); + expectDenied((document) => { + document.parameters.namedValues.value["clearance-claim"] = "invalid claim"; + }, /clearance-claim is not a valid identifier/u); +}); + +test("rejects unbounded limits and backend substitution", () => { + expectDenied((document) => { + document.parameters.namedValues.value["request-max-bytes"] = "10485761"; + }, /request-max-bytes must be an integer/u); + expectDenied((document) => { + document.parameters.namedValues.value["foundry-backend-id"] = "untrusted-backend"; + }, /foundry-backend-id is generated by the deployment/u); + expectDenied((document) => { + document.parameters.namedValues.value["foundry-project-endpoint"] = + "https://other.services.ai.azure.com/api/projects/project"; + }, /foundry-project-endpoint must identify/u); + expectDenied((document) => { + document.parameters.namedValues.value["foundry-project-endpoint"] = + "https://replace-foundry-account.services.ai.azure.com/api/projects/%2e%2e"; + }, /foundry-project-endpoint must identify/u); + expectDenied((document) => { + document.parameters.namedValues.value["apim-api-audience"] = + "https://user@example.com/local-studio"; + }, /apim-api-audience must be an api or HTTPS URI/u); +}); + +test("rejects version-pinned and cross-vault secret references", () => { + expectDenied((document) => { + document.parameters.keyVaultNamedValues.value["client-secret"] = + "https://replace-key-vault.vault.azure.net/secrets/client-secret/version"; + }, /must use an unversioned Azure secret URL/u); + expectDenied((document) => { + document.parameters.keyVaultNamedValues.value["client-secret"] = + "https://other-vault.vault.azure.net/secrets/client-secret"; + }, /must use an unversioned Azure secret URL/u); + expectDenied((document) => { + document.parameters.keyVaultNamedValues.value["client-secret"] = + "https://replace-key-vault.vault.azure.net/secrets/client-secret"; + }, /Unused Key Vault named values/u); +}); + +test("uses gateway-generated correlation and a TLS-validated Foundry backend", () => { + assert.match(policy, /context\.RequestId\.ToString\(\)/u); + assert.doesNotMatch(policy, /Headers\.GetValueOrDefault\("x-correlation-id"/u); + assert.match(policy, /set-backend-service backend-id="\{\{foundry-backend-id\}\}"/u); + assert.match(bicep, /name: foundryBackendId/u); + assert.match(bicep, /var snapshotPrefix = '\$\{apiId\}-\$\{apiRevision\}-'/u); + assert.match(bicep, /value: apiPolicySnapshot/u); + assert.match(bicep, /validateCertificateChain: true/u); + assert.match(bicep, /validateCertificateName: true/u); +}); + +test("requires a digest-bound, unexpired rollback approval", () => { + const manifest = approvedRollbackManifest(); + const accepted = runRollbackValidation(manifest); + assert.equal(accepted.status, 0, `${accepted.stdout}${accepted.stderr}`); + manifest.policy_sha256 = "0".repeat(64); + const denied = runRollbackValidation(manifest); + assert.notEqual(denied.status, 0); + assert.match(`${denied.stdout}${denied.stderr}`, /policy digest does not match/u); + manifest.policy_sha256 = sha256(join(packageDirectory, "policy.xml")); + manifest.required_checks.pop(); + const incomplete = runRollbackValidation(manifest); + assert.notEqual(incomplete.status, 0); + assert.match(`${incomplete.stdout}${incomplete.stderr}`, /every required verification gate/u); + const mismatched = approvedRollbackManifest(); + mismatched.approved_revision = "1"; + const wrongTarget = runRollbackValidation(mismatched); + assert.notEqual(wrongTarget.status, 0); + assert.match(`${wrongTarget.stdout}${wrongTarget.stderr}`, /target does not match/u); +}); + +test("executes promotion and digest-bound rollback without contacting Azure", () => { + const revision = example.parameters.apiRevision.value; + const fake = createFakeAzure(revision); + const promotion = runAzureScript(fake, "promote-revision.sh", [ + "governed-rg", + "replace-apim-name", + "local-studio-ai", + revision, + ]); + assert.equal(promotion.status, 0, `${promotion.stdout}${promotion.stderr}`); + const manifestPath = join(fake.directory, "rollback.json"); + writeFileSync(manifestPath, JSON.stringify(approvedRollbackManifest())); + const rollback = runAzureScript(fake, "rollback-revision.sh", [ + "governed-rg", + "replace-apim-name", + manifestPath, + examplePath, + ]); + assert.equal(rollback.status, 0, `${rollback.stdout}${rollback.stderr}`); + const calls = readFileSync(fake.log, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + rmSync(fake.directory, { force: true, recursive: true }); + assert.equal( + calls.filter((args) => args.slice(0, 4).join(" ") === "apim api release create").length, + 2, + ); + assert.ok(calls.some((args) => args.includes("--query"))); + assert.match(rollback.stdout, /Validated APIM configuration/u); + assert.match(rollback.stdout, /Validated approved rollback manifest/u); +}); diff --git a/deploy/spire/README.md b/deploy/spire/README.md new file mode 100644 index 000000000..ac2b5a916 --- /dev/null +++ b/deploy/spire/README.md @@ -0,0 +1,40 @@ +# SPIRE workload identity + + + + CI, license + + +This package pins the hardened SPIRE charts and assigns separate identities to the frontend, controller, and agent runtime. The default catch-all identity is disabled. Workload registration is restricted by namespace and component labels. + +Chart pins: + +- `spire` chart `0.29.0`, SPIRE application `1.14.5` +- `spire-crds` chart `0.5.0` +- repository `https://spiffe.github.io/helm-charts-hardened/` + +Validate before deployment: + +```sh +deploy/spire/scripts/validate.sh +``` + +Apply the CRDs, SPIRE release, and Local Studio namespace resources only after review: + +```sh +helm upgrade --install --create-namespace --namespace spire-mgmt spire-crds spire-crds --repo https://spiffe.github.io/helm-charts-hardened/ --version 0.5.0 +helm upgrade --install --namespace spire-mgmt spire spire --repo https://spiffe.github.io/helm-charts-hardened/ --version 0.29.0 --values deploy/spire/values.yaml +kubectl apply --kustomize deploy/spire +``` + +Mount the CSI volume at `/run/spiffe/workload` in each admitted workload, set `LOCAL_STUDIO_SPIFFE_CONFIG` to a deployment-owned copy of `workload-identity.example.json`, and retain the exact ServiceAccount and component labels from this package. For a separate agent-runtime pod, set `LOCAL_STUDIO_AGENT_RUNTIME_HOST=0.0.0.0`; non-loopback binding fails unless SPIFFE mode is `required`. + +The baseline NetworkPolicy permits admitted Local Studio workloads and cluster DNS only. Add reviewed destination-specific egress policies for APIM, Foundry, Kubernetes, Vault, GitLab, Jira, and other commissioned services before starting those integrations. + +JWT-SVID validation and rotating X.509-SVID mTLS authenticate each service hop independently. The receiver admits exact peer identities and rejects a JWT subject that differs from the TLS peer. Live multi-replica SPIRE, CSI, NetworkPolicy, and revocation behavior remains a deployment acceptance gate. + +Rotation is handled by SPIRE. Revocation requires removing or disabling the matching ClusterSPIFFEID, terminating affected pods, and confirming new Workload API calls are denied. Rollback sets the Local Studio identity mode to `optional` only for bounded recovery; shared deployment authorization remains governed by OIDC. + +Federation is disabled. Delegated Identity API and Broker API authority are not configured. + +The TensorPrime Phase-0 connection profile and service-specific deployment boundary are documented in [TensorPrime Phase-0](./TENSORPRIME.md). Its service catalog deliberately declares plaintext transport, no server-side mTLS enforcement, and no Ray TLS. Local Studio's own internal service mTLS does not change those TensorPrime service facts. diff --git a/deploy/spire/TENSORPRIME.md b/deploy/spire/TENSORPRIME.md new file mode 100644 index 000000000..b8426d92c --- /dev/null +++ b/deploy/spire/TENSORPRIME.md @@ -0,0 +1,72 @@ +# TensorPrime Phase-0 workload identity + +`tensorprime-connection-profile.json` is the typed Local Studio service catalog for the TensorPrime environment. It is decoded with Effect Schema before use. Set `LOCAL_STUDIO_TENSORPRIME_PROFILE` to its deployment-owned path alongside `LOCAL_STUDIO_SPIFFE_CONFIG`. + +The connection profile binds: + +- trust domain `tprime.vlans.ca`; +- identity template `spiffe://tprime.vlans.ca/ns/{namespace}/sa/{serviceaccount}`; +- CSI driver `csi.spiffe.io`; +- mount `/run/spiffe/workload`; +- socket `/run/spiffe/workload/spire-agent.sock`; +- one-hour X.509-SVIDs watched through the Workload API; +- Ray, vLLM, LiteLLM, embedding HTTP/gRPC, ASR, and unified external endpoints. + +## Phase-0 security boundary + +SPIRE can issue and rotate an X.509-SVID after a workload mounts the socket. That is workload-identity readiness, not service authentication proof. + +TensorPrime Ray and inference services currently accept plaintext connections. They do not validate client SVIDs. Ray TLS is not configured. Evidence emitted from the typed profile therefore keeps `service_mtls_enforced=false` and `ray_tls_configured=false`, even when SVID acquisition and rotation are observed. + +Do not use an HTTPS or mTLS endpoint until the corresponding TensorPrime service is configured to present and validate SVIDs and a separate live acceptance proves the path. + +## Deployment + +### Local development + +To enable SPIFFE workload identity against a local SPIRE daemon, set both environment variables before starting the controller and agent runtime: + +```sh +export LOCAL_STUDIO_SPIFFE_CONFIG=deploy/spire/workload-identity.example.json +export LOCAL_STUDIO_TENSORPRIME_PROFILE=deploy/spire/tensorprime-connection-profile.json +``` + +The example workload-identity config and the TensorPrime connection profile share the same trust domain (`tprime.vlans.ca`) and Workload API socket (`unix:///run/spiffe/workload/spire-agent.sock`), so they bind without modification. A local SPIRE agent must be listening on that socket. + +Validate locally: + +```sh +deploy/spire/scripts/validate.sh +``` + +After review, install the SPIRE package as described in `README.md`, then apply the Local Studio workloads, generated profile ConfigMap, runtime bindings, and destination-specific egress policy: + +```sh +kubectl apply --kustomize deploy/spire +``` + +The Kustomize package creates the profile ConfigMap and sets: + +```sh +LOCAL_STUDIO_TENSORPRIME_PROFILE=/etc/local-studio/tensorprime/tensorprime-connection-profile.json +``` + +No secret, token, SVID, private key, or trust bundle belongs in the profile. + +## Readiness and rotation acceptance + +For each Local Studio workload: + +1. Confirm its ServiceAccount matches the profile identity. +2. Confirm `/run/spiffe/workload/spire-agent.sock` exists and is a Unix socket. +3. Fetch the X.509-SVID through the Workload API. +4. Confirm the URI SAN equals the configured SPIFFE ID. +5. Record expiry and rotation generation without persisting certificate or key material. +6. Keep TensorPrime service transport evidence at plaintext/not-configured. +7. Wait for a streamed SVID update and prove the generation increases before the prior certificate expires. + +The local validator and hermetic tests prove configuration and evidence semantics only. Live SPIRE issuance, CSI mounting, rotation, NetworkPolicy behavior, service reachability, revocation, and future server-side mTLS remain deployment acceptance gates. + +## Rollback + +Remove `LOCAL_STUDIO_TENSORPRIME_PROFILE` to disable the TensorPrime catalog binding. Remove the TensorPrime egress NetworkPolicy if the integration is offboarded. Do not change workload identity from required to optional except during a bounded, reviewed recovery. diff --git a/deploy/spire/kustomization.yaml b/deploy/spire/kustomization.yaml new file mode 100644 index 000000000..45890edfa --- /dev/null +++ b/deploy/spire/kustomization.yaml @@ -0,0 +1,61 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - workloads.yaml + - tensorprime-network-policy.yaml +configMapGenerator: + - name: tensorprime-connection-profile + namespace: local-studio + files: + - tensorprime-connection-profile.json +generatorOptions: + disableNameSuffixHash: true +patches: + - target: + group: apps + version: v1 + kind: Deployment + name: local-studio-controller + namespace: local-studio + patch: |- + - op: add + path: /spec/template/spec/containers/0/env/- + value: + name: LOCAL_STUDIO_TENSORPRIME_PROFILE + value: /etc/local-studio/tensorprime/tensorprime-connection-profile.json + - op: add + path: /spec/template/spec/containers/0/volumeMounts/- + value: + name: tensorprime-profile + mountPath: /etc/local-studio/tensorprime + readOnly: true + - op: add + path: /spec/template/spec/volumes/- + value: + name: tensorprime-profile + configMap: + name: tensorprime-connection-profile + - target: + group: apps + version: v1 + kind: Deployment + name: local-studio-agent-runtime + namespace: local-studio + patch: |- + - op: add + path: /spec/template/spec/containers/0/env/- + value: + name: LOCAL_STUDIO_TENSORPRIME_PROFILE + value: /etc/local-studio/tensorprime/tensorprime-connection-profile.json + - op: add + path: /spec/template/spec/containers/0/volumeMounts/- + value: + name: tensorprime-profile + mountPath: /etc/local-studio/tensorprime + readOnly: true + - op: add + path: /spec/template/spec/volumes/- + value: + name: tensorprime-profile + configMap: + name: tensorprime-connection-profile diff --git a/deploy/spire/scripts/validate.mjs b/deploy/spire/scripts/validate.mjs new file mode 100644 index 000000000..73b744ccb --- /dev/null +++ b/deploy/spire/scripts/validate.mjs @@ -0,0 +1,103 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const valuesText = readFileSync(resolve(root, "values.yaml"), "utf8"); +const config = JSON.parse(readFileSync(resolve(root, "workload-identity.example.json"), "utf8")); +const tensorPrime = JSON.parse( + readFileSync(resolve(root, "tensorprime-connection-profile.json"), "utf8"), +); +const expected = ["local-studio-frontend", "local-studio-controller", "local-studio-agent-runtime"]; +const errors = []; + +if (!valuesText.includes(`trustDomain: ${config.trust_domain}`)) { + errors.push("trust domain differs between Helm and runtime configuration"); +} +if (!/recommendations:\s*\n\s+enabled: true/u.test(valuesText)) { + errors.push("SPIRE recommendations are not enabled"); +} +if (!/default:\s*\n\s+enabled: false/u.test(valuesText)) { + errors.push("catch-all workload identity must be disabled"); +} +if (config.x509_mtls !== "required") { + errors.push("X.509-SVID mTLS is not required"); +} +if (config.endpoint !== "unix:///run/spiffe/workload/spire-agent.sock") { + errors.push("Workload API endpoint does not match the CSI socket"); +} +if ( + tensorPrime.trust_domain !== config.trust_domain || + tensorPrime.workload_api?.endpoint !== config.endpoint +) { + errors.push("TensorPrime profile differs from the SPIFFE workload configuration"); +} +if ( + tensorPrime.spiffe_id_template !== + `spiffe://${tensorPrime.trust_domain}/ns/{namespace}/sa/{serviceaccount}` +) { + errors.push("TensorPrime SPIFFE ID template is invalid"); +} +if ( + tensorPrime.capabilities?.service_mtls_enforcement !== "not-configured" || + tensorPrime.capabilities?.ray_tls !== "not-configured" +) { + errors.push("TensorPrime Phase-0 transport limitations are not preserved"); +} +const requiredServiceIds = new Set([ + "ray-client", + "ray-dashboard", + "ray-serve", + "vllm-gemma4", + "vllm-qwen3-next", + "litellm-gateway", + "embedding-http", + "embedding-grpc", + "whisper-asr", + "gemma4-external", + "qwen3-next-external", + "platform-api-external", + "llm-api-external", +]); +for (const service of tensorPrime.services ?? []) { + requiredServiceIds.delete(service.id); + if ( + service.transport_security !== "plaintext" || + service.server_mtls_enforced !== false || + !/^(?:grpc|http):\/\//u.test(service.url) + ) { + errors.push(`TensorPrime service ${service.id ?? "unknown"} overstates transport security`); + } +} +if (requiredServiceIds.size > 0) { + errors.push(`TensorPrime service catalog is incomplete: ${[...requiredServiceIds].join(", ")}`); +} +if ( + /"(?:api[_-]?key|password|private[_-]?key|token|secret)"\s*:/iu.test(JSON.stringify(tensorPrime)) +) { + errors.push("TensorPrime profile contains a secret-bearing field"); +} +for (const name of expected) { + const start = valuesText.indexOf(`${name}:`); + const end = valuesText.indexOf("\n local-studio-", start + name.length); + const identity = valuesText.slice(start, end < 0 ? undefined : end); + if (start < 0 || !identity.includes("enabled: true")) { + errors.push(`${name} identity is not enabled`); + } + if (!identity.includes("app.kubernetes.io/component")) { + errors.push(`${name} has no exact workload selector`); + } + if (!identity.includes("jwtTTL: 5m")) errors.push(`${name} JWT-SVID TTL is not five minutes`); +} +if (/\b(?:authorized_delegates|broker)\b/u.test(valuesText)) { + errors.push("delegated or broker identity authority is present"); +} +for (const key of ["frontend_id", "controller_id", "agent_runtime_id"]) { + if (!config[key]?.startsWith(`spiffe://${config.trust_domain}/`)) { + errors.push(`${key} is outside the configured trust domain`); + } +} +if (errors.length) { + for (const error of errors) process.stderr.write(`${error}\n`); + process.exit(1); +} +process.stdout.write("SPIRE deployment contract validated\n"); diff --git a/deploy/spire/scripts/validate.sh b/deploy/spire/scripts/validate.sh new file mode 100755 index 000000000..3372a8624 --- /dev/null +++ b/deploy/spire/scripts/validate.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +CHART_VERSION="0.29.0" +CRDS_VERSION="0.5.0" +REPOSITORY="https://spiffe.github.io/helm-charts-hardened/" + +RENDERED="$(mktemp)" +trap 'rm -f "$RENDERED"' EXIT + +node "$SCRIPT_DIR/validate.mjs" +helm template spire-crds spire-crds \ + --repo "$REPOSITORY" \ + --version "$CRDS_VERSION" \ + --namespace spire-mgmt >/dev/null +helm template spire spire \ + --repo "$REPOSITORY" \ + --version "$CHART_VERSION" \ + --namespace spire-mgmt \ + --values "$ROOT_DIR/values.yaml" >"$RENDERED" +test "$(grep -c '^kind: ClusterSPIFFEID$' "$RENDERED")" -eq 3 +grep -q 'name: spire-mgmt-spire-local-studio-frontend' "$RENDERED" +grep -q 'name: spire-mgmt-spire-local-studio-controller' "$RENDERED" +grep -q 'name: spire-mgmt-spire-local-studio-agent-runtime' "$RENDERED" +grep -q 'k8s:sa:local-studio-frontend' "$RENDERED" +grep -q 'k8s:sa:local-studio-controller' "$RENDERED" +grep -q 'k8s:sa:local-studio-agent-runtime' "$RENDERED" +if grep -Eq 'name: spire-mgmt-spire-(default|oidc|spike|test)' "$RENDERED"; then + exit 1 +fi +KUSTOMIZED="$(kubectl kustomize "$ROOT_DIR")" +test "$(grep -c '^kind: Deployment$' "$ROOT_DIR/workloads.yaml")" -eq 3 +test "$(grep -c '^kind: Service$' "$ROOT_DIR/workloads.yaml")" -eq 3 +test "$(grep -c 'driver: csi.spiffe.io' "$ROOT_DIR/workloads.yaml")" -eq 3 +test "$(grep -c 'mountPath: /run/spiffe/workload' "$ROOT_DIR/workloads.yaml")" -eq 3 +test "$(grep -c 'readOnlyRootFilesystem: true' "$ROOT_DIR/workloads.yaml")" -eq 3 +test "$(grep -c 'serviceAccountName: local-studio-' "$ROOT_DIR/workloads.yaml")" -eq 3 +test "$(grep -c 'fsGroup: 10001' "$ROOT_DIR/workloads.yaml")" -eq 3 +test "$(grep -c 'mountPath: /tmp' "$ROOT_DIR/workloads.yaml")" -eq 3 +test "$(grep -c 'tcpSocket:' "$ROOT_DIR/workloads.yaml")" -eq 6 +grep -q 'https://local-studio-controller:8080' "$ROOT_DIR/workloads.yaml" +grep -q 'https://local-studio-agent-runtime:8081' "$ROOT_DIR/workloads.yaml" +test "$(grep -c 'name: BACKEND_URL' "$ROOT_DIR/workloads.yaml")" -eq 2 +grep -q 'kubernetes.io/metadata.name: kuberay-system' "$ROOT_DIR/tensorprime-network-policy.yaml" +grep -q 'kubernetes.io/metadata.name: cortaix-llm-inference' "$ROOT_DIR/tensorprime-network-policy.yaml" +test "$(printf '%s\n' "$KUSTOMIZED" | grep -c 'name: LOCAL_STUDIO_TENSORPRIME_PROFILE')" -eq 2 +test "$(printf '%s\n' "$KUSTOMIZED" | grep -c 'name: tensorprime-profile')" -eq 4 +printf '%s\n' "$KUSTOMIZED" | grep -q 'name: tensorprime-connection-profile' +printf '%s\n' "SPIRE Helm and workload manifests validated" diff --git a/deploy/spire/tensorprime-connection-profile.json b/deploy/spire/tensorprime-connection-profile.json new file mode 100644 index 000000000..51299b237 --- /dev/null +++ b/deploy/spire/tensorprime-connection-profile.json @@ -0,0 +1,189 @@ +{ + "version": 1, + "id": "tensorprime-phase0", + "phase": "phase0", + "trust_domain": "tprime.vlans.ca", + "spiffe_id_template": "spiffe://tprime.vlans.ca/ns/{namespace}/sa/{serviceaccount}", + "workload_api": { + "csi_driver": "csi.spiffe.io", + "mount_path": "/run/spiffe/workload", + "socket_path": "/run/spiffe/workload/spire-agent.sock", + "endpoint": "unix:///run/spiffe/workload/spire-agent.sock" + }, + "x509_svid": { + "ttl_seconds": 3600, + "rotation": "workload-api-stream", + "persistence": "memory-only" + }, + "capabilities": { + "svid_issuance": "available", + "svid_rotation": "available", + "service_mtls_enforcement": "not-configured", + "ray_tls": "not-configured" + }, + "identities": [ + { + "component": "frontend", + "namespace": "local-studio", + "service_account": "local-studio-frontend", + "spiffe_id": "spiffe://tprime.vlans.ca/ns/local-studio/sa/local-studio-frontend" + }, + { + "component": "controller", + "namespace": "local-studio", + "service_account": "local-studio-controller", + "spiffe_id": "spiffe://tprime.vlans.ca/ns/local-studio/sa/local-studio-controller" + }, + { + "component": "agent-runtime", + "namespace": "local-studio", + "service_account": "local-studio-agent-runtime", + "spiffe_id": "spiffe://tprime.vlans.ca/ns/local-studio/sa/local-studio-agent-runtime" + } + ], + "services": [ + { + "id": "ray-client", + "kind": "ray-client", + "scope": "in-cluster", + "protocol": "grpc", + "url": "grpc://raycluster-kuberay-head-svc.kuberay-system:10001", + "host_header": null, + "openai_compatible": false, + "transport_security": "plaintext", + "server_mtls_enforced": false + }, + { + "id": "ray-dashboard", + "kind": "ray-dashboard", + "scope": "in-cluster", + "protocol": "http", + "url": "http://raycluster-kuberay-head-svc.kuberay-system:8265", + "host_header": null, + "openai_compatible": false, + "transport_security": "plaintext", + "server_mtls_enforced": false + }, + { + "id": "ray-serve", + "kind": "ray-serve", + "scope": "in-cluster", + "protocol": "http", + "url": "http://raycluster-kuberay-head-svc.kuberay-system:8000", + "host_header": null, + "openai_compatible": false, + "transport_security": "plaintext", + "server_mtls_enforced": false + }, + { + "id": "vllm-gemma4", + "kind": "vllm", + "scope": "in-cluster", + "protocol": "http", + "url": "http://gemma4.cortaix-llm-inference:80", + "host_header": null, + "openai_compatible": true, + "transport_security": "plaintext", + "server_mtls_enforced": false + }, + { + "id": "vllm-qwen3-next", + "kind": "vllm", + "scope": "in-cluster", + "protocol": "http", + "url": "http://qwen3-next.cortaix-llm-inference:80", + "host_header": null, + "openai_compatible": true, + "transport_security": "plaintext", + "server_mtls_enforced": false + }, + { + "id": "litellm-gateway", + "kind": "litellm", + "scope": "in-cluster", + "protocol": "http", + "url": "http://litellm-gateway.cortaix-llm-inference:4000", + "host_header": null, + "openai_compatible": true, + "transport_security": "plaintext", + "server_mtls_enforced": false + }, + { + "id": "embedding-http", + "kind": "embedding-http", + "scope": "in-cluster", + "protocol": "http", + "url": "http://bge-embed.cortaix-llm-inference:7997", + "host_header": null, + "openai_compatible": false, + "transport_security": "plaintext", + "server_mtls_enforced": false + }, + { + "id": "embedding-grpc", + "kind": "embedding-grpc", + "scope": "in-cluster", + "protocol": "grpc", + "url": "grpc://bge-embed-grpc.cortaix-llm-inference:8080", + "host_header": null, + "openai_compatible": false, + "transport_security": "plaintext", + "server_mtls_enforced": false + }, + { + "id": "whisper-asr", + "kind": "asr", + "scope": "in-cluster", + "protocol": "http", + "url": "http://whisper-asr.cortaix-llm-inference:8000", + "host_header": null, + "openai_compatible": true, + "transport_security": "plaintext", + "server_mtls_enforced": false + }, + { + "id": "gemma4-external", + "kind": "vllm", + "scope": "external", + "protocol": "http", + "url": "http://172.18.7.201:80", + "host_header": null, + "openai_compatible": true, + "transport_security": "plaintext", + "server_mtls_enforced": false + }, + { + "id": "qwen3-next-external", + "kind": "vllm", + "scope": "external", + "protocol": "http", + "url": "http://172.18.7.202:80", + "host_header": null, + "openai_compatible": true, + "transport_security": "plaintext", + "server_mtls_enforced": false + }, + { + "id": "platform-api-external", + "kind": "unified-api", + "scope": "external", + "protocol": "http", + "url": "http://172.18.7.204:80", + "host_header": "api.tprime.vlans.ca", + "openai_compatible": true, + "transport_security": "plaintext", + "server_mtls_enforced": false + }, + { + "id": "llm-api-external", + "kind": "unified-api", + "scope": "external", + "protocol": "http", + "url": "http://172.18.7.206:80", + "host_header": null, + "openai_compatible": true, + "transport_security": "plaintext", + "server_mtls_enforced": false + } + ] +} diff --git a/deploy/spire/tensorprime-network-policy.yaml b/deploy/spire/tensorprime-network-policy.yaml new file mode 100644 index 000000000..3e1fc77d2 --- /dev/null +++ b/deploy/spire/tensorprime-network-policy.yaml @@ -0,0 +1,42 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: local-studio-tensorprime-egress + namespace: local-studio +spec: + podSelector: + matchExpressions: + - key: app.kubernetes.io/component + operator: In + values: + - controller + - agent-runtime + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kuberay-system + ports: + - protocol: TCP + port: 10001 + - protocol: TCP + port: 8265 + - protocol: TCP + port: 8000 + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: cortaix-llm-inference + ports: + - protocol: TCP + port: 80 + - protocol: TCP + port: 4000 + - protocol: TCP + port: 7997 + - protocol: TCP + port: 8080 + - protocol: TCP + port: 8000 diff --git a/deploy/spire/values.yaml b/deploy/spire/values.yaml new file mode 100644 index 000000000..92c15daff --- /dev/null +++ b/deploy/spire/values.yaml @@ -0,0 +1,86 @@ +global: + openshift: false + spire: + clusterName: cortaix-factory + trustDomain: tprime.vlans.ca + caSubject: + country: CA + organization: Thales + commonName: tprime.vlans.ca + recommendations: + enabled: true + strictMode: true + namespaces: + create: true +spire-server: + controllerManager: + enabled: true + identities: + clusterSPIFFEIDs: + default: + enabled: false + oidc-discovery-provider: + enabled: false + test-keys: + enabled: false + spike-bootstrap: + enabled: false + spike-keeper: + enabled: false + spike-nexus: + enabled: false + spike-pilot: + enabled: false + local-studio-frontend: + enabled: true + spiffeIDTemplate: spiffe://{{ .TrustDomain }}/ns/{{ .PodMeta.Namespace }}/sa/local-studio-frontend + namespaceSelector: + matchLabels: + local-studio.io/workload-identity: enabled + podSelector: + matchLabels: + app.kubernetes.io/component: frontend + workloadSelectorTemplates: + - k8s:sa:local-studio-frontend + jwtTTL: 5m + ttl: 1h + local-studio-controller: + enabled: true + spiffeIDTemplate: spiffe://{{ .TrustDomain }}/ns/{{ .PodMeta.Namespace }}/sa/local-studio-controller + namespaceSelector: + matchLabels: + local-studio.io/workload-identity: enabled + podSelector: + matchLabels: + app.kubernetes.io/component: controller + workloadSelectorTemplates: + - k8s:sa:local-studio-controller + jwtTTL: 5m + ttl: 1h + local-studio-agent-runtime: + enabled: true + spiffeIDTemplate: spiffe://{{ .TrustDomain }}/ns/{{ .PodMeta.Namespace }}/sa/local-studio-agent-runtime + namespaceSelector: + matchLabels: + local-studio.io/workload-identity: enabled + podSelector: + matchLabels: + app.kubernetes.io/component: agent-runtime + workloadSelectorTemplates: + - k8s:sa:local-studio-agent-runtime + jwtTTL: 5m + ttl: 1h +spire-agent: + enabled: true +spiffe-csi-driver: + enabled: true +spiffe-oidc-discovery-provider: + enabled: false +tornjak-frontend: + enabled: false +spike-keeper: + enabled: false +spike-nexus: + enabled: false +spike-pilot: + enabled: false diff --git a/deploy/spire/workload-identity.example.json b/deploy/spire/workload-identity.example.json new file mode 100644 index 000000000..3c6e7487e --- /dev/null +++ b/deploy/spire/workload-identity.example.json @@ -0,0 +1,11 @@ +{ + "mode": "required", + "x509_mtls": "required", + "endpoint": "unix:///run/spiffe/workload/spire-agent.sock", + "trust_domain": "tprime.vlans.ca", + "frontend_id": "spiffe://tprime.vlans.ca/ns/local-studio/sa/local-studio-frontend", + "controller_id": "spiffe://tprime.vlans.ca/ns/local-studio/sa/local-studio-controller", + "agent_runtime_id": "spiffe://tprime.vlans.ca/ns/local-studio/sa/local-studio-agent-runtime", + "agent_runtime_audience": "local-studio-agent-runtime", + "controller_audience": "local-studio-controller" +} diff --git a/deploy/spire/workloads.yaml b/deploy/spire/workloads.yaml new file mode 100644 index 000000000..fe0199b30 --- /dev/null +++ b/deploy/spire/workloads.yaml @@ -0,0 +1,408 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: local-studio + labels: + local-studio.io/workload-identity: enabled +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: local-studio-frontend + namespace: local-studio +automountServiceAccountToken: false +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: local-studio-controller + namespace: local-studio +automountServiceAccountToken: false +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: local-studio-agent-runtime + namespace: local-studio +automountServiceAccountToken: false +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: local-studio-workload-identity + namespace: local-studio +data: + workload-identity.json: | + { + "mode": "required", + "x509_mtls": "required", + "endpoint": "unix:///run/spiffe/workload/spire-agent.sock", + "trust_domain": "tprime.vlans.ca", + "frontend_id": "spiffe://tprime.vlans.ca/ns/local-studio/sa/local-studio-frontend", + "controller_id": "spiffe://tprime.vlans.ca/ns/local-studio/sa/local-studio-controller", + "agent_runtime_id": "spiffe://tprime.vlans.ca/ns/local-studio/sa/local-studio-agent-runtime", + "agent_runtime_audience": "local-studio-agent-runtime", + "controller_audience": "local-studio-controller" + } +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: local-studio-controller + namespace: local-studio +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: local-studio + app.kubernetes.io/component: controller + template: + metadata: + labels: + app.kubernetes.io/name: local-studio + app.kubernetes.io/component: controller + spec: + serviceAccountName: local-studio-controller + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + fsGroup: 10001 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + containers: + - name: controller + image: local-studio/controller:2.1.0 + imagePullPolicy: IfNotPresent + env: + - name: LOCAL_STUDIO_HOST + value: 0.0.0.0 + - name: LOCAL_STUDIO_PORT + value: "8080" + - name: LOCAL_STUDIO_DATA_DIR + value: /var/lib/local-studio + - name: LOCAL_STUDIO_MODELS_DIR + value: /models + - name: LOCAL_STUDIO_AGENT_RUNTIME_URL + value: https://local-studio-agent-runtime:8081 + - name: LOCAL_STUDIO_SPIFFE_CONFIG + value: /etc/local-studio/workload-identity.json + - name: TMPDIR + value: /tmp + ports: + - name: https + containerPort: 8080 + readinessProbe: + tcpSocket: + port: https + livenessProbe: + tcpSocket: + port: https + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + readOnlyRootFilesystem: true + volumeMounts: + - name: workload-api + mountPath: /run/spiffe/workload + readOnly: true + - name: workload-config + mountPath: /etc/local-studio + readOnly: true + - name: data + mountPath: /var/lib/local-studio + - name: models + mountPath: /models + - name: tmp + mountPath: /tmp + volumes: + - name: workload-api + csi: + driver: csi.spiffe.io + readOnly: true + - name: workload-config + configMap: + name: local-studio-workload-identity + - name: data + emptyDir: {} + - name: models + emptyDir: {} + - name: tmp + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: local-studio-controller + namespace: local-studio +spec: + selector: + app.kubernetes.io/name: local-studio + app.kubernetes.io/component: controller + ports: + - name: https + port: 8080 + targetPort: https +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: local-studio-agent-runtime + namespace: local-studio +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: local-studio + app.kubernetes.io/component: agent-runtime + template: + metadata: + labels: + app.kubernetes.io/name: local-studio + app.kubernetes.io/component: agent-runtime + spec: + serviceAccountName: local-studio-agent-runtime + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + fsGroup: 10001 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + containers: + - name: agent-runtime + image: local-studio/agent-runtime:2.1.0 + imagePullPolicy: IfNotPresent + env: + - name: LOCAL_STUDIO_AGENT_RUNTIME_HOST + value: 0.0.0.0 + - name: PORT + value: "8081" + - name: LOCAL_STUDIO_DATA_DIR + value: /var/lib/local-studio + - name: LOCAL_STUDIO_CONTROLLER_URL + value: https://local-studio-controller:8080 + - name: BACKEND_URL + value: https://local-studio-controller:8080 + - name: LOCAL_STUDIO_SPIFFE_CONFIG + value: /etc/local-studio/workload-identity.json + - name: TMPDIR + value: /tmp + ports: + - name: https + containerPort: 8081 + readinessProbe: + tcpSocket: + port: https + livenessProbe: + tcpSocket: + port: https + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + readOnlyRootFilesystem: true + volumeMounts: + - name: workload-api + mountPath: /run/spiffe/workload + readOnly: true + - name: workload-config + mountPath: /etc/local-studio + readOnly: true + - name: data + mountPath: /var/lib/local-studio + - name: tmp + mountPath: /tmp + volumes: + - name: workload-api + csi: + driver: csi.spiffe.io + readOnly: true + - name: workload-config + configMap: + name: local-studio-workload-identity + - name: data + emptyDir: {} + - name: tmp + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: local-studio-agent-runtime + namespace: local-studio +spec: + selector: + app.kubernetes.io/name: local-studio + app.kubernetes.io/component: agent-runtime + ports: + - name: https + port: 8081 + targetPort: https +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: local-studio-frontend + namespace: local-studio +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: local-studio + app.kubernetes.io/component: frontend + template: + metadata: + labels: + app.kubernetes.io/name: local-studio + app.kubernetes.io/component: frontend + spec: + serviceAccountName: local-studio-frontend + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + fsGroup: 10001 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + containers: + - name: frontend + image: local-studio/frontend:2.1.0 + imagePullPolicy: IfNotPresent + env: + - name: PORT + value: "3000" + - name: LOCAL_STUDIO_DATA_DIR + value: /var/lib/local-studio + - name: LOCAL_STUDIO_AGENT_RUNTIME_URL + value: https://local-studio-agent-runtime:8081 + - name: BACKEND_URL + value: https://local-studio-controller:8080 + - name: LOCAL_STUDIO_SPIFFE_CONFIG + value: /etc/local-studio/workload-identity.json + - name: TMPDIR + value: /tmp + ports: + - name: http + containerPort: 3000 + readinessProbe: + tcpSocket: + port: http + livenessProbe: + tcpSocket: + port: http + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + readOnlyRootFilesystem: true + volumeMounts: + - name: workload-api + mountPath: /run/spiffe/workload + readOnly: true + - name: workload-config + mountPath: /etc/local-studio + readOnly: true + - name: data + mountPath: /var/lib/local-studio + - name: tmp + mountPath: /tmp + volumes: + - name: workload-api + csi: + driver: csi.spiffe.io + readOnly: true + - name: workload-config + configMap: + name: local-studio-workload-identity + - name: data + emptyDir: {} + - name: tmp + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: local-studio-frontend + namespace: local-studio +spec: + selector: + app.kubernetes.io/name: local-studio + app.kubernetes.io/component: frontend + ports: + - name: http + port: 3000 + targetPort: http +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: local-studio-default-deny + namespace: local-studio +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: local-studio-internal + namespace: local-studio +spec: + podSelector: + matchExpressions: + - key: app.kubernetes.io/component + operator: In + values: + - frontend + - controller + - agent-runtime + policyTypes: + - Ingress + - Egress + ingress: + - from: + - podSelector: + matchExpressions: + - key: app.kubernetes.io/component + operator: In + values: + - frontend + - controller + - agent-runtime + ports: + - protocol: TCP + port: 3000 + - protocol: TCP + port: 8080 + - protocol: TCP + port: 8081 + egress: + - to: + - podSelector: + matchExpressions: + - key: app.kubernetes.io/component + operator: In + values: + - frontend + - controller + - agent-runtime + ports: + - protocol: TCP + port: 3000 + - protocol: TCP + port: 8080 + - protocol: TCP + port: 8081 + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 diff --git a/docs/adr/ADR-001-scientific-workbench-kuberay-boundary.md b/docs/adr/ADR-001-scientific-workbench-kuberay-boundary.md new file mode 100644 index 000000000..65db88902 --- /dev/null +++ b/docs/adr/ADR-001-scientific-workbench-kuberay-boundary.md @@ -0,0 +1,47 @@ +# ADR-001: Scientific Workbench and KubeRay Boundary + +Date: 2026-07-27 +Status: Accepted for the first vertical slice +Classification: C2 + +## Context + +Scientists need an interactive notebook environment that can use governed datasets, approved AI models, and elastic Ray compute without requiring direct Kubernetes access. The appliance must preserve C2 handling controls and produce enough evidence to reconstruct how an experiment ran. + +Direct notebook access to the Kubernetes API would combine exploratory code execution with infrastructure authority. Accepting arbitrary RayJob or pod manifests would also bypass compute quotas, image policy, network policy, dataset leases, and approval checks. + +## Decision + +Local Studio is the workbench control plane. KubeRay is the distributed execution plane. + +The workbench owns notebook lifecycle, compute profiles, dataset attachments, model references, approvals, job submission, and experiment receipts. A scientist selects from governed inputs; the controller validates the canonical contract and generates the Kubernetes resources. + +Notebook pods execute user code but receive no general Kubernetes credentials. They submit work through the workbench API. The workbench creates RayJob resources through a dedicated service identity whose permissions are limited to the managed workbench namespaces and resource types. + +Each model is referenced by a qualified `provider_id/model_id`. TensorPrime is an OpenAI-compatible provider, and its endpoint configuration remains outside notebook content. Model routing must not silently fall back to a different provider or local model. + +Datasets are attached read-only with version, digest, purpose, classification, and lease expiry. Compute uses named profiles with bounded CPU, memory, GPU, worker count, runtime, idle timeout, network policy, and classification ceiling. + +Every accepted submission produces an experiment receipt containing the notebook and environment digests, dataset and model references, Ray job identity, policy decisions, approvals, artifact digests, timing, outcome, and resource usage. + +## Trust boundaries + +- Browser to workbench API: authenticated user identity, project authorization, and C2 session controls. +- Workbench API to Kubernetes: dedicated workload identity, namespace scope, admission policy, and auditable resource creation. +- Notebook to datasets: expiring read-only attachment, purpose binding, and digest verification. +- Notebook and Ray workers to models: approved egress path and qualified model identity. +- Runtime to artifact storage: project-scoped write identity and immutable receipt linkage. + +## Consequences + +- Scientists cannot submit arbitrary Kubernetes manifests through the workbench. +- Compute policy remains centrally enforceable and reusable across notebook and batch workloads. +- KubeRay reconciliation and scheduling stay outside the application domain. +- Interactive notebook startup depends on control-plane admission and cluster capacity. +- A complete experiment can be replayed only when referenced images, datasets, models, parameters, seeds, and artifacts remain available. + +## First vertical slice + +The first slice defines and tests the canonical C2 contracts for notebook sessions, compute profiles and leases, dataset attachments, qualified models, RayJob submissions, and experiment receipts. + +Runtime APIs, Kubernetes reconciliation, notebook UI, identity integration, storage, and cluster deployment are subsequent slices and are not claimed by this decision. diff --git a/docs/adr/ADR-002-spiffe-workload-identity-boundary.md b/docs/adr/ADR-002-spiffe-workload-identity-boundary.md new file mode 100644 index 000000000..781fc4d01 --- /dev/null +++ b/docs/adr/ADR-002-spiffe-workload-identity-boundary.md @@ -0,0 +1,25 @@ +# ADR-002: SPIFFE workload identity boundary + +## Status + +Accepted for the JWT-SVID and X.509-SVID service-authentication slice. Live cluster acceptance is pending. + +## Context + +OIDC identifies a human operator and carries authorization roles, entitlements, tenant, and C2 clearance. It does not establish the identity of the frontend, controller, or agent-runtime processes. Static service tokens also do not provide attestation or automatic rotation. + +## Decision + +Shared deployments require independently validated JWT-SVID and X.509-SVID identities on service-to-service HTTP requests. Each workload streams rotating X.509-SVID material from its node-local Workload API and obtains a short-lived audience-bound JWT-SVID for each outbound hop. The receiving service validates both identities independently, admits exact configured SPIFFE IDs, and requires the JWT subject to match the TLS peer. + +Human OIDC and workload SPIFFE validation are sequential gates. SPIFFE identity never creates a user principal, role, entitlement, tenant, or clearance. + +The implementation uses direct gRPC Workload API calls over a Unix socket and sends the mandatory `workload.spiffe.io: true` metadata. SVIDs are not persisted, returned to browser components, logged, or passed in process arguments. + +SPIRE registration selects exact namespace, ServiceAccount, and component labels. The catch-all chart identity is disabled. Delegated Identity API, Broker API, and federation are not enabled. + +## Consequences + +Required mode fails closed when the workload socket, issuance, validation, audience, trust domain, or admitted peer is invalid. Optional mode preserves local recovery without representing the connection as observed. + +JWT-SVID validation and X.509-SVID peer authorization produce separate per-hop evidence. Rotation replaces the complete in-memory certificate, key, and bundle snapshot and clears superseded private-key buffers. Hermetic protocol and TLS checks do not replace live SPIRE, CSI, NetworkPolicy, revocation, and multi-replica acceptance. diff --git a/docs/enterprise-access.md b/docs/enterprise-access.md new file mode 100644 index 000000000..5992b922b --- /dev/null +++ b/docs/enterprise-access.md @@ -0,0 +1,76 @@ +# Enterprise access + +Local Studio supports independent Microsoft Entra ID and Keycloak issuers. Shared web deployments use `required_oidc`; desktop loopback can retain `local`. + +## Configuration + +Set `LOCAL_STUDIO_ENTERPRISE_AUTH_CONFIG` to an absolute JSON file path available to both the frontend and controller. The file follows `EnterpriseAuthConfig` in `controller/contracts/enterprise-auth.ts`. Role and clearance mappings are deployment-owned. Client secrets are supplied only through `LOCAL_STUDIO_OIDC_SECRET_` and must come from the deployment secret store. + +Set `LOCAL_STUDIO_ENTERPRISE_SESSION_KEYS` from the deployment secret store on every frontend instance. Its value is an ordered JSON array such as `[{"id":"2026-07","key":""},{"id":"2026-04","key":""}]`. The first entry is the write key and remaining entries are read-only migration keys. `LOCAL_STUDIO_ENTERPRISE_SESSION_KEY` remains a single-key compatibility input when the keyring variable is absent. Do not configure both variables; ambiguous dual configuration fails closed. Key identifiers and key material must be unique. + +All instances must use the same ordered keyring. The default `LOCAL_STUDIO_ENTERPRISE_STATE_STORE=posix` adapter encrypts session, refresh-token, callback, logout, replay, index, and MSAL cache records in the process-locked, atomically replaced `enterprise-sessions.json`. It coordinates processes on one POSIX host and remains the local desktop compatibility mode. + +Shared multi-node deployments set `LOCAL_STUDIO_ENTERPRISE_STATE_STORE=redis`, `LOCAL_STUDIO_ENTERPRISE_REDIS_URL`, and an optional `LOCAL_STUDIO_ENTERPRISE_REDIS_NAMESPACE`. Remote Redis URLs must use `rediss://`; unencrypted `redis://` is accepted only for loopback fixtures. Redis stores the same AES-GCM envelopes, coordinates mutations through optimistic CAS, and uses renewable token-bound leases for refresh and MSAL cache fencing. A configured Redis outage fails closed and never falls back to POSIX. Use a dedicated ACL identity restricted to the configured namespace, require TLS, enable durable replication appropriate to the deployment, and keep Redis credentials in the deployment secret store. + +Entra app registrations use authorization code with PKCE and a confidential web redirect: + +`https:///api/auth/callback/` + +Expose the APIM API scope and assign app roles or groups that are explicitly mapped to Local Studio roles. The Foundry delegated scope is `https://ai.azure.com/.default`. + +Keycloak clients use standard authorization code with PKCE, the same redirect pattern, exact issuer and audience values, and explicit realm or client-role mappings. ID and Logout Token signatures default to the registration default `RS256`; set `id_token_signing_algorithm` to `PS256` or `ES256` only when the client registration explicitly selects it and discovery advertises it. Token and revocation client authentication is negotiated from discovery metadata. `client_secret_basic` is preferred, `client_secret_post` is supported, and issuers advertising no supported confidential-client method fail closed. Implicit and resource-owner password grants are not supported. + +For Keycloak back-channel logout, set `backchannel_logout` to `{"enabled":true,"session_required":true}` on the issuer and register: + +`https:///api/auth/backchannel-logout/` + +Keep Keycloak front-channel logout disabled for that client, configure the Backchannel Logout URL to the exact URI, and enable Backchannel logout session required. Local Studio accepts only signed form-posted Logout Tokens from a configured issuer, validates the logout event profile, stores encrypted `jti` replay evidence, and removes sessions through issuer-bound `sid` and `sub` indices. Microsoft Entra currently documents front-channel single sign-out rather than the signed OIDC back-channel protocol, so Entra issuer configuration rejects back-channel enablement instead of claiming unsupported registration. + +## APIM + +The executable standard-APIM package is under `deploy/azure/apim`. Its Bicep targets an existing APIM service and existing Foundry, Content Safety, Key Vault, and Application Insights resources. It imports the five-operation `/ai/v1` API as a non-current revision, materializes revision-scoped named values and backend entities, binds the policy to that immutable configuration snapshot, applies diagnostics, and binds APIM's system-assigned identity to Foundry User, Cognitive Services User, and conditional Key Vault Secrets User roles. Values containing credentials use unversioned Key Vault-backed secret named values and are not committed. + +Run `node deploy/azure/apim/scripts/validate.mjs` for local package validation. The schema-bound validator includes hermetic denial fixtures for issuer, tenant, claim, allowlist, quota, endpoint, secret-reference, and rollback-manifest drift. Azure provider validation, resource preflight, first-revision bootstrap, what-if, deployment, revision promotion, and digest-bound approved-revision rollback are documented in `deploy/azure/apim/README.md`. Do not run the deployment scripts without an authorized and validated Azure deployment plan. + +The policy validates Entra or Keycloak tokens before authorization, rate-limits by validated principal, replaces caller-provided correlation values, removes inbound bearer, proxy, API-key, subscription-key, function-key, and cookie credentials, obtains a Foundry backend token with APIM managed identity, and routes through a TLS-validating backend entity. Assign that identity only the Foundry project roles needed for model and agent invocation. + +Configure operations for: + +- `GET /ai/v1/models` +- `POST /ai/v1/chat/completions` +- `POST /ai/v1/responses` +- `GET /ai/v1/agents` +- `POST /ai/v1/agents/{agentId}/invoke` + +The included policy enforces deployment model and agent allowlists, C2 claim mappings, request body limits, content-safety controls, subject-and-tenant quotas, non-streaming token metrics, correlation IDs, and diagnostic redaction. Streaming remains enabled; its usage evidence depends on backend token headers because APIM token-metric emission is applied only to non-streaming operations. Do not promote a policy without negative tests for every denied model, agent, issuer, audience, role, clearance, and tenant. + +The preview AI Gateway tier is evaluation-only. Its gateway-wide runtime key model does not replace the per-user authorization boundary required here. + +## Rotation and incident response + +OIDC signing keys rotate through issuer discovery and JWKS. Invalid discovery, issuer, audience, signature, expiry, nonce, or role mapping fails closed. Authorization callback state is consumed only after issuer, expiry, and constant-time state validation, and logout independently requires the browser CSRF cookie/header proof before deleting a session. + +Rotate session encryption keys in three phases. First distribute `[old, new]` to every process so all readers know both keys while writes remain on the old primary. Then distribute `[new, old]`; either rollout cohort can read records written by the other while active records migrate to the new primary on access. Finally, remove the old key only after every process uses the new primary and the maximum session, callback, logout-ticket, and MSAL-cache retention window has elapsed, or after all sessions and caches have been deliberately invalidated. Removing a key earlier makes unmigrated records unreadable by design. + +Rotate confidential-client credentials in the secret store, restart frontend instances to rebuild MSAL token caches, and invalidate active sessions. During Redis maintenance, drain frontend traffic or preserve quorum; do not switch a live deployment between Redis and POSIX because the adapters do not migrate state automatically. A Redis outage in an OIDC deployment denies session access until the configured store recovers. + +For a compromised issuer or role mapping: + +1. Remove the issuer or mapping from the enterprise configuration. +2. Revoke affected identity sessions at the issuer. +3. Rotate the confidential-client credential. +4. Remove APIM access for the affected issuer or audience. +5. Review correlation IDs in APIM diagnostics and Local Studio audit events. +6. Restore access only after signed configuration review and denial-path testing. + +Authentication events are appended to `enterprise-audit.jsonl` with restrictive permissions. Controller authorization and invocation events are emitted as structured JSON for collection by the deployment log pipeline. Neither surface includes access, refresh, or identity tokens. + +The frontend removes browser cookies, authorization headers, and browser-supplied enterprise identity headers before agent-runtime forwarding. It adds the active session token only in the dedicated internal header. The agent runtime independently validates signature, issuer, audience, expiry, mapped roles, and the model or agent entitlement before dispatch. Its existing service credential remains a separate authorization layer. + +Rollback explicitly promotes an operator-selected approved APIM revision whose time-bounded manifest matches the policy and parameter SHA-256 digests. It never infers a revision from deployment history. A rollback must not re-enable browser-held API keys or forward user credentials to Foundry. + +The checked-in policy contract tests and XML validation do not compile or deploy the policy in Azure. Import validation, managed-identity RBAC, content-safety backend wiring, diagnostics delivery, streaming, revocation, and denied-operation behavior remain live acceptance gates. + +## Acceptance boundary + +`npm run check` and `npm run test:integration` establish local conformance only. Live acceptance separately proves both issuer flows, APIM validation and revocation, managed-identity access, model and project-agent invocation, streaming, correlation telemetry, and RBAC denial in the target Azure tenant. diff --git a/docs/scientific-workbench-status.md b/docs/scientific-workbench-status.md new file mode 100644 index 000000000..677af0faf --- /dev/null +++ b/docs/scientific-workbench-status.md @@ -0,0 +1,91 @@ +# Scientific Workbench Status + +Checkpoint: 2026-07-27 +Branch: `feat/scientific-workbench` +Classification: C2 + +## Completed slices + +- Governed notebook, compute, dataset, model, RayJob, and experiment-receipt contracts. +- RayJob admission and constrained `ray.io/v1` resource generation. +- cortAIx scientific console at `/science`. +- Effect-native KubeRay server-side apply and explicit status reconciliation. +- Terminal experiment-receipt finalization with measured resource usage, artifact digests, policy decisions, approvals, timing, and cluster identity. +- Governed Jupyter notebook inspection, revision-bound cell changes and bounded execution. +- Agent tools for notebook inspection, scientist-approved cell changes and scientist-approved execution. +- Expandable agent notebook orb on `/science` with live cell source, kernel output and interaction evidence. + +## Runtime configuration + +The controller enables the KubeRay gateway only when these values are present: + +```text +LOCAL_STUDIO_KUBERAY_API_URL +LOCAL_STUDIO_KUBERAY_TOKEN_FILE +LOCAL_STUDIO_KUBERAY_CA_FILE +``` + +The CA file is optional when the Kubernetes API certificate is already trusted. The token file must contain a non-empty workload identity token. + +Python notebook execution uses an unprivileged, network-disabled SmolVM guest. Build the pinned local image: + +```text +npm run build:notebook-python-image +``` + +The command writes the ignored artifact to `data/python-notebook-image.tar` and prints the complete digest-bound value to place in `.env.local`: + +```text +LOCAL_STUDIO_NOTEBOOK_PYTHON_IMAGE=/absolute/path/to/data/python-notebook-image.tar@sha256: +``` + +The related controller settings are: + +```text +LOCAL_STUDIO_NOTEBOOK_ROOT +LOCAL_STUDIO_NOTEBOOK_PYTHON +LOCAL_STUDIO_NOTEBOOK_SMOLVM +LOCAL_STUDIO_NOTEBOOK_NODE_IMAGE +LOCAL_STUDIO_NOTEBOOK_PYTHON_IMAGE +``` + +`LOCAL_STUDIO_NOTEBOOK_PYTHON_IMAGE` must reference a local `.tar` and include its SHA-256 digest. Python execution fails closed when the value is missing, remote, or mismatched. Existing inspection and Node.js notebook routing remain available independently. + +## Local acceptance + +Run the complete repository gate: + +```text +npm run check +``` + +Start the controller: + +```text +cd controller +LOCAL_STUDIO_DISABLE_METRICS=true bun src/main.ts +``` + +Start the cortAIx appliance: + +```text +LOCAL_STUDIO_APPLIANCE=cortaix-factory npm run dev +``` + +Acceptance targets: + +- Controller health: `http://127.0.0.1:8080/health` +- Scientific console: `http://127.0.0.1:3000/science` + +## Return path + +1. Configure the KubeRay API URL and workload identity files. +2. Submit one admitted RayJob through the workbench API. +3. Reconcile it through running to a terminal state. +4. Finalize and retrieve its experiment receipt. +5. Add automatic reconciliation with bounded retries and controller shutdown cancellation. +6. Surface live job state and receipts in the scientific console. +7. Bind notebook operations to authenticated scientist and project identities. +8. Persist notebook interaction events into experiment receipts. + +No live-cluster acceptance is recorded until steps 1–4 succeed against the intended KubeRay cluster. diff --git a/docs/setup-commissioning-acceptance.md b/docs/setup-commissioning-acceptance.md new file mode 100644 index 000000000..c001f7683 --- /dev/null +++ b/docs/setup-commissioning-acceptance.md @@ -0,0 +1,85 @@ +# Setup commissioning acceptance + +This document defines the evidence required to accept the workstation commissioning flow. It does not treat configuration, a rendered control, or a green unit test as proof of a live external connection. + +## Track-to-proof matrix + +| Track | Local acceptance | Live acceptance | Failure expectation | Standing | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Enterprise access | Session API decodes `local`, `optional_oidc`, and `required_oidc`; forged, expired, wrong-issuer, wrong-audience, and unmapped-role tokens fail closed; the browser receives only an opaque session cookie. | Complete Entra and Keycloak authorization-code flows with PKCE, validate roles and C2 clearance, sign out, revoke the session at the issuer, and observe denial on the next request. | Discovery, JWKS, callback, nonce, issuer, audience, expiry, role, clearance, and logout failures remain explicit and do not create an authenticated session. | `observed` only after token validation; issuer metadata alone is `observed` metadata, not an authenticated identity. | +| Credentials and agents | Onboarding schemas reject unknown services, invalid credential references, oversized credentials, invalid URLs, and invalid SSH targets; keyring reads return reference presence only; apply requires current probes and emits an unsigned digest-bound receipt. | Probe Vault, GitLab, Jira, the inference runtime, FastCRW, and an enabled SSH agent from the deployed runtime; apply, revoke, and recovery paths complete against the selected local agents. | Missing keyring, rejected credentials, stale probes, profile-digest drift, partial apply, and partial revoke remain visible and block replacement enrollment. | Saved configuration is `claimed`; a successful current probe and an unsigned enrollment receipt are `observed`; only a cryptographically verified receipt is `attested`. | +| Execution environment | Kubernetes configuration accepts HTTPS endpoints and loopback HTTP only, resolves bounded controller or projected-service-account credential references, persists no credential bytes or absolute paths, replaces the active gateway only after persistence, and validates Kubernetes and Ray discovery documents. Access-fabric tests cover plan, probe, apply, offboard, and recovery. | From the target deployment, probe the private Kubernetes API and `ray.io/v1`, submit a governed C2 RayJob, reconcile it to a terminal state, and verify NetBird or Boundary when private routing is required. | Missing files, unreadable credentials, escaping or symbolic-link references, permissive token modes, insecure URLs, request timeout, non-2xx responses, malformed documents, failed persistence, and failed access-fabric recovery do not produce an observed standing. | Saved metadata is `claimed`; both live discovery calls are required for `observed`; failures are `contradicted`. | +| Inference | All six persisted stages remain reachable: storage, runtime, model, acquisition, serving, and verification. Provider routing preserves qualified model identity and rejects unknown models without silent local fallback. | Invoke the configured remote or local model through the complete client path, including streaming where supported, and capture the selected provider/model identity, response status, and usage evidence. | Storage, runtime install, acquisition, launch, readiness, routing, model rejection, cancellation, and benchmark errors remain on the stage that owns remediation. | Selection or launch configuration is `claimed`; a successful end-to-end request is `observed`. | +| Review | The review derives each row from the same session, onboarding, environment, access-fabric, and inference state used by its track. No row becomes ready because a component rendered or an endpoint was merely configured. | Repeat the track probes in the deployment and compare timestamps, immutable identities, digests, and correlation identifiers with the review surface. | Missing, stale, contradicted, or unavailable evidence prevents a ready verdict and links back to the owning track. | Review preserves the source standing; it never promotes `claimed` to `observed` or `attested`. | + +## Local verification + +Run the repository gates from the repository root: + +```sh +npm run check +npm run test:integration +``` + +Run the focused environment boundary tests while developing that slice: + +```sh +bun test controller/tests/environment-routes.test.ts +bun test controller/tests/kuberay-gateway.test.ts +bun test frontend/src/features/setup/setup-view/setup-shell-design.test.ts +``` + +Start the full application through the repository workflow in a dedicated terminal: + +```sh +npm run dev +``` + +From a second terminal, verify the three expected listeners and the controller-facing setup contract: + +```sh +lsof -nP -iTCP:3000 -sTCP:LISTEN +lsof -nP -iTCP:8080 -sTCP:LISTEN +lsof -nP -iTCP:8081 -sTCP:LISTEN +curl --fail --silent http://127.0.0.1:8080/health +curl --fail --silent http://127.0.0.1:8080/environment/kubernetes +curl --fail --silent http://127.0.0.1:3000/api/auth/session +``` + +Browser acceptance must exercise all five tracks at desktop and narrow viewport widths. It must record JavaScript exceptions, hydration errors, console errors, failed same-origin API requests, keyboard reachability, focus visibility, and horizontal overflow. Repeat the setup surface in cortAIx light, cortAIx dark, high-contrast, and forced-colors modes. Static token presence is supporting evidence, not rendered acceptance. + +## Live acceptance boundary + +Local tests may use hermetic OIDC, Kubernetes, Ray, inference, and access-fabric fixtures. They prove contract behavior only. They do not prove: + +- Entra or Keycloak tenant configuration, Conditional Access, issuer revocation, or group and app-role assignment. +- APIM token validation, managed-identity substitution, Foundry model or agent invocation, quotas, diagnostics, or correlation. +- Kubernetes RBAC, admission policy, NetworkPolicy, GPU scheduling, RayJob execution, or namespace cleanup in the target cluster. +- Vault, GitLab, Jira, FastCRW, NetBird, Boundary, remote SSH, DNS, certificate, or private-routing reachability from the deployed runtime. +- Hardware qualification, model performance, or sustained streaming under the target workload. + +Record live acceptance separately with target identity, deployment revision, timestamps, immutable model and agent identifiers, policy decisions, correlation identifiers, and redacted command output. A local green gate must not be reported as live acceptance. + +## Failure and recovery checks + +Acceptance includes negative behavior: + +1. Stop each dependency independently and confirm that only its owning track degrades. +2. Present expired or incorrect credentials and confirm that no credential value appears in the response, UI, log, receipt, or error. +3. Use wrong issuer, audience, tenant, role, clearance, model, agent, Kubernetes document, and Ray API versions and confirm fail-closed behavior. +4. Interrupt configuration persistence and enrollment apply operations and confirm either rollback or an explicit recovery state. +5. Restart the frontend, controller, and agent runtime and confirm persisted non-secret configuration is reconstructed without reusing browser-held credentials. +6. Revoke an OIDC session, onboarding receipt, access-fabric enrollment, and Kubernetes connection and confirm downstream invocation is denied. + +## Secret-handling constraints + +- Do not enter, print, commit, screenshot, attach, or persist raw access tokens, refresh tokens, client secrets, API keys, service-account tokens, kubeconfigs, SSH private keys, or keyring values. +- OIDC secrets come from the deployment secret store. Browser storage contains no identity or Foundry token. +- Service credentials are written through the native keyring interface. UI and API responses expose only approved reference names and presence. +- Kubernetes configuration stores endpoint metadata and bounded references such as `controller:cluster.token` or `kubernetes:token`. Responses and persisted settings contain neither credential bytes nor absolute credential paths. The controller resolves and reads credential files at request time. +- APIM removes inbound credentials before backend forwarding and uses managed identity toward Foundry. +- Evidence and diagnostic artifacts must be reviewed for credential fragments before retention or sharing. + +## Completion rule + +Commissioning is accepted only when every required track has current evidence at its declared standing, every negative path fails as specified, the full repository gates pass, rendered browser acceptance passes in all required modes, and separately required live checks are attached. Unsupported or unavailable external checks remain open; they are not converted into local acceptance. diff --git a/frontend/.depcheckrc.json b/frontend/.depcheckrc.json index 4d817fd72..3e1e5b6ca 100644 --- a/frontend/.depcheckrc.json +++ b/frontend/.depcheckrc.json @@ -21,6 +21,7 @@ "knip", "lint-staged", "concurrently", + "cross-env", "electron-builder", "madge" ], diff --git a/frontend/README.md b/frontend/README.md index e3e281be5..1447b2099 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,5 +1,10 @@ # Frontend + + + CI, license, release + + `frontend/` is the Next.js 16 and React 19 interface for Local Studio and the source of the macOS Electron app. The web and desktop builds share the same routes, agent runtime integration, controller API bridge, and UI kit. diff --git a/frontend/desktop/app-identity.ts b/frontend/desktop/app-identity.ts index ff52ad3ab..57d967c4d 100644 --- a/frontend/desktop/app-identity.ts +++ b/frontend/desktop/app-identity.ts @@ -4,26 +4,42 @@ import { readFileSync } from "node:fs"; import { migrateLegacyUserData } from "./logic/user-data-migration"; import { mirrorStableUserData } from "./logic/dev-channel-mirror"; -const CANONICAL_APP_NAME = "Local Studio"; const LEGACY_BRANDED_APP_NAME = ["v", "LLM Studio"].join(""); const LEGACY_USER_DATA_NAMES = [LEGACY_BRANDED_APP_NAME, "frontend"]; const devAppName = process.env.LOCAL_STUDIO_DESKTOP_APP_NAME?.trim(); +const configuredDevAppName = process.env.LOCAL_STUDIO_DESKTOP_DEV_APP_NAME?.trim(); const devUserDataDir = process.env.LOCAL_STUDIO_DESKTOP_USER_DATA_DIR?.trim(); // A packaged build must know its own channel without depending on the // environment it happens to be launched from: electron-builder stamps // `localStudioChannel` into the bundled package.json via extraMetadata. -function packagedChannel(): string | undefined { - if (!app.isPackaged) return undefined; +function packagedIdentity(): { + channel?: string; + appName?: string; + devAppName?: string; +} { + if (!app.isPackaged) return {}; try { const metaPath = path.join(app.getAppPath(), "package.json"); - const meta = JSON.parse(readFileSync(metaPath, "utf8")) as { localStudioChannel?: unknown }; - return typeof meta.localStudioChannel === "string" ? meta.localStudioChannel : undefined; + const meta = JSON.parse(readFileSync(metaPath, "utf8")) as Record; + return { + channel: typeof meta.localStudioChannel === "string" ? meta.localStudioChannel : undefined, + appName: + typeof meta.localStudioBrandAppName === "string" ? meta.localStudioBrandAppName : undefined, + devAppName: + typeof meta.localStudioBrandDevAppName === "string" + ? meta.localStudioBrandDevAppName + : undefined, + }; } catch { - return undefined; + return {}; } } -const releaseChannel = (process.env.LOCAL_STUDIO_DESKTOP_CHANNEL ?? packagedChannel()) +const packaged = packagedIdentity(); +const canonicalAppName = + process.env.LOCAL_STUDIO_BRAND_APP_NAME?.trim() || packaged.appName || "Local Studio"; +const CANONICAL_APP_NAME = canonicalAppName; +const releaseChannel = (process.env.LOCAL_STUDIO_DESKTOP_CHANNEL ?? packaged.channel) ?.trim() .toLowerCase(); @@ -31,7 +47,12 @@ const releaseChannel = (process.env.LOCAL_STUDIO_DESKTOP_CHANNEL ?? packagedChan // (built from main). The dev channel is packaged under its own app id, product // name and user-data dir, so the two installs never collide and Launch Services // can never resolve one when you asked for the other. -const DEV_APP_NAME = `${CANONICAL_APP_NAME} Dev`; +const DEV_APP_NAME = + configuredDevAppName && configuredDevAppName.length > 0 + ? configuredDevAppName + : packaged.devAppName + ? packaged.devAppName + : `${CANONICAL_APP_NAME} Dev`; const isDevChannel = releaseChannel === "dev"; const nonStablePackagedChannel = app.isPackaged && releaseChannel !== undefined && releaseChannel !== "" && !isDevChannel; diff --git a/frontend/desktop/configs.ts b/frontend/desktop/configs.ts index ea69e32a9..2767dde22 100644 --- a/frontend/desktop/configs.ts +++ b/frontend/desktop/configs.ts @@ -2,9 +2,16 @@ import { app } from "electron"; import path from "node:path"; const DEFAULT_DEV_SERVER_URL = "http://127.0.0.1:3000"; +const DEFAULT_APP_NAME = "Local Studio"; +const configuredAppName = process.env.LOCAL_STUDIO_BRAND_APP_NAME?.trim(); export const DESKTOP_CONFIG = { - appName: "Local Studio", + appName: + configuredAppName && configuredAppName.length > 0 + ? configuredAppName + : app.isPackaged + ? app.getName() + : DEFAULT_APP_NAME, minimumWindow: { width: 1200, height: 760 }, preferredWindow: { width: 1520, height: 980 }, startupTimeoutMs: 45_000, diff --git a/frontend/desktop/electron-builder.yml b/frontend/desktop/electron-builder.yml index 21f67f11f..cfdb01297 100644 --- a/frontend/desktop/electron-builder.yml +++ b/frontend/desktop/electron-builder.yml @@ -1,5 +1,5 @@ -appId: org.local.studio.desktop -productName: Local Studio +appId: ${env.LOCAL_STUDIO_BRAND_APP_ID} +productName: ${env.LOCAL_STUDIO_BRAND_APP_NAME} asar: true afterPack: ./scripts/electron-builder-after-pack.mjs @@ -110,9 +110,11 @@ mac: - target: dmg arch: - arm64 + - x64 - target: zip arch: - arm64 + - x64 hardenedRuntime: true gatekeeperAssess: false entitlements: desktop/resources/entitlements.mac.plist @@ -131,4 +133,7 @@ win: linux: target: - - AppImage + - target: AppImage + arch: + - x64 + - arm64 diff --git a/frontend/desktop/logic/agent-runtime-lifecycle.test.ts b/frontend/desktop/logic/agent-runtime-lifecycle.test.ts new file mode 100644 index 000000000..5ee50aa18 --- /dev/null +++ b/frontend/desktop/logic/agent-runtime-lifecycle.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { describe, it } from "node:test"; + +const runtime = readFileSync(new URL("./agent-runtime-server.ts", import.meta.url), "utf8"); +const appServer = readFileSync(new URL("./app-server.ts", import.meta.url), "utf8"); + +describe("desktop lifecycle credential bootstrap", () => { + it("creates independent high-entropy runtime credentials and instance identity", () => { + assert.match(runtime, /randomBytes\(32\)\.toString\("base64url"\)/); + assert.match(runtime, /LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN: lifecycleToken/); + assert.match(runtime, /LOCAL_STUDIO_PROVISIONING_TOKEN: lifecycleToken/); + assert.match(runtime, /options\.lifecycleToken === undefined/); + assert.match(appServer, /lifecycleToken: process\.env\.LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN/); + assert.match(runtime, /LOCAL_STUDIO_AGENT_RUNTIME_INSTANCE_ID: instanceId/); + assert.match(runtime, /payload\.instanceId === instanceId/); + assert.doesNotMatch(runtime, /Using agent runtime at/); + }); + + it("passes lifecycle authority only to the embedded server environment", () => { + assert.match(appServer, /LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN: agentRuntime\.lifecycleToken/); + assert.match(appServer, /LOCAL_STUDIO_PROVISIONING_TOKEN: agentRuntime\.lifecycleToken/); + assert.doesNotMatch(appServer, /log\.[a-z]+\([^)]*lifecycleToken/); + assert.doesNotMatch(runtime, /log\.[a-z]+\([^)]*lifecycleToken/); + }); +}); diff --git a/frontend/desktop/logic/agent-runtime-server.ts b/frontend/desktop/logic/agent-runtime-server.ts index fedab1bf2..d607c779a 100644 --- a/frontend/desktop/logic/agent-runtime-server.ts +++ b/frontend/desktop/logic/agent-runtime-server.ts @@ -11,11 +11,15 @@ import { resolveAugmentedPath } from "../helpers/resolve-path"; export type AgentRuntimeHandle = { process?: ChildProcess; url: string; + lifecycleToken: string; + instanceId: string; }; type StartAgentRuntimeOptions = { frontendUrl: string; preferredPort?: number; + lifecycleToken?: string; + onSpawn?: (child: ChildProcess) => void; }; let currentAgentRuntime: ChildProcess | null = null; @@ -42,12 +46,12 @@ function agentRuntimeEntry(): string { ); } -async function isAgentRuntimeHealthy(url: string): Promise { +async function isAgentRuntimeHealthy(url: string, instanceId: string): Promise { try { const response = await fetch(`${url}/health`, { signal: AbortSignal.timeout(1_000) }); if (!response.ok) return false; - const payload = (await response.json()) as { service?: unknown }; - return payload.service === "local-studio-agent-runtime"; + const payload = (await response.json()) as { service?: unknown; instanceId?: unknown }; + return payload.service === "local-studio-agent-runtime" && payload.instanceId === instanceId; } catch { return false; } @@ -56,6 +60,7 @@ async function isAgentRuntimeHealthy(url: string): Promise { async function waitForAgentRuntime( child: ChildProcess, url: string, + instanceId: string, timeoutMs: number, ): Promise { const startedAt = Date.now(); @@ -63,7 +68,7 @@ async function waitForAgentRuntime( if (child.exitCode !== null) { throw new Error(`Agent runtime exited with code ${child.exitCode}`); } - if (await isAgentRuntimeHealthy(url)) return; + if (await isAgentRuntimeHealthy(url, instanceId)) return; await new Promise((resolve) => setTimeout(resolve, 200)); } throw new Error(`Timed out waiting for agent runtime: ${url}`); @@ -92,12 +97,6 @@ async function stopChild(child: ChildProcess): Promise { export async function startAgentRuntime( options: StartAgentRuntimeOptions, ): Promise { - const preferredUrl = options.preferredPort ? `http://127.0.0.1:${options.preferredPort}` : null; - if (preferredUrl && (await isAgentRuntimeHealthy(preferredUrl))) { - log.info(`Using agent runtime at ${preferredUrl}`); - return { url: preferredUrl }; - } - const entry = agentRuntimeEntry(); if (!existsSync(entry)) { throw new Error(`Missing agent runtime bundle: ${entry}`); @@ -106,6 +105,11 @@ export async function startAgentRuntime( const port = await resolveStablePort(options.preferredPort); const url = `http://127.0.0.1:${port}`; const litterBridgeSecret = randomBytes(32).toString("base64url"); + const lifecycleToken = + options.lifecycleToken === undefined + ? randomBytes(32).toString("base64url") + : options.lifecycleToken.trim(); + const instanceId = randomBytes(16).toString("hex"); const child = fork(entry, { stdio: "pipe", detached: false, @@ -119,9 +123,14 @@ export async function startAgentRuntime( LOCAL_STUDIO_RESOURCES_PATH: process.resourcesPath, LOCAL_STUDIO_AGENT_CWD: process.env.LOCAL_STUDIO_AGENT_CWD || app.getPath("home"), LOCAL_STUDIO_FRONTEND_BASE: options.frontendUrl, + LOCAL_STUDIO_DESKTOP: "1", LOCAL_STUDIO_LITTER_BRIDGE_SECRET: litterBridgeSecret, + ...(lifecycleToken ? { LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN: lifecycleToken } : {}), + ...(lifecycleToken ? { LOCAL_STUDIO_PROVISIONING_TOKEN: lifecycleToken } : {}), + LOCAL_STUDIO_AGENT_RUNTIME_INSTANCE_ID: instanceId, }, }); + options.onSpawn?.(child); child.stdout?.on("data", (chunk: Buffer | string) => { log.info(`agent-runtime: ${String(chunk).trim()}`); @@ -135,8 +144,8 @@ export async function startAgentRuntime( currentAgentRuntime = child; try { - await waitForAgentRuntime(child, url, DESKTOP_CONFIG.startupTimeoutMs); - return { process: child, url }; + await waitForAgentRuntime(child, url, instanceId, DESKTOP_CONFIG.startupTimeoutMs); + return { process: child, url, lifecycleToken, instanceId }; } catch (error) { await stopChild(child); throw error; diff --git a/frontend/desktop/logic/app-server.ts b/frontend/desktop/logic/app-server.ts index a914a2af8..7f84856b0 100644 --- a/frontend/desktop/logic/app-server.ts +++ b/frontend/desktop/logic/app-server.ts @@ -163,7 +163,12 @@ export async function startFrontendServer( port: Number(new URL(DESKTOP_CONFIG.devServerUrl).port || "3000"), url: DESKTOP_CONFIG.devServerUrl, }; - const agentRuntime = await startAgentRuntime({ frontendUrl: runtime.url, preferredPort: 8081 }); + const agentRuntime = await startAgentRuntime({ + frontendUrl: runtime.url, + preferredPort: 8081, + lifecycleToken: process.env.LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN || "", + onSpawn: (child) => registerOAuthVault(child, DESKTOP_CONFIG.userDataDir), + }); return { agentRuntime, runtime }; } @@ -195,7 +200,10 @@ export async function startFrontendServer( const port = await resolveStablePort(options.port ?? readPersistedPort()); persistPort(port); const url = `http://127.0.0.1:${port}`; - const agentRuntime = await startAgentRuntime({ frontendUrl: url }); + const agentRuntime = await startAgentRuntime({ + frontendUrl: url, + onSpawn: (child) => registerOAuthVault(child, DESKTOP_CONFIG.userDataDir), + }); log.info(`Starting embedded frontend server from ${serverScript} on ${url}`); @@ -225,6 +233,9 @@ export async function startFrontendServer( LOCAL_STUDIO_RESOURCES_PATH: process.resourcesPath, LOCAL_STUDIO_AGENT_CWD: process.env.LOCAL_STUDIO_AGENT_CWD || app.getPath("home"), LOCAL_STUDIO_AGENT_RUNTIME_URL: agentRuntime.url, + LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN: agentRuntime.lifecycleToken, + LOCAL_STUDIO_PROVISIONING_TOKEN: agentRuntime.lifecycleToken, + LOCAL_STUDIO_AGENT_RUNTIME_INSTANCE_ID: agentRuntime.instanceId, LOCAL_STUDIO_FRONTEND_BASE: url, }, }); diff --git a/frontend/desktop/logic/oauth-vault-registration.test.ts b/frontend/desktop/logic/oauth-vault-registration.test.ts new file mode 100644 index 000000000..12543805f --- /dev/null +++ b/frontend/desktop/logic/oauth-vault-registration.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { describe, test } from "node:test"; + +const source = (relative: string) => readFileSync(new URL(relative, import.meta.url), "utf8"); + +const appServer = source("./app-server.ts"); +const agentRuntimeServer = source("./agent-runtime-server.ts"); +const vault = source("./oauth-vault.ts"); + +describe("desktop onboarding vault wiring", () => { + test("marks the runtime as a desktop secure-storage client", () => { + assert.match(agentRuntimeServer, /LOCAL_STUDIO_DESKTOP: "1"/); + }); + + test("registers the vault before runtime readiness in dev and packaged startup", () => { + const registrations = appServer.match( + /onSpawn: \(child\) => registerOAuthVault\(child, DESKTOP_CONFIG\.userDataDir\)/g, + ); + assert.equal(registrations?.length, 2); + assert.match(agentRuntimeServer, /options\.onSpawn\?\.\(child\)/); + assert.match( + agentRuntimeServer, + /options\.onSpawn\?\.\(child\)[\s\S]*await waitForAgentRuntime\(child/, + ); + }); + + test("uses rotating asynchronous native storage and rejects Linux plaintext fallback", () => { + assert.match(vault, /safeStorage\.isAsyncEncryptionAvailable\(\)/); + assert.match(vault, /safeStorage\.encryptStringAsync/); + assert.match(vault, /safeStorage\.decryptStringAsync/); + assert.match(vault, /decrypted\.shouldReEncrypt/); + assert.match(vault, /getSelectedStorageBackend\(\) === "basic_text"/); + }); +}); diff --git a/frontend/desktop/logic/oauth-vault.ts b/frontend/desktop/logic/oauth-vault.ts index 3c0b2656f..4c9469f8f 100644 --- a/frontend/desktop/logic/oauth-vault.ts +++ b/frontend/desktop/logic/oauth-vault.ts @@ -1,6 +1,6 @@ import { safeStorage } from "electron"; import { randomUUID } from "node:crypto"; -import { chmod, readFile, rename, writeFile } from "node:fs/promises"; +import { chmod, lstat, open, readFile, rename, unlink, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import path from "node:path"; import type { ChildProcess } from "node:child_process"; @@ -37,6 +37,10 @@ function isVaultRequest(value: unknown): value is VaultRequest { async function readVault(file: string): Promise> { if (!existsSync(file)) return {}; + const metadata = await lstat(file); + if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1) { + throw new Error("OAuth vault file is unsafe"); + } const parsed: unknown = JSON.parse(await readFile(file, "utf8")); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("OAuth vault is invalid"); @@ -49,28 +53,71 @@ async function readVault(file: string): Promise> { ); } +async function syncDirectory(value: string): Promise { + try { + const handle = await open(value, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + } catch (source) { + const code = (source as NodeJS.ErrnoException).code; + if ( + process.platform === "win32" && + ["EINVAL", "EISDIR", "ENOTSUP", "EPERM"].includes(code ?? "") + ) { + return; + } + throw source; + } +} + async function writeVault(file: string, vault: Record): Promise { const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`; - await writeFile(temporary, JSON.stringify(vault, null, 2), { mode: 0o600 }); - await chmod(temporary, 0o600); - await rename(temporary, file); - await chmod(file, 0o600); + try { + await writeFile(temporary, JSON.stringify(vault, null, 2), { mode: 0o600 }); + await chmod(temporary, 0o600); + const handle = await open(temporary, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + await rename(temporary, file); + await chmod(file, 0o600); + await syncDirectory(path.dirname(file)); + } catch (source) { + await unlink(temporary).catch(() => undefined); + throw source; + } } function vaultOperation(file: string, request: VaultRequest): Promise { const operation = vaultAccess.then(async () => { - if (!safeStorage.isEncryptionAvailable()) throw new Error("Secure storage is unavailable"); + if (!(await safeStorage.isAsyncEncryptionAvailable())) { + throw new Error("Secure storage is unavailable"); + } + if (process.platform === "linux" && safeStorage.getSelectedStorageBackend() === "basic_text") { + throw new Error("Native Linux secret storage is unavailable"); + } const vault = await readVault(file); if (request.operation === "read") { const encrypted = vault[request.key]; if (!encrypted) return undefined; - const decrypted = safeStorage.decryptString(Buffer.from(encrypted, "base64")); - if (decrypted.length > 1_000_000) throw new Error("OAuth vault value is too large"); - return decrypted; + const decrypted = await safeStorage.decryptStringAsync(Buffer.from(encrypted, "base64")); + if (decrypted.result.length > 1_000_000) throw new Error("OAuth vault value is too large"); + if (decrypted.shouldReEncrypt) { + vault[request.key] = (await safeStorage.encryptStringAsync(decrypted.result)).toString( + "base64", + ); + await writeVault(file, vault); + } + return decrypted.result; } if (request.operation === "write") { if (request.value === undefined) throw new Error("Vault value is required"); - vault[request.key] = safeStorage.encryptString(request.value).toString("base64"); + vault[request.key] = (await safeStorage.encryptStringAsync(request.value)).toString("base64"); } else { delete vault[request.key]; } diff --git a/frontend/desktop/main.ts b/frontend/desktop/main.ts index fd04902bc..617e074a4 100644 --- a/frontend/desktop/main.ts +++ b/frontend/desktop/main.ts @@ -586,7 +586,7 @@ async function run(): Promise { // (port in use, unwritable userData, missing server.js, slow-start timeout). try { dialog.showErrorBox( - "Local Studio failed to start", + `${app.getName()} failed to start`, `${error instanceof Error ? error.message : String(error)}\n\nSee the app logs for details.`, ); } catch { diff --git a/frontend/desktop/resources/appliances/cortaix-factory/icon.icns b/frontend/desktop/resources/appliances/cortaix-factory/icon.icns new file mode 100644 index 000000000..18708f690 Binary files /dev/null and b/frontend/desktop/resources/appliances/cortaix-factory/icon.icns differ diff --git a/frontend/desktop/resources/pi-extensions/connectors.ts b/frontend/desktop/resources/pi-extensions/connectors.ts index fe0d16c73..06a5e17ae 100644 --- a/frontend/desktop/resources/pi-extensions/connectors.ts +++ b/frontend/desktop/resources/pi-extensions/connectors.ts @@ -1,4 +1,4 @@ -// Connector bridge extension for Local Studio. +// Connector bridge extension for the desktop app. // // At session start it asks the frontend for the tool inventory of every // enabled connector (MCP servers configured in Settings → Connectors) and @@ -39,7 +39,11 @@ const textResult = (text: string, details: Record): ToolResult /** Render an MCP tools/call result (content blocks) as plain text. */ const renderMcpResult = (result: unknown): string => { - if (result && typeof result === "object" && Array.isArray((result as { content?: unknown[] }).content)) { + if ( + result && + typeof result === "object" && + Array.isArray((result as { content?: unknown[] }).content) + ) { const blocks = (result as { content: Array<{ type?: string; text?: string }> }).content; const texts = blocks .map((block) => (block.type === "text" && block.text ? block.text : JSON.stringify(block))) diff --git a/frontend/desktop/resources/pi-extensions/fastcrw-search.test.ts b/frontend/desktop/resources/pi-extensions/fastcrw-search.test.ts new file mode 100644 index 000000000..8e53aeab3 --- /dev/null +++ b/frontend/desktop/resources/pi-extensions/fastcrw-search.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import registerFastCrwSearch from "./fastcrw-search"; + +type RegisteredTool = { + execute: ( + id: string, + params: { + query: string; + limit?: number; + lang?: string; + recency?: string; + categories?: string[]; + }, + signal?: AbortSignal, + ) => Promise<{ + content: Array<{ type: string; text: string }>; + details: Record; + }>; +}; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function registeredTool(): RegisteredTool { + let tool: RegisteredTool | undefined; + registerFastCrwSearch({ + registerTool: (candidate: RegisteredTool) => { + tool = candidate; + }, + } as unknown as ExtensionAPI); + if (!tool) throw new Error("FastCRW tool was not registered"); + return tool; +} + +describe("FastCRW native search extension", () => { + test("forwards recency using the shared onboarding request field", async () => { + let requestBody: unknown; + let requestUrl = ""; + let requestInit: RequestInit | undefined; + globalThis.fetch = ((input, init) => { + requestUrl = String(input); + requestInit = init; + requestBody = JSON.parse(String(init?.body)); + return Promise.resolve( + Response.json({ + success: true, + data: [{ title: "Source", url: "https://example.test", snippet: "Evidence" }], + }), + ); + }) as typeof fetch; + + const result = await registeredTool().execute("call-1", { + query: "current platform status", + recency: "qdr:d", + limit: 3, + lang: "en", + categories: ["science", "security"], + }); + + expect(requestUrl).toBe("http://127.0.0.1:3000/api/agent/onboarding/search"); + expect(requestInit?.method).toBe("POST"); + expect(new Headers(requestInit?.headers).get("content-type")).toBe("application/json"); + expect(requestBody).toEqual({ + query: "current platform status", + limit: 3, + recency: "qdr:d", + lang: "en", + categories: ["science", "security"], + }); + expect(result.details["count"]).toBe(1); + }); + + test("returns a bounded failure object when the proxy rejects the request", async () => { + globalThis.fetch = (() => + Promise.resolve(Response.json({ error: "Unauthorized" }, { status: 401 }))) as typeof fetch; + + const result = await registeredTool().execute("call-2", { query: "source" }); + + expect(result.details).toEqual({ failed: true, status: 401 }); + expect(result.content[0]?.text).toBe("crw_search failed: HTTP 401"); + }); +}); diff --git a/frontend/desktop/resources/pi-extensions/fastcrw-search.ts b/frontend/desktop/resources/pi-extensions/fastcrw-search.ts new file mode 100644 index 000000000..82e8e9625 --- /dev/null +++ b/frontend/desktop/resources/pi-extensions/fastcrw-search.ts @@ -0,0 +1,112 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; + +type SearchRow = { + title?: unknown; + url?: unknown; + snippet?: unknown; + description?: unknown; + position?: unknown; + score?: unknown; +}; + +type SearchResponse = { + success?: unknown; + data?: unknown; +}; + +const frontendBase = ( + process.env.LOCAL_STUDIO_FRONTEND_BASE?.trim() || "http://127.0.0.1:3000" +).replace(/\/+$/, ""); +const timeoutMs = 20_000; + +const output = (text: string, details: Record) => ({ + content: [{ type: "text" as const, text }], + details, +}); + +const rowsFrom = (payload: SearchResponse): SearchRow[] => { + if (!Array.isArray(payload.data)) return []; + return payload.data.filter( + (row): row is SearchRow => Boolean(row) && typeof row === "object" && !Array.isArray(row), + ); +}; + +export default function registerFastCrwSearch(pi: ExtensionAPI): void { + pi.registerTool({ + name: "crw_search", + label: "FastCRW: Search", + description: + "Search the web through the configured FastCRW service and return source titles, URLs, snippets, positions, and scores.", + promptSnippet: "Search the web with FastCRW when current external sources are required", + parameters: Type.Object({ + query: Type.String({ minLength: 1, maxLength: 2000 }), + limit: Type.Optional(Type.Number({ minimum: 1, maximum: 20 })), + lang: Type.Optional(Type.String({ minLength: 2, maxLength: 16 })), + recency: Type.Optional( + Type.Union([ + Type.Literal("qdr:h"), + Type.Literal("qdr:d"), + Type.Literal("qdr:w"), + Type.Literal("qdr:m"), + Type.Literal("qdr:y"), + ]), + ), + categories: Type.Optional( + Type.Array(Type.String({ minLength: 1, maxLength: 64 }), { maxItems: 5 }), + ), + }), + async execute(_id, params, signal) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const abort = () => controller.abort(); + signal?.addEventListener("abort", abort, { once: true }); + if (signal?.aborted) controller.abort(); + try { + const response = await fetch(`${frontendBase}/api/agent/onboarding/search`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: params.query, + limit: params.limit ?? 5, + ...(params.lang ? { lang: params.lang } : {}), + ...(params.recency ? { recency: params.recency } : {}), + ...(params.categories?.length ? { categories: params.categories } : {}), + }), + signal: controller.signal, + }); + const payload = (await response.json()) as SearchResponse; + if (!response.ok || payload.success !== true) { + return output(`crw_search failed: HTTP ${response.status}`, { + failed: true, + status: response.status, + }); + } + const rows = rowsFrom(payload).map((row, index) => ({ + title: typeof row.title === "string" ? row.title : "Untitled result", + url: typeof row.url === "string" ? row.url : "", + snippet: + typeof row.snippet === "string" + ? row.snippet + : typeof row.description === "string" + ? row.description + : "", + position: typeof row.position === "number" ? row.position : index + 1, + score: typeof row.score === "number" ? row.score : null, + })); + return output(JSON.stringify(rows, null, 2), { + provider: "fastcrw", + endpoint: "keyring-proxy", + count: rows.length, + results: rows, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return output(`crw_search failed: ${message}`, { failed: true, error: message }); + } finally { + clearTimeout(timeout); + signal?.removeEventListener("abort", abort); + } + }, + }); +} diff --git a/frontend/desktop/resources/pi-extensions/goal.ts b/frontend/desktop/resources/pi-extensions/goal.ts index a2d62b28b..d7f7eef72 100644 --- a/frontend/desktop/resources/pi-extensions/goal.ts +++ b/frontend/desktop/resources/pi-extensions/goal.ts @@ -12,7 +12,9 @@ // This file is retained as the shared, pure section builder (and its tests). // It is no longer registered as a runtime extension. -const MARKER = "Local Studio session goal:"; +const brandAppName = process.env.LOCAL_STUDIO_BRAND_APP_NAME?.trim() || "Local Studio"; +const MARKER = `${brandAppName} session goal:`; +const LEGACY_MARKER = "Local Studio session goal:"; /** Statuses where the goal should steer the turn. A paused/complete/blocked * goal stays in the store (so the UI can show and resume it) but must not keep @@ -48,7 +50,9 @@ export function goalSystemPromptSection(goal: SessionGoal): string | null { if (turnBudget !== null) { lines.push("", `Turn budget: ${turnsUsed} of ${turnBudget} used.`); if (status === "budget_limited") { - lines.push("The budget is spent. Summarise progress and what remains; do not start new work."); + lines.push( + "The budget is spent. Summarise progress and what remains; do not start new work.", + ); } } else if (turnsUsed > 0) { lines.push("", `Turns spent on this goal so far: ${turnsUsed}.`); diff --git a/frontend/desktop/resources/pi-extensions/local-studio-agent-policy.ts b/frontend/desktop/resources/pi-extensions/local-studio-agent-policy.ts index 48031ccdd..5eda1bb9e 100644 --- a/frontend/desktop/resources/pi-extensions/local-studio-agent-policy.ts +++ b/frontend/desktop/resources/pi-extensions/local-studio-agent-policy.ts @@ -1,7 +1,11 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +const brandAppName = process.env.LOCAL_STUDIO_BRAND_APP_NAME?.trim() || "Local Studio"; +const ARTIFACT_POLICY_MARKER = `${brandAppName} artifact policy:`; +const LEGACY_ARTIFACT_POLICY_MARKER = "Local Studio artifact policy:"; + const ARTIFACT_POLICY = ` -Local Studio artifact policy: +${ARTIFACT_POLICY_MARKER} When you use a write, edit, file, or artifact tool to create or update content, that tool call is the artifact output. Do not repeat the same file body, HTML, source code, patch, or edit payload in assistant text after the tool result. @@ -17,7 +21,12 @@ to print or show it after it has already been written. export default function localStudioAgentPolicy(pi: ExtensionAPI) { pi.on("before_agent_start", (event) => { - if (event.systemPrompt.includes("Local Studio artifact policy:")) return {}; + if ( + event.systemPrompt.includes(ARTIFACT_POLICY_MARKER) || + event.systemPrompt.includes(LEGACY_ARTIFACT_POLICY_MARKER) + ) { + return {}; + } return { systemPrompt: `${event.systemPrompt.trimEnd()}\n\n${ARTIFACT_POLICY}` }; }); } diff --git a/frontend/desktop/resources/pi-extensions/notebooks.test.ts b/frontend/desktop/resources/pi-extensions/notebooks.test.ts new file mode 100644 index 000000000..b901e7303 --- /dev/null +++ b/frontend/desktop/resources/pi-extensions/notebooks.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import registerNotebookExtension from "./notebooks"; + +type RegisteredTool = { + name: string; + execute: ( + id: string, + params: Record, + signal: AbortSignal, + onUpdate: () => void, + context: { ui: { confirm: () => Promise } }, + ) => Promise<{ details: Record }>; +}; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +const tools = (): RegisteredTool[] => { + const registered: RegisteredTool[] = []; + registerNotebookExtension({ + registerTool: (tool: RegisteredTool) => registered.push(tool), + } as unknown as ExtensionAPI); + return registered; +}; + +describe("notebook Pi extension", () => { + test("registers inspect, patch, execute and structure tools", () => { + expect(tools().map(({ name }) => name)).toEqual([ + "notebook_inspect", + "notebook_patch_cell", + "notebook_execute_cell", + "notebook_structure", + ]); + }); + + test("does not request an approval when the scientist rejects structure mutation", async () => { + let requests = 0; + globalThis.fetch = (() => { + requests += 1; + return Promise.reject(new Error("unexpected request")); + }) as typeof fetch; + const tool = tools().find(({ name }) => name === "notebook_structure")!; + const result = await tool.execute( + "tool-01", + { + notebook_id: "notebook-01", + expected_revision: `sha256:${"a".repeat(64)}`, + operation: "delete", + cell_index: 2, + }, + new AbortController().signal, + () => undefined, + { ui: { confirm: async () => false } }, + ); + + expect(requests).toBe(0); + expect(result.details).toEqual({ rejected: true }); + }); + + test("uses the issued approval for an accepted structure mutation", async () => { + const requests: Array<{ url: string; body: Record }> = []; + globalThis.fetch = (async (input, init) => { + requests.push({ + url: String(input), + body: init?.body ? (JSON.parse(String(init.body)) as Record) : {}, + }); + return requests.length === 1 + ? Response.json({ approval: { id: "approval-01" } }, { status: 201 }) + : Response.json({ notebook: { revision: `sha256:${"b".repeat(64)}` } }); + }) as typeof fetch; + const tool = tools().find(({ name }) => name === "notebook_structure")!; + const result = await tool.execute( + "tool-01", + { + notebook_id: "notebook-01", + expected_revision: `sha256:${"a".repeat(64)}`, + operation: "move", + cell_index: 2, + direction: "up", + }, + new AbortController().signal, + () => undefined, + { ui: { confirm: async () => true } }, + ); + + expect(requests).toHaveLength(2); + expect(requests[0]?.url).toEndWith("/workbench/notebooks/notebook-01/approvals"); + expect(requests[1]?.url).toEndWith("/workbench/notebooks/notebook-01/document/structure"); + expect(requests[1]?.body["approval_id"]).toBe("approval-01"); + expect(result.details["notebook"]).toEqual({ revision: `sha256:${"b".repeat(64)}` }); + }); +}); diff --git a/frontend/desktop/resources/pi-extensions/notebooks.ts b/frontend/desktop/resources/pi-extensions/notebooks.ts new file mode 100644 index 000000000..7298a1873 --- /dev/null +++ b/frontend/desktop/resources/pi-extensions/notebooks.ts @@ -0,0 +1,251 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; + +type ToolResult = { + content: Array<{ type: "text"; text: string }>; + details: Record; +}; + +const controllerBase = process.env.LOCAL_STUDIO_CONTROLLER_BASE?.trim() || "http://127.0.0.1:8080"; + +const result = (text: string, details: Record = {}): ToolResult => ({ + content: [{ type: "text", text }], + details, +}); + +const notebookUrl = (notebookId: string, suffix = "") => + `${controllerBase}/workbench/notebooks/${encodeURIComponent(notebookId)}/document${suffix}`; + +const request = async ( + notebookId: string, + method: "GET" | "PATCH" | "POST", + body: Record | null, + signal: AbortSignal | undefined, + suffix = "", +): Promise> => { + const response = await fetch(notebookUrl(notebookId, suffix), { + method, + headers: { + ...(body ? { "Content-Type": "application/json" } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + signal, + }); + const payload = (await response.json()) as Record; + if (!response.ok) { + throw new Error( + typeof payload["detail"] === "string" ? payload["detail"] : `HTTP ${response.status}`, + ); + } + return payload; +}; + +const render = (payload: Record): ToolResult => + result(JSON.stringify(payload["notebook"] ?? payload, null, 2), payload); + +const failure = (operation: string, error: unknown): ToolResult => { + const message = error instanceof Error ? error.message : String(error); + return result(`${operation} failed: ${message}`, { failed: true, error: message }); +}; + +const approval = async ( + notebookId: string, + expectedRevision: string, + operation: "patch" | "execute" | "structure", + cellIndex: number, + signal: AbortSignal | undefined, +): Promise => { + const response = await fetch( + `${controllerBase}/workbench/notebooks/${encodeURIComponent(notebookId)}/approvals`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + expected_revision: expectedRevision, + operation, + cell_index: cellIndex, + }), + signal, + }, + ); + const payload = (await response.json()) as { + approval?: { id?: string }; + detail?: string; + }; + if (!response.ok || !payload.approval?.id) { + throw new Error(payload.detail ?? `Approval failed with HTTP ${response.status}`); + } + return payload.approval.id; +}; + +export default function registerNotebookExtension(pi: ExtensionAPI): void { + pi.registerTool({ + name: "notebook_inspect", + label: "Notebook: Inspect", + description: + "Inspect a governed Jupyter notebook, including cells, bounded outputs, kernel identity and revision.", + promptSnippet: "Inspect governed Jupyter notebooks and their bounded outputs", + parameters: Type.Object({ + notebook_id: Type.String({ description: "Persisted governed notebook session identifier" }), + }), + async execute(_id, params, signal) { + try { + return render(await request(params.notebook_id, "GET", null, signal)); + } catch (error) { + return failure("notebook_inspect", error); + } + }, + }); + + pi.registerTool({ + name: "notebook_patch_cell", + label: "Notebook: Propose cell patch", + description: + "Propose replacement source for one notebook cell. The scientist must approve before the notebook changes.", + promptSnippet: "Propose revision-bound notebook cell edits for scientist approval", + parameters: Type.Object({ + notebook_id: Type.String(), + expected_revision: Type.String(), + cell_index: Type.Number(), + source: Type.String(), + }), + executionMode: "sequential", + async execute(_id, params, signal, _onUpdate, ctx) { + const approved = await ctx.ui.confirm( + "Approve notebook cell change", + `Replace cell ${params.cell_index} in ${params.notebook_id}?\n\n${params.source.slice(0, 4000)}`, + { signal }, + ); + if (!approved) + return result("Scientist rejected the notebook cell change.", { rejected: true }); + try { + const approvalId = await approval( + params.notebook_id, + params.expected_revision, + "patch", + params.cell_index, + signal, + ); + return render( + await request( + params.notebook_id, + "PATCH", + { + expected_revision: params.expected_revision, + cell_index: params.cell_index, + source: params.source, + approval_id: approvalId, + }, + signal, + ), + ); + } catch (error) { + return failure("notebook_patch_cell", error); + } + }, + }); + + pi.registerTool({ + name: "notebook_execute_cell", + label: "Notebook: Execute cell", + description: + "Execute a revision-bound notebook code cell in a real Jupyter kernel after scientist approval.", + promptSnippet: "Execute approved notebook cells with a bounded timeout", + parameters: Type.Object({ + notebook_id: Type.String(), + expected_revision: Type.String(), + cell_index: Type.Number(), + timeout_seconds: Type.Optional(Type.Number()), + }), + executionMode: "sequential", + async execute(_id, params, signal, _onUpdate, ctx) { + const approved = await ctx.ui.confirm( + "Approve notebook execution", + `Execute cell ${params.cell_index} in ${params.notebook_id}? Code execution can read or modify data available to the kernel.`, + { signal }, + ); + if (!approved) return result("Scientist rejected notebook execution.", { rejected: true }); + try { + const approvalId = await approval( + params.notebook_id, + params.expected_revision, + "execute", + params.cell_index, + signal, + ); + return render( + await request( + params.notebook_id, + "POST", + { + expected_revision: params.expected_revision, + cell_index: params.cell_index, + timeout_seconds: params.timeout_seconds, + approval_id: approvalId, + }, + signal, + "/execute", + ), + ); + } catch (error) { + return failure("notebook_execute_cell", error); + } + }, + }); + + pi.registerTool({ + name: "notebook_structure", + label: "Notebook: Change structure", + description: "Insert, delete or move one notebook cell after scientist approval.", + promptSnippet: "Propose revision-bound notebook structure changes for scientist approval", + parameters: Type.Object({ + notebook_id: Type.String(), + expected_revision: Type.String(), + operation: Type.Union([Type.Literal("insert"), Type.Literal("delete"), Type.Literal("move")]), + cell_index: Type.Number(), + cell_type: Type.Optional( + Type.Union([Type.Literal("code"), Type.Literal("markdown"), Type.Literal("raw")]), + ), + direction: Type.Optional(Type.Union([Type.Literal("up"), Type.Literal("down")])), + }), + executionMode: "sequential", + async execute(_id, params, signal, _onUpdate, ctx) { + const approved = await ctx.ui.confirm( + "Approve notebook structure change", + `${params.operation} cell ${params.cell_index} in ${params.notebook_id}?`, + { signal }, + ); + if (!approved) + return result("Scientist rejected notebook structure change.", { rejected: true }); + try { + const approvalId = await approval( + params.notebook_id, + params.expected_revision, + "structure", + params.cell_index, + signal, + ); + return render( + await request( + params.notebook_id, + "POST", + { + expected_revision: params.expected_revision, + operation: params.operation, + cell_index: params.cell_index, + cell_type: params.cell_type, + direction: params.direction, + approval_id: approvalId, + }, + signal, + "/structure", + ), + ); + } catch (error) { + return failure("notebook_structure", error); + } + }, + }); +} diff --git a/frontend/desktop/resources/pi-extensions/plan.ts b/frontend/desktop/resources/pi-extensions/plan.ts index 754132854..9f02cb4ec 100644 --- a/frontend/desktop/resources/pi-extensions/plan.ts +++ b/frontend/desktop/resources/pi-extensions/plan.ts @@ -1,4 +1,4 @@ -// Plan tool extension for Local Studio. +// Plan tool extension for the desktop app. // // Gives Pi a structured task plan it can read and rewrite. The renderer shows // and edits the same document in the right-hand "Plan" panel through @@ -16,6 +16,7 @@ type ToolResult = { const FRONTEND_BASE = process.env.LOCAL_STUDIO_FRONTEND_BASE ?? "http://127.0.0.1:3000"; const PLAN_SESSION_ID = process.env.LOCAL_STUDIO_PLAN_SESSION_ID ?? ""; const PLAN_TOOL_TIMEOUT_MS = 20_000; +const brandAppName = process.env.LOCAL_STUDIO_BRAND_APP_NAME?.trim() || "Local Studio"; function result(text: string, details: Record = {}): ToolResult { return { content: [{ type: "text", text }], details }; @@ -53,8 +54,7 @@ export default function registerPlanExtension(pi: ExtensionAPI) { pi.registerTool({ name: "plan_read", label: "Plan: Read", - description: - "Read the shared Local Studio task plan (a Markdown checklist shown in the Plan panel). Call this at the start of a multi-step task to pick up an existing plan and its progress.", + description: `Read the shared ${brandAppName} task plan (a Markdown checklist shown in the Plan panel). Call this at the start of a multi-step task to pick up an existing plan and its progress.`, parameters: Type.Object({}), async execute(_id, _params, signal) { try { @@ -70,8 +70,7 @@ export default function registerPlanExtension(pi: ExtensionAPI) { pi.registerTool({ name: "plan_write", label: "Plan: Write", - description: - "Replace the shared Local Studio task plan shown in the Plan panel. Provide the FULL Markdown document. Use a `### To-dos` heading followed by checkbox lines: `- [ ]` pending, `- [/]` in progress, `- [x]` completed, `- [-]` cancelled. Keep exactly one item in progress. Call this whenever the plan or the status of a step changes.", + description: `Replace the shared ${brandAppName} task plan shown in the Plan panel. Provide the FULL Markdown document. Use a \`### To-dos\` heading followed by checkbox lines: \`- [ ]\` pending, \`- [/]\` in progress, \`- [x]\` completed, \`- [-]\` cancelled. Keep exactly one item in progress. Call this whenever the plan or the status of a step changes.`, parameters: Type.Object({ markdown: Type.String({ description: "Full replacement plan Markdown (a `### To-dos` checkbox list).", diff --git a/frontend/desktop/resources/pi-extensions/sitegeist-browser.ts b/frontend/desktop/resources/pi-extensions/sitegeist-browser.ts index f60d5f70d..b7ef4bf04 100644 --- a/frontend/desktop/resources/pi-extensions/sitegeist-browser.ts +++ b/frontend/desktop/resources/pi-extensions/sitegeist-browser.ts @@ -1,4 +1,4 @@ -// Sitegeist browser tool extension for Local Studio. +// Sitegeist browser tool extension for the desktop app. // // Registers Pi `sitegeist_*` tools that each make one HTTP JSON-RPC 2.0 call to // the local sitegeist relay (`${SITEGEIST_RELAY_URL}/rpc`), which forwards to the diff --git a/frontend/desktop/resources/pi-extensions/subagents.ts b/frontend/desktop/resources/pi-extensions/subagents.ts index c59a88cec..9c8e157e1 100644 --- a/frontend/desktop/resources/pi-extensions/subagents.ts +++ b/frontend/desktop/resources/pi-extensions/subagents.ts @@ -1,4 +1,4 @@ -// Subagent tool for Local Studio. +// Subagent tool for the desktop app. // // Registers a `subagent` tool that spawns an independent child agent session // in the runtime (same project, own context) and returns its final report as diff --git a/frontend/desktop/resources/plugins/gmail/skills/gmail/SKILL.md b/frontend/desktop/resources/plugins/gmail/skills/gmail/SKILL.md index 44cb4cd98..9fc459f02 100644 --- a/frontend/desktop/resources/plugins/gmail/skills/gmail/SKILL.md +++ b/frontend/desktop/resources/plugins/gmail/skills/gmail/SKILL.md @@ -1,6 +1,6 @@ --- name: gmail -description: Search and read the connected Gmail account with Local Studio's read-only tools. +description: Search and read the connected Gmail account with read-only tools. --- # Gmail diff --git a/frontend/desktop/resources/plugins/google-calendar/skills/google-calendar/SKILL.md b/frontend/desktop/resources/plugins/google-calendar/skills/google-calendar/SKILL.md index ca8d8b8ca..c6d1634c4 100644 --- a/frontend/desktop/resources/plugins/google-calendar/skills/google-calendar/SKILL.md +++ b/frontend/desktop/resources/plugins/google-calendar/skills/google-calendar/SKILL.md @@ -1,6 +1,6 @@ --- name: google-calendar -description: Inspect the connected Google Calendar account with Local Studio's read-only tools. +description: Inspect the connected Google Calendar account with read-only tools. --- # Google Calendar diff --git a/frontend/desktop/resources/skills/browser/SKILL.md b/frontend/desktop/resources/skills/browser/SKILL.md index 6c930749c..14d6e89d7 100644 --- a/frontend/desktop/resources/skills/browser/SKILL.md +++ b/frontend/desktop/resources/skills/browser/SKILL.md @@ -1,11 +1,11 @@ --- name: browser -description: Drive the Local Studio embedded browser when the user opens/enables the Browser panel or asks to browse, open, inspect, search, or interact with web pages. +description: Drive the embedded browser when the user opens/enables the Browser panel or asks to browse, open, inspect, search, or interact with web pages. --- # Browser -The Browser is the live embedded browser panel in Local Studio. When this skill is loaded, the browser tools are available and connected to the currently focused session. +The Browser is the live embedded browser panel in the desktop app. When this skill is loaded, the browser tools are available and connected to the currently focused session. Use the browser tools when the user asks you to browse, search the web, open a page, inspect a link, interact with a website, or when current web content matters. Prefer the embedded browser over shell-only scraping when the user asks to open something visually or continue from the page already visible in the Browser panel. diff --git a/frontend/desktop/resources/skills/plan/SKILL.md b/frontend/desktop/resources/skills/plan/SKILL.md index 0f9378fd3..8c4f05f2b 100644 --- a/frontend/desktop/resources/skills/plan/SKILL.md +++ b/frontend/desktop/resources/skills/plan/SKILL.md @@ -1,11 +1,11 @@ --- name: plan -description: Shared task plan between the human and the model in Local Studio. Use it to maintain a Cursor-style checklist for any multi-step task so the human can watch progress live in the Plan panel. Always use the plan tools for this instead of writing a plan to a Markdown file in the workspace. +description: Shared task plan between the human and the model in the desktop app. Use it to maintain a Cursor-style checklist for any multi-step task so the human can watch progress live in the Plan panel. Always use the plan tools for this instead of writing a plan to a Markdown file in the workspace. --- # Plan -The plan is a single Markdown checklist that the user sees and can edit live in the right-hand "Plan" panel of Local Studio. It is **shared** state: anything you write is rendered immediately as a checklist, and status the user toggles in the panel is visible to you on your next read. +The plan is a single Markdown checklist that the user sees and can edit live in the right-hand "Plan" panel of the desktop app. It is **shared** state: anything you write is rendered immediately as a checklist, and status the user toggles in the panel is visible to you on your next read. Two tools are available: diff --git a/frontend/e2e/controller-agent.config.ts b/frontend/e2e/controller-agent.config.ts index b563d563b..8fb3613be 100644 --- a/frontend/e2e/controller-agent.config.ts +++ b/frontend/e2e/controller-agent.config.ts @@ -41,7 +41,7 @@ const startScript = path.resolve(__dirname, "..", "scripts", "start-standalone.m export default defineConfig({ testDir: ".", - testMatch: ["controller-agent.spec.ts"], + testMatch: ["controller-agent.spec.ts", "setup-commissioning.spec.ts", "theme-light.spec.ts"], outputDir: "../test-results/controller-agent", workers: 1, retries: 0, @@ -66,6 +66,9 @@ export default defineConfig({ command: [ `PORT=${frontendPort}`, `HOME=${homeDir}`, + "LOCAL_STUDIO_APPLIANCE=cortaix-factory", + "LOCAL_STUDIO_DESKTOP=1", + "LOCAL_STUDIO_AGENT_ONBOARDING_TOKEN=controller-agent-e2e", `LOCAL_STUDIO_AGENT_RUNTIME_URL=http://127.0.0.1:${runtimePort}`, `LOCAL_STUDIO_DATA_DIR=${dataDir}`, `node ${startScript}`, diff --git a/frontend/e2e/controller-agent.spec.ts b/frontend/e2e/controller-agent.spec.ts index 5acdc728e..e6691ae80 100644 --- a/frontend/e2e/controller-agent.spec.ts +++ b/frontend/e2e/controller-agent.spec.ts @@ -7,7 +7,6 @@ test("Pi defaults to the active controller and reveals other models on request", const picker = page.getByRole("button", { name: /^Model:/ }).first(); await expect(picker).toBeEnabled({ timeout: 60_000 }); await expect(picker).toHaveAccessibleName(/controller-model/); - await expect(page.getByRole("button", { name: "Pi tools: read only" })).toBeVisible(); await picker.click(); await page.getByRole("menuitem", { name: /^Model\b/ }).click(); await expect(page.getByRole("menuitemradio", { name: "controller-model" })).toBeVisible(); diff --git a/frontend/e2e/enterprise-oidc.integration.test.ts b/frontend/e2e/enterprise-oidc.integration.test.ts new file mode 100644 index 000000000..d50e523c7 --- /dev/null +++ b/frontend/e2e/enterprise-oidc.integration.test.ts @@ -0,0 +1,676 @@ +import assert from "node:assert/strict"; +import { createHash, randomUUID } from "node:crypto"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, describe, test } from "node:test"; +import { exportJWK, generateKeyPair, SignJWT, type JWK } from "jose"; +import { NextRequest } from "next/server"; +import { GET as login } from "@/app/api/auth/login/[issuer]/route"; +import { GET as callback } from "@/app/api/auth/callback/[issuer]/route"; +import { POST as backchannelLogout } from "@/app/api/auth/backchannel-logout/[issuer]/route"; +import { GET as session } from "@/app/api/auth/session/route"; +import { GET as finishLogout, POST as logout } from "@/app/api/auth/logout/route"; +import { ENTERPRISE_FLOW_COOKIE, ENTERPRISE_SESSION_COOKIE } from "@/lib/auth/enterprise-session"; +import { discoverIssuer } from "@/lib/auth/oidc-client"; +import { CSRF_COOKIE, CSRF_HEADER } from "@/lib/security/request-boundary"; +import { authorizeEnterpriseAgentRequest } from "@local-studio/agent-runtime/enterprise-auth"; + +type TokenMode = "valid" | "nonce" | "issuer" | "audience" | "tenant" | "expired"; + +type SigningKey = Awaited>["privateKey"]; + +type FixtureKey = { + kid: string; + privateKey: SigningKey; + publicJwk: JWK; +}; + +const directory = mkdtempSync(join(tmpdir(), "enterprise-oidc-integration-")); +const applicationOrigin = "http://127.0.0.1:39000"; +const originalEnvironment = { + dataDir: process.env.LOCAL_STUDIO_DATA_DIR, + authConfig: process.env.LOCAL_STUDIO_ENTERPRISE_AUTH_CONFIG, + sessionKey: process.env.LOCAL_STUDIO_ENTERPRISE_SESSION_KEY, + sessionKeys: process.env.LOCAL_STUDIO_ENTERPRISE_SESSION_KEYS, + keycloakSecret: process.env.LOCAL_STUDIO_OIDC_SECRET_KEYCLOAK, + entraSecret: process.env.LOCAL_STUDIO_OIDC_SECRET_ENTRA, +}; +let issuerOrigin = ""; +let keycloakIssuer = ""; +let entraIssuer = ""; +let server: ReturnType; +let primaryKey: FixtureKey; +let rotatedKey: FixtureKey; +let rogueKey: FixtureKey; +let psKey: FixtureKey; +let activeKey: FixtureKey; +let tokenMode: TokenMode = "valid"; +let refreshes = 0; +let revocations = 0; +let endSessions = 0; +const csrfToken = "enterprise-logout-csrf-proof"; +const basicAuthorization = `Basic ${Buffer.from("local-studio:fixture-secret", "utf8").toString("base64")}`; + +const readBody = async (request: IncomingMessage): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +}; + +const sendJson = (response: ServerResponse, status: number, value: unknown): void => { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(value)); +}; + +const publicKey = (key: FixtureKey): JWK => ({ + ...key.publicJwk, + kid: key.kid, + alg: "RS256", + use: "sig", +}); + +const identityToken = async (input: { + key: FixtureKey; + issuer: string; + nonce: string | undefined; + roles: string[]; + clearanceGroup: string | undefined; + mode: TokenMode; + sid?: string; + expirationSeconds?: number; +}): Promise => { + const now = Math.floor(Date.now() / 1000); + const token = new SignJWT({ + roles: input.roles, + groups: input.clearanceGroup ? [input.clearanceGroup] : [], + tid: input.mode === "tenant" ? "other-tenant" : "tenant-1", + name: "Fixture Scientist", + ...(input.sid ? { sid: input.sid } : {}), + ...(input.nonce ? { nonce: input.mode === "nonce" ? "wrong-nonce" : input.nonce } : {}), + }) + .setProtectedHeader({ alg: "RS256", kid: input.key.kid }) + .setIssuer(input.mode === "issuer" ? `${input.issuer}/wrong` : input.issuer) + .setAudience(input.mode === "audience" ? "wrong-client" : "local-studio") + .setSubject("subject-1") + .setIssuedAt(input.mode === "expired" ? now - 120 : now) + .setExpirationTime(input.mode === "expired" ? now - 60 : now + (input.expirationSeconds ?? 30)); + return token.sign(input.key.privateKey); +}; + +const runtimeToken = ( + key: FixtureKey, + issuer: string, + audience = "local-studio-api", +): Promise => + new SignJWT({ roles: ["scientist"], groups: ["c2"], tid: "tenant-1" }) + .setProtectedHeader({ alg: "RS256", kid: key.kid }) + .setIssuer(issuer) + .setAudience(audience) + .setSubject("subject-1") + .setIssuedAt() + .setExpirationTime("5m") + .sign(key.privateKey); + +const cookieValue = (response: Response, name: string): string => { + const value = response.headers.get("set-cookie") ?? ""; + const match = value.match(new RegExp(`(?:^|,\\s*)${name}=([^;]+)`, "u")); + if (!match?.[1]) throw new Error(`Response did not set ${name}`); + return match[1]; +}; + +const loginFlow = async (issuerId = "keycloak") => { + const response = await login( + new NextRequest(`${applicationOrigin}/api/auth/login/${issuerId}?returnTo=%2Fsettings`), + { params: Promise.resolve({ issuer: issuerId }) }, + ); + assert.equal(response.status, 307); + const authorization = new URL(response.headers.get("location") ?? ""); + assert.equal(authorization.searchParams.get("code_challenge_method"), "S256"); + assert.equal(authorization.searchParams.get("nonce")?.length, 43); + return { + authorization, + flowCookie: cookieValue(response, ENTERPRISE_FLOW_COOKIE), + }; +}; + +before(async () => { + const first = await generateKeyPair("RS256"); + const second = await generateKeyPair("RS256"); + const third = await generateKeyPair("RS256"); + const ps = await generateKeyPair("PS256"); + primaryKey = { + kid: "fixture-key-1", + privateKey: first.privateKey, + publicJwk: await exportJWK(first.publicKey), + }; + rotatedKey = { + kid: "fixture-key-2", + privateKey: second.privateKey, + publicJwk: await exportJWK(second.publicKey), + }; + rogueKey = { + kid: "fixture-key-rogue", + privateKey: third.privateKey, + publicJwk: await exportJWK(third.publicKey), + }; + psKey = { + kid: "fixture-key-ps256", + privateKey: ps.privateKey, + publicJwk: await exportJWK(ps.publicKey), + }; + activeKey = primaryKey; + server = createServer(async (request, response) => { + const url = new URL(request.url ?? "/", issuerOrigin); + const issuer = url.pathname.startsWith("/entra") ? entraIssuer : keycloakIssuer; + if (url.pathname.endsWith("/.well-known/openid-configuration")) { + sendJson(response, 200, { + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + jwks_uri: `${issuer}/jwks`, + revocation_endpoint: `${issuer}/revoke`, + end_session_endpoint: `${issuer}/logout`, + backchannel_logout_supported: true, + backchannel_logout_session_supported: true, + id_token_signing_alg_values_supported: ["RS256"], + token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"], + }); + return; + } + if (url.pathname.endsWith("/jwks")) { + sendJson(response, 200, { keys: [publicKey(activeKey)] }); + return; + } + if (url.pathname === "/keycloak/token" && request.method === "POST") { + const body = new URLSearchParams(await readBody(request)); + assert.equal(request.headers.authorization, basicAuthorization); + assert.equal(body.get("client_id"), null); + assert.equal(body.get("client_secret"), null); + if (body.get("grant_type") === "authorization_code") { + const verifier = body.get("code_verifier") ?? ""; + const expected = createHash("sha256").update(verifier).digest("base64url"); + const nonce = body.get("code") === "fixture-code" ? currentNonce : ""; + assert.equal(expected, currentChallenge); + sendJson(response, 200, { + token_type: "Bearer", + access_token: "fixture-access-token", + refresh_token: "fixture-refresh-token", + id_token: await identityToken({ + key: activeKey, + issuer: keycloakIssuer, + nonce, + roles: ["platform"], + clearanceGroup: "c2", + mode: tokenMode, + sid: currentSid, + }), + }); + return; + } + if (body.get("grant_type") === "refresh_token") { + refreshes += 1; + assert.equal(body.get("refresh_token"), "fixture-refresh-token"); + await new Promise((resolve) => setTimeout(resolve, 25)); + sendJson(response, 200, { + token_type: "Bearer", + access_token: "rotated-access-token", + refresh_token: "rotated-refresh-token", + id_token: await identityToken({ + key: activeKey, + issuer: keycloakIssuer, + nonce: undefined, + roles: ["viewer"], + clearanceGroup: undefined, + mode: "valid", + sid: currentSid, + expirationSeconds: 300, + }), + }); + return; + } + } + if (url.pathname === "/keycloak/revoke" && request.method === "POST") { + const body = new URLSearchParams(await readBody(request)); + assert.equal(request.headers.authorization, basicAuthorization); + assert.equal(body.get("token"), "rotated-refresh-token"); + revocations += 1; + response.writeHead(200); + response.end(); + return; + } + if (url.pathname === "/keycloak/logout") { + assert.equal(url.searchParams.get("client_id"), "local-studio"); + assert.ok(url.searchParams.get("id_token_hint")); + endSessions += 1; + response.writeHead(302, { + location: url.searchParams.get("post_logout_redirect_uri") ?? applicationOrigin, + }); + response.end(); + return; + } + response.writeHead(404); + response.end(); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("OIDC fixture did not bind"); + issuerOrigin = `http://127.0.0.1:${address.port}`; + keycloakIssuer = `${issuerOrigin}/keycloak`; + entraIssuer = `${issuerOrigin}/entra`; + const configPath = join(directory, "enterprise-auth.json"); + writeFileSync( + configPath, + JSON.stringify({ + mode: "required_oidc", + issuers: [ + { + id: "keycloak", + kind: "keycloak", + issuer: keycloakIssuer, + client_id: "local-studio", + audience: "local-studio-api", + scopes: ["openid"], + tenant: "tenant-1", + realm: "science", + role_claim: "roles", + group_claim: "groups", + role_mappings: { + platform: ["platform_admin"], + viewer: ["viewer"], + scientist: ["scientist"], + }, + clearance_mappings: { c2: "C2" }, + backchannel_logout: { + enabled: true, + session_required: true, + }, + }, + { + id: "entra", + kind: "entra", + issuer: entraIssuer, + client_id: "local-studio", + audience: "local-studio-api", + scopes: ["api://local-studio/access"], + tenant: "tenant-1", + role_claim: "roles", + group_claim: "groups", + role_mappings: { scientist: ["scientist"] }, + clearance_mappings: { c2: "C2" }, + }, + ], + session_idle_seconds: 900, + session_absolute_seconds: 3600, + }), + ); + process.env.LOCAL_STUDIO_DATA_DIR = directory; + process.env.LOCAL_STUDIO_ENTERPRISE_AUTH_CONFIG = configPath; + process.env.LOCAL_STUDIO_ENTERPRISE_SESSION_KEY = "fixture-session-encryption-key-32"; + delete process.env.LOCAL_STUDIO_ENTERPRISE_SESSION_KEYS; + process.env.LOCAL_STUDIO_OIDC_SECRET_KEYCLOAK = "fixture-secret"; + process.env.LOCAL_STUDIO_OIDC_SECRET_ENTRA = "fixture-secret"; +}); + +after(async () => { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + rmSync(directory, { recursive: true, force: true }); + const restore = (key: string, value: string | undefined): void => { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + }; + restore("LOCAL_STUDIO_DATA_DIR", originalEnvironment.dataDir); + restore("LOCAL_STUDIO_ENTERPRISE_AUTH_CONFIG", originalEnvironment.authConfig); + restore("LOCAL_STUDIO_ENTERPRISE_SESSION_KEY", originalEnvironment.sessionKey); + restore("LOCAL_STUDIO_ENTERPRISE_SESSION_KEYS", originalEnvironment.sessionKeys); + restore("LOCAL_STUDIO_OIDC_SECRET_KEYCLOAK", originalEnvironment.keycloakSecret); + restore("LOCAL_STUDIO_OIDC_SECRET_ENTRA", originalEnvironment.entraSecret); +}); + +let currentChallenge = ""; +let currentNonce = ""; +let currentSid = "fixture-sid"; + +const logoutToken = async (input: { + algorithm?: "RS256" | "PS256"; + audience?: string; + events?: unknown; + issuer?: string; + jti?: string; + key?: FixtureKey; + nonce?: string; + omit?: "iat" | "exp" | "jti"; + sid?: string; + subject?: string; + typ?: string; +}): Promise => { + const now = Math.floor(Date.now() / 1000); + const key = input.key ?? activeKey; + const token = new SignJWT({ + events: + input.events === undefined + ? { "http://schemas.openid.net/event/backchannel-logout": {} } + : input.events, + ...(input.nonce ? { nonce: input.nonce } : {}), + ...(input.sid ? { sid: input.sid } : {}), + }) + .setProtectedHeader({ + alg: input.algorithm ?? "RS256", + kid: key.kid, + typ: input.typ ?? "logout+jwt", + }) + .setIssuer(input.issuer ?? keycloakIssuer) + .setAudience(input.audience ?? "local-studio"); + if (input.omit !== "iat") token.setIssuedAt(now); + if (input.omit !== "exp") token.setExpirationTime(now + 120); + if (input.omit !== "jti") token.setJti(input.jti ?? `logout-${randomUUID()}`); + if (input.subject) token.setSubject(input.subject); + return token.sign(key.privateKey); +}; + +const sendBackchannelLogout = (token: string, contentType = "application/x-www-form-urlencoded") => + backchannelLogout( + new NextRequest(`${applicationOrigin}/api/auth/backchannel-logout/keycloak`, { + method: "POST", + headers: { "content-type": contentType }, + body: new URLSearchParams({ logout_token: token }), + }), + { params: Promise.resolve({ issuer: "keycloak" }) }, + ); + +describe("enterprise OIDC route integration", () => { + test("discovers Keycloak-like and Entra-like issuers over HTTP", async () => { + const keycloak = await discoverIssuer({ + id: "keycloak", + kind: "keycloak", + issuer: keycloakIssuer, + client_id: "local-studio", + audience: "local-studio-api", + scopes: ["openid"], + tenant: "tenant-1", + role_claim: "roles", + group_claim: "groups", + role_mappings: { scientist: ["scientist"] }, + clearance_mappings: { c2: "C2" }, + }); + const entra = await discoverIssuer({ + id: "entra", + kind: "entra", + issuer: entraIssuer, + client_id: "local-studio", + audience: "local-studio-api", + scopes: ["api://local-studio/access"], + tenant: "tenant-1", + role_claim: "roles", + group_claim: "groups", + role_mappings: { scientist: ["scientist"] }, + clearance_mappings: { c2: "C2" }, + }); + assert.equal(keycloak.issuer, keycloakIssuer); + assert.equal(entra.issuer, entraIssuer); + }); + + test("enforces PKCE and rejects nonce, issuer, audience, tenant, and expiry failures", async () => { + for (const mode of ["nonce", "issuer", "audience", "tenant", "expired"] as const) { + const flow = await loginFlow(); + currentChallenge = flow.authorization.searchParams.get("code_challenge") ?? ""; + currentNonce = flow.authorization.searchParams.get("nonce") ?? ""; + tokenMode = mode; + const response = await callback( + new NextRequest( + `${applicationOrigin}/api/auth/callback/keycloak?code=fixture-code&state=${encodeURIComponent( + flow.authorization.searchParams.get("state") ?? "", + )}`, + { headers: { cookie: `${ENTERPRISE_FLOW_COOKIE}=${flow.flowCookie}` } }, + ), + { params: Promise.resolve({ issuer: "keycloak" }) }, + ); + assert.equal(response.status, 401, mode); + } + }); + + test("creates one callback session, rejects callback replay, and verifies runtime parity", async () => { + const flow = await loginFlow(); + currentChallenge = flow.authorization.searchParams.get("code_challenge") ?? ""; + currentNonce = flow.authorization.searchParams.get("nonce") ?? ""; + tokenMode = "valid"; + const callbackUrl = `${applicationOrigin}/api/auth/callback/keycloak?code=fixture-code&state=${encodeURIComponent( + flow.authorization.searchParams.get("state") ?? "", + )}`; + const accepted = await callback( + new NextRequest(callbackUrl, { + headers: { cookie: `${ENTERPRISE_FLOW_COOKIE}=${flow.flowCookie}` }, + }), + { params: Promise.resolve({ issuer: "keycloak" }) }, + ); + assert.equal(accepted.status, 307); + sessionCookie = cookieValue(accepted, ENTERPRISE_SESSION_COOKIE); + const replay = await callback( + new NextRequest(callbackUrl, { + headers: { cookie: `${ENTERPRISE_FLOW_COOKIE}=${flow.flowCookie}` }, + }), + { params: Promise.resolve({ issuer: "keycloak" }) }, + ); + assert.equal(replay.status, 401); + assert.equal( + await authorizeEnterpriseAgentRequest( + new Request("http://runtime/api/agent/turn", { + method: "POST", + headers: { + "x-local-studio-enterprise-token": await runtimeToken(primaryKey, keycloakIssuer), + }, + }), + ), + null, + ); + assert.equal( + await authorizeEnterpriseAgentRequest( + new Request("http://runtime/api/agent/turn", { + method: "POST", + headers: { + "x-local-studio-enterprise-token": await runtimeToken(primaryKey, entraIssuer), + }, + }), + ), + null, + ); + }); + + test("deduplicates refresh, accepts JWKS rotation, and rotates a downgraded session", async () => { + activeKey = rotatedKey; + await new Promise((resolveDelay) => setTimeout(resolveDelay, 1_050)); + const request = () => + session( + new NextRequest(`${applicationOrigin}/api/auth/session`, { + headers: { cookie: `${ENTERPRISE_SESSION_COOKIE}=${sessionCookie}` }, + }), + ); + const [first, second] = await Promise.all([request(), request()]); + assert.equal(first.status, 200); + assert.equal(second.status, 200); + const firstBody = (await first.json()) as { + authenticated: boolean; + principal: { roles: string[]; clearance: string }; + }; + const secondBody = (await second.json()) as typeof firstBody; + assert.equal(firstBody.authenticated, true); + assert.deepEqual(firstBody, secondBody); + assert.deepEqual(firstBody.principal.roles, ["viewer"]); + assert.equal(firstBody.principal.clearance, "open"); + assert.equal(refreshes, 1); + const firstCookie = cookieValue(first, ENTERPRISE_SESSION_COOKIE); + const secondCookie = cookieValue(second, ENTERPRISE_SESSION_COOKIE); + assert.equal(firstCookie, secondCookie); + assert.notEqual(firstCookie, sessionCookie); + const stale = await session( + new NextRequest(`${applicationOrigin}/api/auth/session`, { + headers: { cookie: `${ENTERPRISE_SESSION_COOKIE}=${sessionCookie}` }, + }), + ); + assert.equal(((await stale.json()) as { authenticated: boolean }).authenticated, true); + sessionCookie = firstCookie; + assert.equal( + await authorizeEnterpriseAgentRequest( + new Request("http://runtime/api/agent/turn", { + method: "POST", + headers: { + "x-local-studio-enterprise-token": await runtimeToken(rotatedKey, keycloakIssuer), + }, + }), + ), + null, + ); + }); + + test("rejects logout CSRF, revokes, redirects, and denies ticket replay", async () => { + const missingProof = await logout( + new NextRequest(`${applicationOrigin}/api/auth/logout?returnTo=%2Fsettings`, { + method: "POST", + headers: { cookie: `${ENTERPRISE_SESSION_COOKIE}=${sessionCookie}` }, + }), + ); + assert.equal(missingProof.status, 403); + const mismatchedProof = await logout( + new NextRequest(`${applicationOrigin}/api/auth/logout?returnTo=%2Fsettings`, { + method: "POST", + headers: { + cookie: `${ENTERPRISE_SESSION_COOKIE}=${sessionCookie}; ${CSRF_COOKIE}=${csrfToken}`, + [CSRF_HEADER]: "incorrect-proof", + }, + }), + ); + assert.equal(mismatchedProof.status, 403); + const response = await logout( + new NextRequest(`${applicationOrigin}/api/auth/logout?returnTo=%2Fsettings`, { + method: "POST", + headers: { + cookie: `${ENTERPRISE_SESSION_COOKIE}=${sessionCookie}; ${CSRF_COOKIE}=${csrfToken}`, + [CSRF_HEADER]: csrfToken, + }, + }), + ); + assert.equal(response.status, 200); + const body = (await response.json()) as { + revocation: string; + logout_path: string | null; + }; + assert.equal(body.revocation, "observed"); + assert.ok(body.logout_path); + assert.equal(revocations, 1); + const redirect = await finishLogout(new NextRequest(`${applicationOrigin}${body.logout_path}`)); + assert.equal(redirect.status, 303); + const issuerLocation = redirect.headers.get("location") ?? ""; + assert.equal(new URL(issuerLocation).pathname, "/keycloak/logout"); + const endSession = await fetch(issuerLocation, { redirect: "manual" }); + assert.equal(endSession.status, 302); + assert.equal(endSessions, 1); + const replay = await finishLogout(new NextRequest(`${applicationOrigin}${body.logout_path}`)); + assert.equal(new URL(replay.headers.get("location") ?? "").pathname, "/settings"); + }); + + test("validates back-channel logout, removes the indexed session, and denies replay", async () => { + currentSid = "backchannel-session"; + const flow = await loginFlow(); + currentChallenge = flow.authorization.searchParams.get("code_challenge") ?? ""; + currentNonce = flow.authorization.searchParams.get("nonce") ?? ""; + tokenMode = "valid"; + const accepted = await callback( + new NextRequest( + `${applicationOrigin}/api/auth/callback/keycloak?code=fixture-code&state=${encodeURIComponent( + flow.authorization.searchParams.get("state") ?? "", + )}`, + { headers: { cookie: `${ENTERPRISE_FLOW_COOKIE}=${flow.flowCookie}` } }, + ), + { params: Promise.resolve({ issuer: "keycloak" }) }, + ); + const cookie = cookieValue(accepted, ENTERPRISE_SESSION_COOKIE); + for (const invalid of [ + await logoutToken({ + audience: "wrong-client", + sid: currentSid, + subject: "subject-1", + }), + await logoutToken({ + events: {}, + sid: currentSid, + subject: "subject-1", + }), + await logoutToken({ + nonce: "prohibited", + sid: currentSid, + subject: "subject-1", + }), + await logoutToken({ subject: "subject-1" }), + await logoutToken({ + sid: currentSid, + subject: "subject-1", + typ: "JWT", + }), + await logoutToken({ + issuer: `${keycloakIssuer}/wrong`, + sid: currentSid, + subject: "subject-1", + }), + await logoutToken({ + key: rogueKey, + sid: currentSid, + subject: "subject-1", + }), + await logoutToken({ + algorithm: "PS256", + key: psKey, + sid: currentSid, + subject: "subject-1", + }), + await logoutToken({ omit: "iat", sid: currentSid, subject: "subject-1" }), + await logoutToken({ omit: "exp", sid: currentSid, subject: "subject-1" }), + await logoutToken({ omit: "jti", sid: currentSid, subject: "subject-1" }), + ]) { + assert.equal((await sendBackchannelLogout(invalid)).status, 400); + } + const before = await session( + new NextRequest(`${applicationOrigin}/api/auth/session`, { + headers: { cookie: `${ENTERPRISE_SESSION_COOKIE}=${cookie}` }, + }), + ); + assert.equal(((await before.json()) as { authenticated: boolean }).authenticated, true); + const valid = await logoutToken({ + jti: "backchannel-jti", + sid: currentSid, + subject: "subject-1", + }); + const validResponse = await sendBackchannelLogout( + valid, + "Application/X-WWW-Form-Urlencoded; charset=UTF-8", + ); + assert.equal(validResponse.status, 200); + assert.equal(validResponse.headers.get("cache-control"), "no-store"); + assert.equal(revocations, 2); + const after = await session( + new NextRequest(`${applicationOrigin}/api/auth/session`, { + headers: { cookie: `${ENTERPRISE_SESSION_COOKIE}=${cookie}` }, + }), + ); + assert.equal(((await after.json()) as { authenticated: boolean }).authenticated, false); + const replay = await sendBackchannelLogout(valid); + assert.equal(replay.status, 400); + assert.equal(replay.headers.get("cache-control"), "no-store"); + assert.equal( + ( + await sendBackchannelLogout( + await logoutToken({ + sid: currentSid, + subject: "subject-1", + }), + "application/json", + ) + ).status, + 400, + ); + }); +}); + +let sessionCookie = ""; diff --git a/frontend/e2e/enterprise-session-distributed.integration.test.ts b/frontend/e2e/enterprise-session-distributed.integration.test.ts new file mode 100644 index 000000000..abfba9b1b --- /dev/null +++ b/frontend/e2e/enterprise-session-distributed.integration.test.ts @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { once } from "node:events"; +import { existsSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { after, describe, test } from "node:test"; +import type { + EnterpriseAuthConfig, + NormalizedPrincipal, +} from "@local-studio/contracts/enterprise-auth"; +import { createEnterpriseSession, getEnterpriseSession } from "../src/lib/auth/enterprise-session"; +import { withEnterpriseStateLease } from "../src/lib/auth/enterprise-state-store"; + +const directory = mkdtempSync(join(tmpdir(), "enterprise-session-distributed-")); +const previousDataDir = process.env.LOCAL_STUDIO_DATA_DIR; +const previousSessionKey = process.env.LOCAL_STUDIO_ENTERPRISE_SESSION_KEY; +const previousSessionKeys = process.env.LOCAL_STUDIO_ENTERPRISE_SESSION_KEYS; +process.env.LOCAL_STUDIO_DATA_DIR = directory; +process.env.LOCAL_STUDIO_ENTERPRISE_SESSION_KEY = "distributed-session-encryption-key"; +delete process.env.LOCAL_STUDIO_ENTERPRISE_SESSION_KEYS; + +const config: EnterpriseAuthConfig = { + mode: "required_oidc", + issuers: [], + session_idle_seconds: 900, + session_absolute_seconds: 3600, +}; + +const principal: NormalizedPrincipal = { + subject: "distributed-subject", + issuer: "https://issuer.example.test", + issuer_id: "issuer", + tenant: "tenant-1", + display_name: "Distributed Scientist", + roles: ["scientist"], + entitlements: ["notebook:read", "notebook:execute", "ray:admit", "model:invoke", "agent:invoke"], + clearance: "C2", + issued_at: Math.floor(Date.now() / 1000) - 600, + expires_at: Math.floor(Date.now() / 1000) - 1, +}; + +const workerEnvironment = (): NodeJS.ProcessEnv => { + const environment = { ...process.env }; + delete environment["NODE_TEST_CONTEXT"]; + delete environment["BUN_TEST_RUNNER"]; + return environment; +}; + +const runWorker = (sessionId: string, countPath: string): Promise => + new Promise((resolve, reject) => { + const worker = spawn( + process.execPath, + [ + fileURLToPath(new URL("./fixtures/enterprise-session-worker.ts", import.meta.url)), + sessionId, + countPath, + ], + { + cwd: process.cwd(), + env: workerEnvironment(), + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let stdout = ""; + let stderr = ""; + worker.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + worker.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + worker.on("error", reject); + worker.on("exit", (code) => { + if (code === 0) resolve(stdout); + else reject(new Error(stderr || `Session worker exited with ${code}`)); + }); + }); + +const crashLeaseOwner = async (scope: string): Promise => { + const worker = spawn( + process.execPath, + [fileURLToPath(new URL("./fixtures/enterprise-lease-owner-worker.ts", import.meta.url)), scope], + { + cwd: process.cwd(), + env: workerEnvironment(), + stdio: ["ignore", "pipe", "pipe"], + }, + ); + await new Promise((resolve, reject) => { + worker.stdout?.once("data", (chunk) => { + if (String(chunk).trim() === "ready") resolve(); + else reject(new Error("Lease owner returned an invalid readiness signal")); + }); + worker.once("error", reject); + worker.once("exit", (code) => { + reject(new Error(`Lease owner exited before readiness with ${code}`)); + }); + }); + const exited = once(worker, "exit"); + worker.kill("SIGKILL"); + await exited; +}; + +after(() => { + rmSync(directory, { recursive: true, force: true }); + if (previousDataDir === undefined) delete process.env.LOCAL_STUDIO_DATA_DIR; + else process.env.LOCAL_STUDIO_DATA_DIR = previousDataDir; + if (previousSessionKey === undefined) delete process.env.LOCAL_STUDIO_ENTERPRISE_SESSION_KEY; + else process.env.LOCAL_STUDIO_ENTERPRISE_SESSION_KEY = previousSessionKey; + if (previousSessionKeys === undefined) delete process.env.LOCAL_STUDIO_ENTERPRISE_SESSION_KEYS; + else process.env.LOCAL_STUDIO_ENTERPRISE_SESSION_KEYS = previousSessionKeys; +}); + +describe("distributed enterprise sessions", () => { + test("converges refresh across independent processes", async () => { + const stale = await createEnterpriseSession(principal, "distributed-stale-token", config, { + refreshToken: "distributed-refresh-token", + }); + const countPath = join(directory, "distributed-refreshes.log"); + writeFileSync(countPath, ""); + const [first, second] = await Promise.all([ + runWorker(stale.id, countPath), + runWorker(stale.id, countPath), + ]); + const firstResult = JSON.parse(first) as { accessToken: string; sessionId: string }; + const secondResult = JSON.parse(second) as typeof firstResult; + assert.deepEqual(secondResult, firstResult); + assert.equal(firstResult.accessToken, "distributed-access-token"); + assert.equal(readFileSync(countPath, "utf8").trim().split("\n").length, 1); + assert.equal((await getEnterpriseSession(stale.id, config))?.id, firstResult.sessionId); + }); + + test("recovers a stale lease after its owning process is killed", async () => { + const scope = `crashed-owner:${randomUUID()}`; + await crashLeaseOwner(scope); + const digest = createHash("sha256").update(scope, "utf8").digest("hex"); + const lockPath = join(directory, `.enterprise-state-${digest}.lease.lock`); + assert.equal(existsSync(lockPath), true); + const stale = new Date(Date.now() - 60_000); + utimesSync(lockPath, stale, stale); + let acquired = false; + await withEnterpriseStateLease(scope, async () => { + acquired = true; + }); + assert.equal(acquired, true); + assert.equal(existsSync(lockPath), false); + }); +}); diff --git a/frontend/e2e/enterprise-session-redis.integration.test.ts b/frontend/e2e/enterprise-session-redis.integration.test.ts new file mode 100644 index 000000000..ba18bcc1b --- /dev/null +++ b/frontend/e2e/enterprise-session-redis.integration.test.ts @@ -0,0 +1,208 @@ +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { after, before, describe, test } from "node:test"; +import type { + EnterpriseAuthConfig, + NormalizedPrincipal, +} from "@local-studio/contracts/enterprise-auth"; +import { createEnterpriseSession, getEnterpriseSession } from "../src/lib/auth/enterprise-session"; +import { withEnterpriseStateLease } from "../src/lib/auth/enterprise-state-store"; + +const directory = mkdtempSync(join(tmpdir(), "enterprise-session-redis-")); +const container = `local-studio-redis-${randomUUID()}`; +const previousEnvironment = { + dataDir: process.env.LOCAL_STUDIO_DATA_DIR, + key: process.env.LOCAL_STUDIO_ENTERPRISE_SESSION_KEY, + keys: process.env.LOCAL_STUDIO_ENTERPRISE_SESSION_KEYS, + store: process.env.LOCAL_STUDIO_ENTERPRISE_STATE_STORE, + url: process.env.LOCAL_STUDIO_ENTERPRISE_REDIS_URL, + namespace: process.env.LOCAL_STUDIO_ENTERPRISE_REDIS_NAMESPACE, +}; +let stopped = false; + +const config: EnterpriseAuthConfig = { + mode: "required_oidc", + issuers: [], + session_idle_seconds: 900, + session_absolute_seconds: 3600, +}; + +const principal: NormalizedPrincipal = { + subject: "redis-subject", + issuer: "https://issuer.example.test", + issuer_id: "issuer", + tenant: "tenant-1", + display_name: "Redis Scientist", + roles: ["scientist"], + entitlements: ["notebook:read", "notebook:execute", "ray:admit", "model:invoke", "agent:invoke"], + clearance: "C2", + issued_at: Math.floor(Date.now() / 1000) - 600, + expires_at: Math.floor(Date.now() / 1000) - 1, +}; + +const command = (...arguments_: string[]): string => { + const result = spawnSync(arguments_[0], arguments_.slice(1), { encoding: "utf8" }); + if (result.status !== 0) throw new Error(result.stderr || result.stdout); + return result.stdout.trim(); +}; + +const redis = (...arguments_: string[]): string => + command("docker", "exec", container, "redis-cli", ...arguments_); + +const runWorker = (path: URL, arguments_: string[]): Promise => + new Promise((resolve, reject) => { + const environment = { ...process.env }; + delete environment["NODE_TEST_CONTEXT"]; + delete environment["BUN_TEST_RUNNER"]; + const worker = spawn(process.execPath, [fileURLToPath(path), ...arguments_], { + cwd: process.cwd(), + env: environment, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + worker.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + worker.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + worker.once("error", reject); + worker.once("exit", (code) => { + if (code === 0) resolve(stdout); + else reject(new Error(stderr || `Redis worker exited with ${code}`)); + }); + }); + +before(async () => { + command( + "docker", + "run", + "-d", + "--rm", + "--name", + container, + "-p", + "127.0.0.1::6379", + "redis:8-alpine", + "redis-server", + "--save", + "", + "--appendonly", + "no", + ); + let ready = false; + for (let attempt = 0; attempt < 80; attempt += 1) { + const result = spawnSync("docker", ["exec", container, "redis-cli", "PING"], { + encoding: "utf8", + }); + if (result.status === 0 && result.stdout.trim() === "PONG") { + ready = true; + break; + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)); + } + if (!ready) throw new Error("Redis fixture did not become ready"); + const mapping = command("docker", "port", container, "6379/tcp"); + const port = mapping.slice(mapping.lastIndexOf(":") + 1); + process.env.LOCAL_STUDIO_DATA_DIR = directory; + process.env.LOCAL_STUDIO_ENTERPRISE_SESSION_KEY = "redis-session-encryption-key-material"; + delete process.env.LOCAL_STUDIO_ENTERPRISE_SESSION_KEYS; + process.env.LOCAL_STUDIO_ENTERPRISE_STATE_STORE = "redis"; + process.env.LOCAL_STUDIO_ENTERPRISE_REDIS_URL = `redis://127.0.0.1:${port}`; + process.env.LOCAL_STUDIO_ENTERPRISE_REDIS_NAMESPACE = "redis-integration"; +}); + +after(() => { + if (!stopped) spawnSync("docker", ["rm", "-f", container]); + rmSync(directory, { recursive: true, force: true }); + for (const [key, value] of Object.entries(previousEnvironment)) { + const environmentKey = { + dataDir: "LOCAL_STUDIO_DATA_DIR", + key: "LOCAL_STUDIO_ENTERPRISE_SESSION_KEY", + keys: "LOCAL_STUDIO_ENTERPRISE_SESSION_KEYS", + store: "LOCAL_STUDIO_ENTERPRISE_STATE_STORE", + url: "LOCAL_STUDIO_ENTERPRISE_REDIS_URL", + namespace: "LOCAL_STUDIO_ENTERPRISE_REDIS_NAMESPACE", + }[key]!; + if (value === undefined) delete process.env[environmentKey]; + else process.env[environmentKey] = value; + } +}); + +describe("Redis enterprise sessions", () => { + test("coordinates refresh and back-channel logout across independent processes", async () => { + const stale = await createEnterpriseSession(principal, "redis-stale-token", config, { + refreshToken: "redis-refresh-token", + oidcSessionId: "redis-sid", + }); + const countPath = join(directory, "redis-refreshes.log"); + writeFileSync(countPath, ""); + const worker = new URL("./fixtures/enterprise-session-worker.ts", import.meta.url); + const [first, second] = await Promise.all([ + runWorker(worker, [stale.id, countPath]), + runWorker(worker, [stale.id, countPath]), + ]); + const firstResult = JSON.parse(first) as { accessToken: string; sessionId: string }; + assert.deepEqual(JSON.parse(second), firstResult); + assert.equal(readFileSync(countPath, "utf8").trim().split("\n").length, 1); + assert.equal((await getEnterpriseSession(stale.id, config))?.id, firstResult.sessionId); + const logoutWorker = new URL("./fixtures/enterprise-session-logout-worker.ts", import.meta.url); + const logoutArguments = [ + principal.issuer, + principal.issuer_id, + principal.subject, + "redis-sid", + "redis-logout-jti", + ]; + const logoutResults = await Promise.all([ + runWorker(logoutWorker, logoutArguments), + runWorker(logoutWorker, logoutArguments), + ]); + const logouts = logoutResults.map( + (result) => JSON.parse(result) as { deleted: number; replayed: boolean }, + ); + assert.deepEqual( + logouts.sort((left, right) => Number(left.replayed) - Number(right.replayed)), + [ + { deleted: 1, replayed: false }, + { deleted: 0, replayed: true }, + ], + ); + assert.equal(await getEnterpriseSession(stale.id, config), null); + const serialized = redis("GET", "redis-integration:{enterprise-state}:records:v1"); + assert.equal(serialized.includes("redis-stale-token"), false); + assert.equal(serialized.includes("redis-refresh-token"), false); + assert.equal(serialized.includes("distributed-access-token"), false); + assert.equal(serialized.includes("distributed-refresh-token"), false); + assert.equal(serialized.includes("distributed-id-token"), false); + }); + + test("recovers an expired lease and fails closed when Redis is unavailable", async () => { + const scope = "lease-recovery"; + const digest = createHash("sha256").update(scope, "utf8").digest("hex"); + redis("SET", `redis-integration:{enterprise-state}:lease:${digest}`, "other-owner", "PX", "50"); + await new Promise((resolveDelay) => setTimeout(resolveDelay, 75)); + let acquired = false; + await withEnterpriseStateLease(scope, async () => { + acquired = true; + }); + assert.equal(acquired, true); + const lostScope = "lease-loss"; + const lostDigest = createHash("sha256").update(lostScope, "utf8").digest("hex"); + await assert.rejects( + withEnterpriseStateLease(lostScope, async () => { + redis("DEL", `redis-integration:{enterprise-state}:lease:${lostDigest}`); + }), + /ownership was lost/u, + ); + command("docker", "rm", "-f", container); + stopped = true; + await assert.rejects(getEnterpriseSession("unavailable", config)); + }); +}); diff --git a/frontend/e2e/fixtures/appliance.mjs b/frontend/e2e/fixtures/appliance.mjs new file mode 100644 index 000000000..256a6e9c6 --- /dev/null +++ b/frontend/e2e/fixtures/appliance.mjs @@ -0,0 +1,3 @@ +import { resolveApplianceProfile } from "../../../shared/agent/appliance-profile.mjs"; + +export const APP_NAME = resolveApplianceProfile().appName; diff --git a/frontend/e2e/fixtures/e2e-providers.mjs b/frontend/e2e/fixtures/e2e-providers.mjs index f5ecd8abb..ff1f27c94 100644 --- a/frontend/e2e/fixtures/e2e-providers.mjs +++ b/frontend/e2e/fixtures/e2e-providers.mjs @@ -3,6 +3,8 @@ // pipeline (login job -> auth_url -> browser approval -> credential persisted // to auth.json -> Bearer on model requests) against fake-cloud.mjs. +import { APP_NAME } from "./appliance.mjs"; + const BASE = (process.env.LOCAL_STUDIO_E2E_FAKE_CLOUD || "http://127.0.0.1:43213").replace( /\/+$/, "", @@ -40,7 +42,7 @@ const providers = { const state = Math.random().toString(36).slice(2); callbacks.onAuth({ url: `${BASE}/authorize?state=${state}`, - instructions: "Approve Local Studio in your browser.", + instructions: `Approve ${APP_NAME} in your browser.`, }); for (let attempt = 0; attempt < 480; attempt += 1) { if (callbacks.signal?.aborted) throw new Error("Login cancelled"); diff --git a/frontend/e2e/fixtures/enterprise-lease-owner-worker.ts b/frontend/e2e/fixtures/enterprise-lease-owner-worker.ts new file mode 100644 index 000000000..5838249d8 --- /dev/null +++ b/frontend/e2e/fixtures/enterprise-lease-owner-worker.ts @@ -0,0 +1,8 @@ +import { acquireEnterpriseStateLease } from "../../src/lib/auth/enterprise-state-store"; + +const [, , scope] = process.argv; +if (!scope) throw new Error("Lease owner scope is missing"); + +await acquireEnterpriseStateLease(scope); +process.stdout.write("ready\n"); +await new Promise(() => {}); diff --git a/frontend/e2e/fixtures/enterprise-session-logout-worker.ts b/frontend/e2e/fixtures/enterprise-session-logout-worker.ts new file mode 100644 index 000000000..96e95a7aa --- /dev/null +++ b/frontend/e2e/fixtures/enterprise-session-logout-worker.ts @@ -0,0 +1,18 @@ +import { deleteEnterpriseSessionsForLogout } from "../../src/lib/auth/enterprise-session"; + +const [, , issuer, issuerId, subject, sid, jti] = process.argv; +if (!issuer || !issuerId || !subject || !sid || !jti) { + throw new Error("Logout worker arguments are incomplete"); +} + +const result = await deleteEnterpriseSessionsForLogout( + issuer, + issuerId, + jti, + Date.now() + 120_000, + { + subject, + sid, + }, +); +process.stdout.write(JSON.stringify(result)); diff --git a/frontend/e2e/fixtures/enterprise-session-worker.ts b/frontend/e2e/fixtures/enterprise-session-worker.ts new file mode 100644 index 000000000..06214b75c --- /dev/null +++ b/frontend/e2e/fixtures/enterprise-session-worker.ts @@ -0,0 +1,53 @@ +import { appendFileSync } from "node:fs"; +import type { OidcIssuerConfig } from "@local-studio/contracts/enterprise-auth"; +import { resolveEnterpriseSession } from "../../src/lib/auth/enterprise-session"; +import { acquireEnterpriseAccessToken } from "../../src/lib/auth/token-broker"; + +const [, , sessionId, countPath] = process.argv; +if (!sessionId || !countPath) throw new Error("Session worker arguments are incomplete"); + +const session = await resolveEnterpriseSession(sessionId); +if (!session) throw new Error("Session worker could not resolve the session"); + +const issuer: OidcIssuerConfig = { + id: "issuer", + kind: "keycloak", + issuer: "https://issuer.example.test", + client_id: "local-studio", + audience: "local-studio-api", + scopes: ["openid"], + tenant: "tenant-1", + role_claim: "roles", + group_claim: "groups", + role_mappings: { viewer: ["viewer"] }, + clearance_mappings: {}, +}; + +const result = await acquireEnterpriseAccessToken(session, { + issuer: () => issuer, + refresh: async () => { + appendFileSync(countPath, `${process.pid}\n`); + await new Promise((resolve) => setTimeout(resolve, 100)); + const now = Math.floor(Date.now() / 1000); + return { + accessToken: "distributed-access-token", + refreshToken: "distributed-refresh-token", + idToken: "distributed-id-token", + claims: { + sub: session.principal.subject, + iss: issuer.issuer, + tid: issuer.tenant, + iat: now, + exp: now + 600, + roles: ["viewer"], + }, + }; + }, +}); + +process.stdout.write( + JSON.stringify({ + accessToken: result.accessToken, + sessionId: result.session.id, + }), +); diff --git a/frontend/e2e/fixtures/fake-cloud.mjs b/frontend/e2e/fixtures/fake-cloud.mjs index d8153d718..998c60ada 100644 --- a/frontend/e2e/fixtures/fake-cloud.mjs +++ b/frontend/e2e/fixtures/fake-cloud.mjs @@ -5,6 +5,7 @@ import { createServer } from "node:http"; import { randomBytes } from "node:crypto"; +import { APP_NAME } from "./appliance.mjs"; const PORT = Number(process.env.PORT) > 0 ? Number(process.env.PORT) : 43213; @@ -56,7 +57,7 @@ function approvalPage(state) { return `

E2E Cloud

-

Local Studio is requesting access to your E2E Cloud account.

+

${APP_NAME} is requesting access to your E2E Cloud account.

`; } @@ -115,7 +116,7 @@ async function handleOAuth(req, res, url) { return html( res, 200, - `

Approved — return to Local Studio.

`, + `

Approved — return to ${APP_NAME}.

`, ); } if (url.pathname === "/poll") { diff --git a/frontend/e2e/fixtures/fake-controller.mjs b/frontend/e2e/fixtures/fake-controller.mjs index c9314beb5..c65832734 100644 --- a/frontend/e2e/fixtures/fake-controller.mjs +++ b/frontend/e2e/fixtures/fake-controller.mjs @@ -20,29 +20,35 @@ async function streamCompletion(request, response) { }); const id = `controller-${Date.now()}`; const chunks = ["Controller", " scoped", " Pi", " reply."]; - response.write(`data: ${JSON.stringify({ - id, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: "controller-model", - choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], - })}\n\n`); - for (const content of chunks) { - response.write(`data: ${JSON.stringify({ + response.write( + `data: ${JSON.stringify({ id, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model: "controller-model", - choices: [{ index: 0, delta: { content }, finish_reason: null }], - })}\n\n`); + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + })}\n\n`, + ); + for (const content of chunks) { + response.write( + `data: ${JSON.stringify({ + id, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: "controller-model", + choices: [{ index: 0, delta: { content }, finish_reason: null }], + })}\n\n`, + ); } - response.write(`data: ${JSON.stringify({ - id, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: "controller-model", - choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - })}\n\n`); + response.write( + `data: ${JSON.stringify({ + id, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: "controller-model", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + })}\n\n`, + ); response.write("data: [DONE]\n\n"); response.end(); } @@ -50,6 +56,87 @@ async function streamCompletion(request, response) { const server = createServer(async (request, response) => { const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`); if (url.pathname === "/health") return json(response, 200, { ok: true }); + if (url.pathname === "/events") { + response.writeHead(204); + return response.end(); + } + if (url.pathname === "/status") { + return json(response, 200, { + running: false, + process: null, + inference_port: 8000, + launching: null, + }); + } + if (url.pathname === "/studio/settings") { + return json(response, 200, { + config_path: "/tmp/local-studio-e2e.json", + persisted: { models_dir: null, ui_preferences: {} }, + effective: { models_dir: "/tmp/models" }, + }); + } + if (url.pathname === "/studio/diagnostics") { + return json(response, 200, { + app_version: "2.1.0", + timestamp: "2026-07-29T12:00:00.000Z", + platform: "darwin", + arch: "arm64", + release: "e2e", + cpu_model: "Apple Silicon", + cpu_cores: 12, + memory_total: 68_719_476_736, + memory_free: 34_359_738_368, + gpus: [], + runtime: { + vllm_installed: false, + vllm_version: null, + python_path: null, + vllm_bin: null, + }, + disks: [ + { + path: "/tmp/models", + total_bytes: 1_099_511_627_776, + free_bytes: 549_755_813_888, + available_bytes: 549_755_813_888, + }, + ], + config: { models_dir: "/tmp/models" }, + }); + } + if (url.pathname === "/studio/downloads") return json(response, 200, { downloads: [] }); + if (url.pathname === "/studio/presets") { + return json(response, 200, { presets: [], max_vram_gb: 0 }); + } + if (url.pathname === "/runtime/jobs") return json(response, 200, { jobs: [] }); + if (url.pathname === "/runtime/targets") return json(response, 200, { targets: [] }); + if (url.pathname === "/environment/kubernetes") { + return json(response, 200, { + configuration: { enabled: false, api_url: "", token_file: "", ca_file: null }, + probe: { + state: "unconfigured", + checked_at: null, + kubernetes_version: null, + ray_api_version: null, + detail: "Kubernetes workload admission is not configured.", + }, + }); + } + if (url.pathname === "/ai/v1/health") { + return json(response, 200, { + configured: false, + required: false, + state: "claimed", + detail: "Microsoft Foundry is not configured.", + correlation_ids: [], + model_count: 0, + agent_count: 0, + }); + } + if (url.pathname === "/workbench/notebooks") { + return json(response, 200, { notebooks: [] }); + } + if (url.pathname === "/workbench/ray-jobs") return json(response, 200, { jobs: [] }); if (url.pathname === "/v1/models") { return json(response, 200, { object: "list", diff --git a/frontend/e2e/live-dgx.spec.ts b/frontend/e2e/live-dgx.spec.ts index d76d7234b..102ce6427 100644 --- a/frontend/e2e/live-dgx.spec.ts +++ b/frontend/e2e/live-dgx.spec.ts @@ -10,7 +10,7 @@ import { selectLiveController, } from "./live-controller"; -test("Local Studio renders and talks to the live DS4 DGX Spark", async ({ page, context }) => { +test("the desktop app renders and talks to the live DS4 DGX Spark", async ({ page, context }) => { await selectLiveController(context, page); await test.step("Render the live DGX Spark status", async () => { @@ -43,7 +43,7 @@ test("Local Studio renders and talks to the live DS4 DGX Spark", async ({ page, ); }); - await test.step("Run two DS4 turns through Local Studio", async () => { + await test.step("Run two DS4 turns through the desktop app", async () => { const config = await readLiveControllerConfig(page); const beforeId = newestLiveControllerRequestId(config); const beforeCount = liveControllerRequestCount(config); diff --git a/frontend/e2e/provider-hub.config.ts b/frontend/e2e/provider-hub.config.ts index 0dcd2dd1a..04d2bd3d8 100644 --- a/frontend/e2e/provider-hub.config.ts +++ b/frontend/e2e/provider-hub.config.ts @@ -19,6 +19,7 @@ writeFileSync(path.join(dataDir, "api-settings.json"), "{}\n"); const providersModule = path.resolve(__dirname, "fixtures", "e2e-providers.mjs"); const fakeCloudScript = path.resolve(__dirname, "fixtures", "fake-cloud.mjs"); const startStandaloneScript = path.resolve(__dirname, "..", "scripts", "start-standalone.mjs"); +const appliance = "cortaix-factory"; export default defineConfig({ testDir: ".", @@ -49,7 +50,7 @@ export default defineConfig({ }, webServer: [ { - command: `PORT=${cloudPort} node ${fakeCloudScript}`, + command: `PORT=${cloudPort} LOCAL_STUDIO_APPLIANCE=${appliance} node ${fakeCloudScript}`, url: `http://127.0.0.1:${cloudPort}/health`, timeout: 15_000, reuseExistingServer: false, @@ -62,6 +63,7 @@ export default defineConfig({ `LOCAL_STUDIO_DATA_DIR=${dataDir}`, `LOCAL_STUDIO_E2E_PROVIDERS=${providersModule}`, `LOCAL_STUDIO_E2E_FAKE_CLOUD=http://127.0.0.1:${cloudPort}`, + `LOCAL_STUDIO_APPLIANCE=${appliance}`, `node ${startStandaloneScript}`, ].join(" "), url: `${baseURL}/api/desktop-health`, diff --git a/frontend/e2e/provider-hub.spec.ts b/frontend/e2e/provider-hub.spec.ts index d5b082407..d8884362e 100644 --- a/frontend/e2e/provider-hub.spec.ts +++ b/frontend/e2e/provider-hub.spec.ts @@ -10,6 +10,7 @@ import { expect, test, type Page } from "@playwright/test"; test.describe.configure({ mode: "serial" }); const MODELS_PAGE = "/configure?integration=models#integrations"; +const APP_NAME = "cortAIx Factory"; async function openModelsTab(page: Page): Promise { await page.goto(MODELS_PAGE); @@ -20,11 +21,18 @@ async function openModelsTab(page: Page): Promise { test("configure lists the provider catalog", async ({ page }) => { await openModelsTab(page); + await expect(page).toHaveTitle("cortAIx Factory"); + await expect(page.locator("html")).toHaveAttribute("data-appliance", "cortaix-factory"); + await expect(page.getByText("cortAIx Factory", { exact: true }).first()).toBeVisible(); await expect(page.getByTestId("provider-add-e2e-cloud")).toBeVisible(); await expect(page.getByTestId("provider-add-anthropic")).toBeVisible(); await expect(page.getByTestId("provider-add-openai-codex")).toBeVisible(); - await expect(page.getByTestId("provider-add-anthropic").getByRole("button", { name: "Sign in" })).toBeVisible(); - await expect(page.getByTestId("provider-add-anthropic").getByRole("button", { name: "API key" })).toBeVisible(); + await expect( + page.getByTestId("provider-add-anthropic").getByRole("button", { name: "Sign in" }), + ).toBeVisible(); + await expect( + page.getByTestId("provider-add-anthropic").getByRole("button", { name: "API key" }), + ).toBeVisible(); }); test("signs in to a provider with OAuth in the browser", async ({ page, context }) => { @@ -39,7 +47,7 @@ test("signs in to a provider with OAuth in the browser", async ({ page, context const approval = await context.newPage(); await approval.goto(authUrl as string); await approval.getByRole("button", { name: "Approve" }).click(); - await expect(approval.getByText("Approved — return to Local Studio.")).toBeVisible(); + await expect(approval.getByText(`Approved — return to ${APP_NAME}.`)).toBeVisible(); await approval.close(); await expect(page.getByTestId("provider-login-success")).toBeVisible({ timeout: 20_000 }); @@ -77,7 +85,10 @@ test("provider models join the picker and chat streams through the cloud", async test("signs out of the OAuth provider", async ({ page }) => { await openModelsTab(page); await expect(page.getByTestId("provider-row-e2e-cloud")).toBeVisible(); - await page.getByTestId("provider-row-e2e-cloud").getByRole("button", { name: "Sign out" }).click(); + await page + .getByTestId("provider-row-e2e-cloud") + .getByRole("button", { name: "Sign out" }) + .click(); await expect(page.getByTestId("provider-row-e2e-cloud")).toHaveCount(0, { timeout: 15_000 }); await expect(page.getByTestId("provider-add-e2e-cloud")).toBeVisible(); }); @@ -99,7 +110,10 @@ test("connects a builtin provider with an API key", async ({ page }) => { await expect(page.getByTestId("provider-row-fireworks")).toContainText("API key"); await test.step("Sign out again", async () => { - await page.getByTestId("provider-row-fireworks").getByRole("button", { name: "Sign out" }).click(); + await page + .getByTestId("provider-row-fireworks") + .getByRole("button", { name: "Sign out" }) + .click(); await expect(page.getByTestId("provider-row-fireworks")).toHaveCount(0, { timeout: 15_000 }); }); }); diff --git a/frontend/e2e/setup-commissioning.spec.ts b/frontend/e2e/setup-commissioning.spec.ts new file mode 100644 index 000000000..dd3106890 --- /dev/null +++ b/frontend/e2e/setup-commissioning.spec.ts @@ -0,0 +1,173 @@ +import { expect, test, type Page } from "@playwright/test"; + +const tracks = [ + ["Access", "Establish enterprise identity"], + ["Credentials", "Enroll services and agents"], + ["Environment", "Connect the execution environment"], + ["Inference", "Commission model serving"], + ["Review", "Review the commissioned boundary"], +] as const; + +const observeFailures = (page: Page) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + return errors; +}; + +const expectNoHorizontalOverflow = async (page: Page) => { + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + ).toBe(true); +}; + +test("commissioning exposes every governed track with C2 authority", async ({ page }, testInfo) => { + const errors = observeFailures(page); + await page.goto("/setup"); + await expect(page.locator("html")).toHaveAttribute("data-appliance", "cortaix-factory"); + await expect(page.getByRole("navigation", { name: "Setup stages" })).toBeVisible(); + await expect(page.getByRole("complementary", { name: "Setup evidence" })).toBeVisible(); + + for (const [track, title] of tracks) { + await page + .getByRole("navigation", { name: "Setup stages" }) + .getByRole("button", { + name: new RegExp(`^${track}`), + }) + .click(); + await expect(page.getByRole("heading", { level: 1, name: title })).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`track=${track.toLowerCase()}`)); + } + + await page + .getByRole("navigation", { name: "Setup stages" }) + .getByRole("button", { name: /^Inference/ }) + .click(); + const inferenceStages = page.getByRole("navigation", { + name: "Inference commissioning stages", + }); + for (const stage of ["Storage", "Runtime", "Model", "Acquire", "Serve", "Verify"]) { + await expect(inferenceStages.getByRole("button", { name: new RegExp(stage) })).toBeVisible(); + } + + const footer = page.getByRole("contentinfo", { + name: /Confidential classification, derived from appliance profile/, + }); + await expect(footer).toBeVisible(); + await expect(footer).toContainText("C2"); + await expect(footer).toContainText("mode changes deployment, not governance semantics"); + await expectNoHorizontalOverflow(page); + expect(errors).toEqual([]); + + const screenshot = testInfo.outputPath("setup-commissioning-desktop.png"); + await page.screenshot({ path: screenshot, fullPage: true }); + await testInfo.attach("Commissioning desktop", { path: screenshot, contentType: "image/png" }); +}); + +test("commissioning persists editable TensorPrime projections and fails C2 completion closed", async ({ + page, +}) => { + const errors = observeFailures(page); + await page.goto("/setup?track=environment"); + const inferenceProjection = page.getByRole("region", { + name: "Inference API probe projection", + }); + await expect(inferenceProjection).toBeVisible(); + await inferenceProjection.getByLabel("Probe path").fill("/healthz"); + await page.getByRole("button", { name: "Save probe projections" }).click(); + await expect(page.getByRole("button", { name: "Save probe projections" })).toBeEnabled(); + + await page.reload(); + await expect( + page.getByRole("region", { name: "Inference API probe projection" }).getByLabel("Probe path"), + ).toHaveValue("/healthz"); + + await page + .getByRole("navigation", { name: "Setup stages" }) + .getByRole("button", { name: /^Review/ }) + .click(); + await expect(page.getByRole("heading", { name: "TensorPrime service routes" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Complete commissioning" })).toBeDisabled(); + expect(errors).toEqual([]); +}); + +test("commissioning preserves other evidence when one source fails", async ({ page }) => { + const errors = observeFailures(page); + await page.route("**/api/agent/access-fabric", async (route) => { + await route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ detail: "Hermetic source failure" }), + }); + }); + await page.goto("/setup?track=review"); + await expect(page.getByRole("heading", { name: "Private access fabric" })).toBeVisible(); + await expect( + page.locator("#setup-content").getByText("apply recovery requires operator action."), + ).toBeVisible(); + await expect(page.getByRole("heading", { name: "TensorPrime service routes" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "OIDC issuer metadata" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Complete commissioning" })).toBeDisabled(); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("503"); +}); + +test("commissioning remains keyboard and narrow-viewport usable", async ({ page }) => { + const errors = observeFailures(page); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/setup?track=environment"); + await expect( + page.getByRole("heading", { level: 1, name: "Connect the execution environment" }), + ).toBeVisible(); + await page.evaluate(() => { + if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); + }); + await page.keyboard.press("Tab"); + await expect(page.getByRole("link", { name: "Skip to content" })).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(page.locator("#main-content")).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(page.getByRole("link", { name: "Skip to setup" })).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(page.locator("#setup-content")).toBeFocused(); + await expect(page.getByRole("contentinfo")).toBeVisible(); + await expectNoHorizontalOverflow(page); + expect(errors).toEqual([]); +}); + +test("commissioning resolves cortAIx light, dark, high contrast, and forced colors", async ({ + page, +}) => { + const errors = observeFailures(page); + await page.goto("/settings#appearance"); + const colorMode = page.getByRole("tablist", { name: "Color mode" }); + const contrastMode = page.getByRole("tablist", { name: "Contrast mode" }); + + await colorMode.getByRole("tab", { name: "Light" }).click(); + await page.goto("/setup"); + await expect(page.locator("html")).toHaveAttribute("data-theme", "cortaix-light"); + await expect(page.locator(".appliance-brand-mark__light").first()).toBeVisible(); + + await page.goto("/settings#appearance"); + await colorMode.getByRole("tab", { name: "Dark" }).click(); + await page.goto("/setup"); + await expect(page.locator("html")).toHaveAttribute("data-theme", "cortaix-dark"); + await expect(page.locator(".appliance-brand-mark__dark").first()).toBeVisible(); + + await page.goto("/settings#appearance"); + await contrastMode.getByRole("tab", { name: "High" }).click(); + await page.goto("/setup"); + await expect(page.locator("html")).toHaveAttribute("data-contrast-mode", "high"); + await expect(page.locator(".appliance-brand-mark__high-contrast").first()).toBeVisible(); + + await page.goto("/settings#appearance"); + await contrastMode.getByRole("tab", { name: "Standard" }).click(); + await page.emulateMedia({ forcedColors: "active" }); + await page.goto("/setup"); + await expect(page.locator(".appliance-brand-mark__forced-colors").first()).toBeVisible(); + expect(errors).toEqual([]); +}); diff --git a/frontend/e2e/theme-light.spec.ts b/frontend/e2e/theme-light.spec.ts new file mode 100644 index 000000000..072dd2780 --- /dev/null +++ b/frontend/e2e/theme-light.spec.ts @@ -0,0 +1,284 @@ +import { expect, test } from "@playwright/test"; + +test("cortAIx light persists from Appearance through science reload", async ({ + page, +}, testInfo) => { + const consoleErrors: string[] = []; + const hydrationErrors: string[] = []; + const failedBrandAssets: string[] = []; + page.on("console", (message) => { + const text = message.text(); + if (message.type() === "error") consoleErrors.push(text); + if (/hydration|server rendered|didn't match/i.test(text)) hydrationErrors.push(text); + }); + page.on("pageerror", (error) => { + consoleErrors.push(error.message); + if (/hydration|server rendered|didn't match/i.test(error.message)) { + hydrationErrors.push(error.message); + } + }); + page.on("requestfailed", (request) => { + if (request.url().includes("/appliances/cortaix-factory/cortaix-logo-")) { + failedBrandAssets.push(`${request.url()} ${request.failure()?.errorText ?? "failed"}`); + } + }); + page.on("response", (response) => { + if ( + response.url().includes("/appliances/cortaix-factory/cortaix-logo-") && + response.status() >= 400 + ) { + failedBrandAssets.push(`${response.url()} ${response.status()}`); + } + }); + await page.addInitScript(() => { + const trackedWindow = window as typeof window & { + __themeSequence?: Array<{ phase: string; theme: string | null }>; + }; + trackedWindow.__themeSequence = []; + const record = (phase: string) => { + trackedWindow.__themeSequence?.push({ + phase, + theme: document.documentElement?.getAttribute("data-theme") ?? null, + }); + }; + const observeRoot = () => { + if (!document.documentElement) { + setTimeout(observeRoot, 0); + return; + } + record("root-ready"); + new MutationObserver(() => record("mutation")).observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + }; + record("init-before-root"); + observeRoot(); + document.addEventListener("DOMContentLoaded", () => record("dom-content-loaded")); + window.addEventListener("load", () => record("load")); + }); + await page.goto("/settings#appearance"); + await expect(page.getByRole("heading", { name: "Appearance" })).toBeVisible(); + + const mode = page.getByRole("tablist", { name: "Color mode" }); + const light = mode.getByRole("tab", { name: "Light" }); + await light.click(); + await expect(light).toHaveAttribute("aria-selected", "true"); + + await expect + .poll(() => + page.evaluate(() => { + const raw = localStorage.getItem("local-studio-state"); + if (!raw) return null; + const parsed = JSON.parse(raw) as { state?: { themeId?: string } }; + return parsed.state?.themeId ?? null; + }), + ) + .toBe("cortaix-light"); + + await expect + .poll(() => + page.evaluate(() => ({ + theme: document.documentElement.dataset.theme, + background: document.documentElement.style.getPropertyValue("--bg"), + surface: document.documentElement.style.getPropertyValue("--surface"), + })), + ) + .toEqual({ + theme: "cortaix-light", + background: "#f7f7f9", + surface: "#ffffff", + }); + + await page.goto("/science"); + await expect(page).toHaveURL(/\/science$/); + await expect(page.getByRole("heading", { name: "Scientific workbench" })).toBeVisible(); + await expect( + page.locator("header").getByRole("button", { name: "Create notebook" }), + ).toBeVisible(); + await expect(page.getByRole("heading", { name: "Notebook sessions", exact: true })).toBeVisible(); + await page.reload(); + await expect(page.getByRole("heading", { name: "Scientific workbench" })).toBeVisible(); + await page.waitForLoadState("load"); + await page.waitForTimeout(100); + + const measured = await page.evaluate(() => { + const root = document.documentElement; + const main = document.querySelector("main"); + return { + theme: root.dataset.theme, + storedTheme: ( + JSON.parse(localStorage.getItem("local-studio-state") ?? "{}") as { + state?: { themeId?: string }; + } + ).state?.themeId, + backgroundToken: root.style.getPropertyValue("--bg"), + surfaceToken: root.style.getPropertyValue("--surface"), + mainBackground: main ? getComputedStyle(main).backgroundColor : null, + mainForeground: main ? getComputedStyle(main).color : null, + }; + }); + const themeSequence = await page.evaluate( + () => + ( + window as typeof window & { + __themeSequence?: Array<{ phase: string; theme: string | null }>; + } + ).__themeSequence ?? [], + ); + const firstLight = themeSequence.findIndex(({ theme }) => theme === "cortaix-light"); + expect(firstLight).toBeGreaterThanOrEqual(0); + expect(themeSequence.slice(firstLight).every(({ theme }) => theme === "cortaix-light")).toBe( + true, + ); + expect(themeSequence.some(({ phase }) => phase === "dom-content-loaded")).toBe(true); + expect(themeSequence.some(({ phase }) => phase === "load")).toBe(true); + const notebookMetric = page + .getByText("Notebooks", { exact: true }) + .locator("xpath=ancestor::div[contains(@class, 'min-h-')][1]"); + await expect(notebookMetric).toBeVisible(); + const panelBackground = await notebookMetric.evaluate( + (element) => getComputedStyle(element).backgroundColor, + ); + + expect(measured).toEqual({ + theme: "cortaix-light", + storedTheme: "cortaix-light", + backgroundToken: "#f7f7f9", + surfaceToken: "#ffffff", + mainBackground: "rgb(247, 247, 249)", + mainForeground: "rgb(19, 19, 25)", + }); + expect(panelBackground).toBe("rgb(255, 255, 255)"); + const brandVariants = page.locator('[class*="appliance-brand-mark__"]'); + const visibleBrandVariants = () => + brandVariants.evaluateAll( + (elements) => + elements.filter((element) => { + const style = getComputedStyle(element); + return style.display !== "none" && style.visibility !== "hidden"; + }).length, + ); + const lightBrand = page.locator(".appliance-brand-mark__light").first(); + await expect(lightBrand).toBeVisible(); + await expect(lightBrand).toHaveAttribute("src", /cortaix-logo-light\.svg$/); + expect( + await lightBrand.evaluate( + (image: HTMLImageElement) => image.complete && image.naturalWidth > 0, + ), + ).toBe(true); + await expect(page.locator(".appliance-brand-mark__dark").first()).toBeHidden(); + await expect(page.locator(".appliance-brand-mark__high-contrast").first()).toBeHidden(); + await expect(page.locator(".appliance-brand-mark__forced-colors").first()).toBeHidden(); + expect(await visibleBrandVariants()).toBe(1); + + const lightScreenshot = testInfo.outputPath("cortaix-light-science.png"); + await page.screenshot({ path: lightScreenshot, fullPage: true }); + await testInfo.attach("cortAIx light science", { + path: lightScreenshot, + contentType: "image/png", + }); + + await page.goto("/settings#appearance"); + const dark = page.getByRole("tablist", { name: "Color mode" }).getByRole("tab", { + name: "Dark", + }); + await dark.click(); + await expect(dark).toHaveAttribute("aria-selected", "true"); + await expect + .poll(() => + page.evaluate(() => { + const parsed = JSON.parse(localStorage.getItem("local-studio-state") ?? "{}") as { + state?: { themeId?: string }; + }; + return parsed.state?.themeId ?? null; + }), + ) + .toBe("cortaix-dark"); + + await page.goto("/science"); + await page.reload(); + await expect(page.getByRole("heading", { name: "Scientific workbench" })).toBeVisible(); + const darkMeasured = await page.evaluate(() => { + const root = document.documentElement; + const main = document.querySelector("main"); + const metric = Array.from(document.querySelectorAll("div")).find((element) => + element.classList.contains("min-h-[88px]"), + ); + return { + theme: root.dataset.theme, + storedTheme: ( + JSON.parse(localStorage.getItem("local-studio-state") ?? "{}") as { + state?: { themeId?: string }; + } + ).state?.themeId, + backgroundToken: root.style.getPropertyValue("--bg"), + surfaceToken: root.style.getPropertyValue("--surface"), + mainBackground: main ? getComputedStyle(main).backgroundColor : null, + mainForeground: main ? getComputedStyle(main).color : null, + panelBackground: metric ? getComputedStyle(metric).backgroundColor : null, + }; + }); + expect(darkMeasured).toEqual({ + theme: "cortaix-dark", + storedTheme: "cortaix-dark", + backgroundToken: "#131319", + surfaceToken: "#24252f", + mainBackground: "rgb(19, 19, 25)", + mainForeground: "rgb(247, 247, 249)", + panelBackground: "rgb(36, 37, 47)", + }); + const darkBrand = page.locator(".appliance-brand-mark__dark").first(); + await expect(darkBrand).toBeVisible(); + await expect(darkBrand).toHaveAttribute("src", /cortaix-logo-dark\.svg$/); + expect( + await darkBrand.evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth > 0), + ).toBe(true); + await expect(page.locator(".appliance-brand-mark__light").first()).toBeHidden(); + await expect(page.locator(".appliance-brand-mark__high-contrast").first()).toBeHidden(); + await expect(page.locator(".appliance-brand-mark__forced-colors").first()).toBeHidden(); + expect(await visibleBrandVariants()).toBe(1); + + const darkScreenshot = testInfo.outputPath("cortaix-dark-science.png"); + await page.screenshot({ path: darkScreenshot, fullPage: true }); + await testInfo.attach("cortAIx dark science", { + path: darkScreenshot, + contentType: "image/png", + }); + + await page.goto("/settings#appearance"); + const contrast = page.getByRole("tablist", { name: "Contrast mode" }); + await contrast.getByRole("tab", { name: "High" }).click(); + await expect(page.locator("html")).toHaveAttribute("data-contrast-mode", "high"); + const highContrastBrand = page.locator(".appliance-brand-mark__high-contrast").first(); + await expect(highContrastBrand).toBeVisible(); + await expect(highContrastBrand).toHaveAttribute("src", /cortaix-logo-highcontrast\.svg$/); + expect( + await highContrastBrand.evaluate( + (image: HTMLImageElement) => image.complete && image.naturalWidth > 0, + ), + ).toBe(true); + await expect(page.locator(".appliance-brand-mark__light").first()).toBeHidden(); + await expect(page.locator(".appliance-brand-mark__dark").first()).toBeHidden(); + await expect(page.locator(".appliance-brand-mark__forced-colors").first()).toBeHidden(); + expect(await visibleBrandVariants()).toBe(1); + + await contrast.getByRole("tab", { name: "Standard" }).click(); + await page.emulateMedia({ forcedColors: "active" }); + expect(await page.evaluate(() => matchMedia("(forced-colors: active)").matches)).toBe(true); + const forcedColorsBrand = page.locator(".appliance-brand-mark__forced-colors").first(); + await expect(forcedColorsBrand).toBeVisible(); + await expect(forcedColorsBrand).toHaveAttribute("src", /cortaix-logo-forcedcolors\.svg$/); + expect( + await forcedColorsBrand.evaluate( + (image: HTMLImageElement) => image.complete && image.naturalWidth > 0, + ), + ).toBe(true); + await expect(page.locator(".appliance-brand-mark__high-contrast").first()).toBeHidden(); + await expect(page.locator(".appliance-brand-mark__light").first()).toBeHidden(); + await expect(page.locator(".appliance-brand-mark__dark").first()).toBeHidden(); + expect(await visibleBrandVariants()).toBe(1); + expect(failedBrandAssets).toEqual([]); + expect(hydrationErrors).toEqual([]); + expect(consoleErrors).toEqual([]); +}); diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index ed9ae0a39..1092888a6 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -63,6 +63,7 @@ const eslintConfig = defineConfig([ globalIgnores([ // Default ignores of eslint-config-next: ".next/**", + ".next-dev/**", "out/**", "build/**", "next-env.d.ts", diff --git a/frontend/knip.ts b/frontend/knip.ts index ea1814d8f..1be133c8e 100644 --- a/frontend/knip.ts +++ b/frontend/knip.ts @@ -9,7 +9,7 @@ const config = { "desktop/**/*.test.ts", ], project: ["src/**/*.{ts,tsx}", "desktop/**/*.{ts,tsx}"], - ignore: [".next/**", "node_modules/**"], + ignore: [".next/**", ".next-dev/**", "node_modules/**"], ignoreIssues: { "desktop/interfaces.ts": ["types"], }, @@ -35,6 +35,7 @@ const config = { "fast-check", "@types/proper-lockfile", "@types/semver", + "electron-builder", ], ignoreExportsUsedInFile: true, }; diff --git a/frontend/next.config.ts b/frontend/next.config.ts index ba411cb7b..462980e76 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -2,6 +2,28 @@ import type { NextConfig } from "next"; import path from "path"; const nextConfig: NextConfig = { + distDir: process.env.LOCAL_STUDIO_NEXT_DIST_DIR || ".next", + env: { + LOCAL_STUDIO_APPLIANCE: process.env.LOCAL_STUDIO_APPLIANCE, + LOCAL_STUDIO_BRAND_APP_NAME: process.env.LOCAL_STUDIO_BRAND_APP_NAME, + LOCAL_STUDIO_BRAND_SHORT_NAME: process.env.LOCAL_STUDIO_BRAND_SHORT_NAME, + LOCAL_STUDIO_BRAND_DESCRIPTION: process.env.LOCAL_STUDIO_BRAND_DESCRIPTION, + LOCAL_STUDIO_BRAND_THEME_COLOR: process.env.LOCAL_STUDIO_BRAND_THEME_COLOR, + LOCAL_STUDIO_BRAND_ICON_SVG: process.env.LOCAL_STUDIO_BRAND_ICON_SVG, + LOCAL_STUDIO_BRAND_LOGO_LIGHT: process.env.LOCAL_STUDIO_BRAND_LOGO_LIGHT, + LOCAL_STUDIO_BRAND_LOGO_DARK: process.env.LOCAL_STUDIO_BRAND_LOGO_DARK, + LOCAL_STUDIO_BRAND_LOGO_HIGH_CONTRAST: process.env.LOCAL_STUDIO_BRAND_LOGO_HIGH_CONTRAST, + LOCAL_STUDIO_BRAND_LOGO_FORCED_COLORS: process.env.LOCAL_STUDIO_BRAND_LOGO_FORCED_COLORS, + LOCAL_STUDIO_BRAND_ICON_192: process.env.LOCAL_STUDIO_BRAND_ICON_192, + LOCAL_STUDIO_BRAND_ICON_512: process.env.LOCAL_STUDIO_BRAND_ICON_512, + LOCAL_STUDIO_BRAND_APPLE_TOUCH_ICON: process.env.LOCAL_STUDIO_BRAND_APPLE_TOUCH_ICON, + LOCAL_STUDIO_BRAND_MANIFEST_PATH: process.env.LOCAL_STUDIO_BRAND_MANIFEST_PATH, + LOCAL_STUDIO_BRAND_APP_ID: process.env.LOCAL_STUDIO_BRAND_APP_ID, + LOCAL_STUDIO_BRAND_DEV_APP_ID: process.env.LOCAL_STUDIO_BRAND_DEV_APP_ID, + LOCAL_STUDIO_BRAND_DEV_APP_NAME: process.env.LOCAL_STUDIO_BRAND_DEV_APP_NAME, + LOCAL_STUDIO_BRAND_CLASSIFICATION_CODE: process.env.LOCAL_STUDIO_BRAND_CLASSIFICATION_CODE, + LOCAL_STUDIO_BRAND_CLASSIFICATION_LABEL: process.env.LOCAL_STUDIO_BRAND_CLASSIFICATION_LABEL, + }, // Workaround for Next.js 16 bug: when unset, config.generateBuildId becomes // undefined, but generateBuildId() calls it as a function without a guard. generateBuildId: () => Date.now().toString(36) + Math.random().toString(36).slice(2, 8), diff --git a/frontend/package-lock.json b/frontend/package-lock.json index adfb23e9a..d72d1f853 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "2.1.0", "hasInstallScript": true, "dependencies": { + "@azure/msal-node": "5.4.3", "@earendil-works/pi-ai": "0.80.8", "@earendil-works/pi-coding-agent": "0.80.8", "@hono/node-server": "^2.0.12", @@ -16,6 +17,7 @@ "@local-studio/contracts": "file:../controller/contracts", "@lydell/node-pty": "1.2.0-beta.12", "@modelcontextprotocol/sdk": "1.29.0", + "@redis/client": "6.1.0", "@xterm/addon-fit": "0.11.0", "@xterm/addon-web-links": "0.13.0-beta.220", "@xterm/xterm": "6.1.0-beta.285", @@ -25,6 +27,7 @@ "fast-check": "^4.9.0", "highlight.js": "11.11.1", "hono": "4.12.30", + "jose": "6.2.4", "lucide-react": "0.561.0", "mermaid": "^11.16.0", "next": "16.2.12", @@ -38,7 +41,6 @@ "remark-gfm": "4.0.1", "semver": "7.8.5", "typebox": "1.1.38", - "yaml": "2.9.0", "zustand": "4.5.7" }, "devDependencies": { @@ -50,6 +52,7 @@ "@types/react-dom": "19.2.3", "@types/semver": "7.7.1", "concurrently": "9.2.4", + "cross-env": "7.0.3", "depcheck": "1.4.7", "electron": "43.1.1", "electron-builder": "26.15.3", @@ -77,15 +80,19 @@ "dependencies": { "@earendil-works/pi-ai": "0.80.8", "@earendil-works/pi-coding-agent": "0.80.8", + "@grpc/grpc-js": "1.14.4", "@hono/node-server": "^2.0.12", "@lydell/node-pty": "1.2.0-beta.12", "@modelcontextprotocol/sdk": "1.29.0", "chromium-bidi": "0.12.0", "effect": "4.0.0-beta.90", "hono": "4.12.30", + "jose": "6.2.4", "playwright-core": "1.61.1", "proper-lockfile": "4.1.2", - "semver": "7.8.5" + "protobufjs": "7.6.5", + "semver": "7.8.5", + "yaml": "2.9.0" }, "devDependencies": { "@types/semver": "7.7.1" @@ -586,6 +593,28 @@ "node": ">=18.0.0" } }, + "node_modules/@azure/msal-common": { + "version": "16.11.3", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.11.3.tgz", + "integrity": "sha512-VeXOW+t3Rdd9XGX6lVyIg3DhtjMR1JD8ARKcsnGbJFUWwAmF3sHL7GwZc/ZjEUfHESResAonETRYCuG06OBT7A==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.4.3.tgz", + "integrity": "sha512-tumCMmzrRhKmTbYQg/7OlfbrIKcKaf8Ed0Fw3suUpRT3owFYljznVgxcfHe8RycQXY9uyROGiLD1GjhpF45AwA==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.11.3", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -921,7 +950,6 @@ "version": "0.80.8", "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.8.tgz", "integrity": "sha512-oal0jK9E221Imhrj4Q4wXOhWmhzZEzbt9gmbVHs3UR4+KaClg+Ki1vH2FTVY7hntga89koPFSq4kQ6XV/HoSng==", - "hasShrinkwrap": true, "license": "MIT", "dependencies": { "@earendil-works/pi-agent-core": "^0.80.8", @@ -953,1801 +981,377 @@ "@mariozechner/clipboard": "0.3.9" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk": { - "version": "0.91.1", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", - "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { + "version": "0.80.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.8.tgz", "license": "MIT", "dependencies": { - "json-schema-to-ts": "^3.1.1" - }, - "bin": { - "anthropic-ai-sdk": "bin/cli" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" + "@earendil-works/pi-ai": "^0.80.8", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } + "engines": { + "node": ">=22.19.0" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "license": "Apache-2.0", + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { + "version": "0.80.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.8.tgz", + "license": "MIT", "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" }, "engines": { - "node": ">=16.0.0" + "node": ">=22.19.0" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=16.0.0" + "node": ">= 10" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1048.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", - "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/credential-provider-node": "^3.972.42", - "@aws-sdk/eventstream-handler-node": "^3.972.16", - "@aws-sdk/middleware-eventstream": "^3.972.12", - "@aws-sdk/middleware-websocket": "^3.972.19", - "@aws-sdk/token-providers": "3.1048.0", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=20.0.0" + "node": ">= 10" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { - "version": "3.974.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", - "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/xml-builder": "^3.972.24", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=20.0.0" + "node": ">= 10" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.37", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", - "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=20.0.0" + "node": ">= 10" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.39", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", - "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=20.0.0" + "node": ">= 10" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", - "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/credential-provider-env": "^3.972.37", - "@aws-sdk/credential-provider-http": "^3.972.39", - "@aws-sdk/credential-provider-login": "^3.972.41", - "@aws-sdk/credential-provider-process": "^3.972.37", - "@aws-sdk/credential-provider-sso": "^3.972.41", - "@aws-sdk/credential-provider-web-identity": "^3.972.41", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", - "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", - "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.37", - "@aws-sdk/credential-provider-http": "^3.972.39", - "@aws-sdk/credential-provider-ini": "^3.972.41", - "@aws-sdk/credential-provider-process": "^3.972.37", - "@aws-sdk/credential-provider-sso": "^3.972.41", - "@aws-sdk/credential-provider-web-identity": "^3.972.41", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.37", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", - "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", - "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/token-providers": "3.1048.0", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", - "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", - "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", - "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", - "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/nested-clients": { - "version": "3.997.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", - "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/signature-v4-multi-region": "^3.996.27", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", - "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/token-providers": { - "version": "3.1048.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", - "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/types": { - "version": "3.973.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", - "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", - "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/xml-builder": { - "version": "3.972.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", - "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", - "license": "Apache-2.0", - "dependencies": { - "@nodable/entities": "2.1.0", - "@smithy/types": "^4.14.1", - "fast-xml-parser": "5.7.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws/lambda-invoke-store": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", - "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { - "version": "0.80.8", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.8.tgz", - "license": "MIT", - "dependencies": { - "@earendil-works/pi-ai": "^0.80.8", - "ignore": "7.0.5", - "typebox": "1.1.38", - "yaml": "2.9.0" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { - "version": "0.80.8", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.8.tgz", - "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "0.91.1", - "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.6", - "@opentelemetry/api": "1.9.0", - "@smithy/node-http-handler": "4.7.3", - "http-proxy-agent": "7.0.2", - "https-proxy-agent": "7.0.6", - "openai": "6.26.0", - "partial-json": "0.1.7", - "typebox": "1.1.38" - }, - "bin": { - "pi-ai": "./dist/cli.js" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { - "version": "0.80.8", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.8.tgz", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "1.6.0", - "marked": "18.0.5" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", - "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^10.3.0", - "p-retry": "^4.6.2", - "protobufjs": "^7.5.4", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.25.2" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", - "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@mariozechner/clipboard-darwin-arm64": "0.3.9", - "@mariozechner/clipboard-darwin-universal": "0.3.9", - "@mariozechner/clipboard-darwin-x64": "0.3.9", - "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", - "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", - "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", - "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", - "@mariozechner/clipboard-linux-x64-musl": "0.3.9", - "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", - "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-arm64": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", - "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-universal": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", - "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-x64": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", - "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-gnu": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", - "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-musl": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", - "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", - "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", - "cpu": [ - "riscv64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-gnu": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", - "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-musl": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", - "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-arm64-msvc": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", - "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-x64-msvc": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", - "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mistralai/mistralai": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", - "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.40.0", - "ws": "^8.18.0", - "zod": "^3.25.0 || ^4.0.0", - "zod-to-json-schema": "^3.25.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - } - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.41.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", - "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/codegen": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", - "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "license": "BSD-3-Clause" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", - "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", - "license": "BSD-3-Clause" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", - "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/utf8": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", - "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", - "license": "BSD-3-Clause" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", - "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", - "license": "Apache-2.0" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { - "version": "3.24.3", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", - "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/credential-provider-imds": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", - "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/fetch-http-handler": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", - "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/node-http-handler": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", - "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/signature-v4": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", - "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/types": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", - "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": { - "version": "22.19.19", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", - "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/bowser": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", - "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-parser": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", - "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.7", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.2.3" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/gaxios": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", - "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/google-auth-library": { - "version": "10.6.2", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", - "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^7.1.4", - "gcp-metadata": "8.1.2", - "google-logging-utils": "1.1.3", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", - "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", - "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", - "license": "ISC", - "dependencies": { - "lru-cache": "^11.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/json-schema-to-ts": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", - "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "ts-algebra": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", - "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { - "version": "18.0.5", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", - "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/openai": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", - "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", - "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } + "node": ">= 10" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", - "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "@types/retry": "0.12.0", - "retry": "^0.13.1" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=8" + "node": ">= 10" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry/node_modules/@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/partial-json": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", - "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" ], "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=14.0.0" + "node": ">= 10" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } + "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", "engines": { - "node": "18 || 20 || >=22" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", - "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "retry": "^0.12.0", - "signal-exit": "^3.0.2" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "license": "MIT", - "engines": { - "node": ">= 4" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", - "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", - "hasInstallScript": true, + "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.1", - "@protobufjs/fetch": "^1.1.1", - "@protobufjs/float": "^1.0.2", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.1", - "@types/node": ">=13.7.0", - "long": "^5.3.2" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", "engines": { - "node": ">=8" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/strnum": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", - "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/ts-algebra": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", - "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { - "version": "1.1.38", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", - "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", - "license": "MIT" + "node": ">=0.3.1" + } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", - "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "license": "MIT", "engines": { - "node": ">=22.19.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "license": "MIT" + "node_modules/@earendil-works/pi-coding-agent/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", + "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", "engines": { - "node": ">= 8" + "node": "*" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", "license": "ISC", "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" + "lru-cache": "^11.1.0" }, "engines": { - "node": ">= 8" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "license": "MIT", "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "node": ">= 4" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], + "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=16.0.0" + "node": "20 || >=22" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "license": "ISC", + "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", "bin": { - "yaml": "bin.mjs" + "marked": "bin/marked.js" }, "engines": { - "node": ">= 14.6" + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/sponsors/eemeli" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, "funding": { - "url": "https://github.com/sponsors/colinhacks" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" } }, "node_modules/@electron-internal/extract-zip": { @@ -3423,9 +2027,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3442,9 +2043,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3461,9 +2059,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3480,9 +2075,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3499,9 +2091,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3518,9 +2107,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3537,9 +2123,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3556,9 +2139,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3575,9 +2155,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3600,9 +2177,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3625,9 +2199,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3650,9 +2221,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3675,9 +2243,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3700,9 +2265,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3725,9 +2287,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3750,9 +2309,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4436,9 +2992,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4455,9 +3008,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4474,9 +3024,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4493,9 +3040,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5053,6 +3597,30 @@ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, + "node_modules/@redis/client": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-6.1.0.tgz", + "integrity": "sha512-7u1LefkezJF0HESlhO7ZFLEPfyY+NejP3SGv+Z4pGaT3oM5GVVLa0u3f4rDLUrcw+SRo8IlX9Y8JAONeDdg1Ag==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -7560,7 +6128,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -7710,7 +6277,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -8289,6 +6855,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -8589,6 +7164,25 @@ "optional": true, "peer": true }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -9800,9 +8394,9 @@ } }, "node_modules/devtools-protocol": { - "version": "0.0.1663043", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1663043.tgz", - "integrity": "sha512-33aOY3ZnBP1dgZsshgaL+/XlsQleiFZgyUaDtdZkEa1nbZhVY1MoDeWjk+wxg25fU924l1ZJfoGNmjjeA/5s1w==", + "version": "0.0.1669207", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1669207.tgz", + "integrity": "sha512-7/s6rk++9nRcZZHAmW1LwmJbXZjGSOLjHUYjGtVxEc6mo5A3pk5PW9fPzw7R/hLw2gJPlY3GzYCvfZhS46Tvlw==", "license": "BSD-3-Clause", "peer": true }, @@ -13282,9 +11876,9 @@ } }, "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -13471,6 +12065,28 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/jstransformer": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/jstransformer/-/jstransformer-1.0.0.tgz", @@ -14320,12 +12936,48 @@ "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, "node_modules/lodash.isequal": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead." }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -14333,6 +12985,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -20297,7 +18955,6 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" diff --git a/frontend/package.json b/frontend/package.json index 15abca98b..f38fe782b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,7 @@ "repository": "https://github.com/sybil-solutions/local-studio", "scripts": { "predev": "node scripts/link-services-node-modules.mjs", - "dev": "concurrently -k -n NEXT,AGENT -c cyan,green \"next dev\" \"npm --prefix ../services/agent-runtime run dev\"", + "dev": "cross-env LOCAL_STUDIO_NEXT_DIST_DIR=.next-dev concurrently -k -n NEXT,AGENT -c cyan,green \"next dev\" \"npm --prefix ../services/agent-runtime run dev\"", "build": "node scripts/prepare-next-build.mjs && npm --prefix ../services/agent-runtime run bundle && next build --webpack && node scripts/complete-standalone-build.mjs && node scripts/assert-standalone-build.mjs", "start": "node scripts/start-standalone.mjs", "perf:audit": "node scripts/perf-audit.mjs", @@ -24,23 +24,24 @@ "check:dupes": "jscpd src", "check:cleanup": "npm run check:deadcode && npm run check:dupes && npm run depcheck", "check:ui-structure": "node scripts/validate-ui-structure.mjs", + "check:appliances": "node scripts/validate-appliances.mjs", "check:fix": "knip --fix", "desktop:build:main": "tsc -p desktop/tsconfig.json", "desktop:start": "electron desktop/dist/main.js", - "desktop:start:dev": "LOCAL_STUDIO_DESKTOP_DEV_SERVER_URL=http://127.0.0.1:3000 electron desktop/dist/main.js", - "desktop:start:dev:beta": "LOCAL_STUDIO_DESKTOP_APP_NAME=\"Local Studio Dev\" LOCAL_STUDIO_DESKTOP_USER_DATA_DIR=\"$HOME/Library/Application Support/Local Studio Dev\" LOCAL_STUDIO_DESKTOP_DISABLE_AUTO_UPDATE=true LOCAL_STUDIO_DESKTOP_DEV_SERVER_URL=http://127.0.0.1:3001 electron desktop/dist/main.js", + "desktop:start:dev": "cross-env LOCAL_STUDIO_DESKTOP_CHANNEL=dev LOCAL_STUDIO_DESKTOP_DEV_SERVER_URL=http://127.0.0.1:3000 electron desktop/dist/main.js", + "desktop:start:dev:beta": "cross-env LOCAL_STUDIO_DESKTOP_CHANNEL=dev LOCAL_STUDIO_DESKTOP_DISABLE_AUTO_UPDATE=true LOCAL_STUDIO_DESKTOP_DEV_SERVER_URL=http://127.0.0.1:3001 electron desktop/dist/main.js", "desktop:build": "npm run build && npm run desktop:build:main", "desktop:dev": "npm run desktop:build:main && concurrently -k -n NEXT,ELECTRON -c cyan,magenta \"npm run dev\" \"node -e \\\"setTimeout(() => process.exit(0), 3000)\\\" && npm run desktop:start:dev\"", - "desktop:dev:beta": "npm run desktop:build:main && concurrently -k -n NEXT,ELECTRON -c cyan,magenta \"PORT=3001 npm run dev\" \"node -e \\\"setTimeout(() => process.exit(0), 3000)\\\" && npm run desktop:start:dev:beta\"", - "desktop:dist": "npm run desktop:build && electron-builder --config desktop/electron-builder.yml", - "desktop:dist:dev": "npm run desktop:build && electron-builder --config desktop/electron-builder.yml --config.appId=org.local.studio.desktop.dev --config.productName=\"Local Studio Dev\" --config.extraMetadata.localStudioChannel=dev --config.directories.output=dist-desktop-dev", - "desktop:dist:notarized": "npm run desktop:build && electron-builder --config desktop/electron-builder.yml --config.mac.notarize=true", - "desktop:pack": "npm run desktop:build && electron-builder --dir --config desktop/electron-builder.yml", + "desktop:dev:beta": "npm run desktop:build:main && concurrently -k -n NEXT,ELECTRON -c cyan,magenta \"cross-env PORT=3001 npm run dev\" \"node -e \\\"setTimeout(() => process.exit(0), 3000)\\\" && npm run desktop:start:dev:beta\"", + "desktop:dist": "npm run desktop:build && node scripts/run-electron-builder.mjs dist", + "desktop:dist:dev": "npm run desktop:build && node scripts/run-electron-builder.mjs dist-dev", + "desktop:dist:notarized": "npm run desktop:build && node scripts/run-electron-builder.mjs dist-notarized", + "desktop:pack": "npm run desktop:build && node scripts/run-electron-builder.mjs pack", "desktop:smoke": "node scripts/desktop-package-smoke.mjs", "typecheck": "tsc --noEmit", "typecheck:desktop": "tsc -p desktop/tsconfig.json", "check:cycles": "madge --extensions ts,tsx --circular src", - "check:static": "npm run lint && npm run typecheck && npm run typecheck:desktop && npm run typecheck:extensions && npm run check:cycles && npm run check:ui-structure", + "check:static": "npm run lint && npm run typecheck && npm run typecheck:desktop && npm run typecheck:extensions && npm run check:cycles && npm run check:ui-structure && npm run check:appliances", "test": "bun test src desktop", "test:e2e": "playwright test --config e2e/controller-agent.config.ts", "check:quality": "node scripts/validate-package-json.mjs && npm run check:static && npm run test && npm run check:cleanup && npm run build", @@ -48,6 +49,7 @@ "typecheck:extensions": "tsc -p desktop/resources/pi-extensions/tsconfig.json" }, "dependencies": { + "@azure/msal-node": "5.4.3", "@earendil-works/pi-ai": "0.80.8", "@earendil-works/pi-coding-agent": "0.80.8", "@hono/node-server": "^2.0.12", @@ -55,6 +57,7 @@ "@local-studio/contracts": "file:../controller/contracts", "@lydell/node-pty": "1.2.0-beta.12", "@modelcontextprotocol/sdk": "1.29.0", + "@redis/client": "6.1.0", "@xterm/addon-fit": "0.11.0", "@xterm/addon-web-links": "0.13.0-beta.220", "@xterm/xterm": "6.1.0-beta.285", @@ -64,6 +67,7 @@ "fast-check": "^4.9.0", "highlight.js": "11.11.1", "hono": "4.12.30", + "jose": "6.2.4", "lucide-react": "0.561.0", "mermaid": "^11.16.0", "next": "16.2.12", @@ -77,7 +81,6 @@ "remark-gfm": "4.0.1", "semver": "7.8.5", "typebox": "1.1.38", - "yaml": "2.9.0", "zustand": "4.5.7" }, "devDependencies": { @@ -89,6 +92,7 @@ "@types/react-dom": "19.2.3", "@types/semver": "7.7.1", "concurrently": "9.2.4", + "cross-env": "7.0.3", "depcheck": "1.4.7", "electron": "43.1.1", "electron-builder": "26.15.3", diff --git a/frontend/public/appliances/cortaix-factory/android-adaptive-background.svg b/frontend/public/appliances/cortaix-factory/android-adaptive-background.svg new file mode 100644 index 000000000..ea3d2b6ef --- /dev/null +++ b/frontend/public/appliances/cortaix-factory/android-adaptive-background.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/appliances/cortaix-factory/android-adaptive-foreground.svg b/frontend/public/appliances/cortaix-factory/android-adaptive-foreground.svg new file mode 100644 index 000000000..1b486148a --- /dev/null +++ b/frontend/public/appliances/cortaix-factory/android-adaptive-foreground.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/appliances/cortaix-factory/cortaix-icon-mono.svg b/frontend/public/appliances/cortaix-factory/cortaix-icon-mono.svg new file mode 100644 index 000000000..d8e8bae3d --- /dev/null +++ b/frontend/public/appliances/cortaix-factory/cortaix-icon-mono.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/appliances/cortaix-factory/cortaix-icon.svg b/frontend/public/appliances/cortaix-factory/cortaix-icon.svg new file mode 100644 index 000000000..c25923c33 --- /dev/null +++ b/frontend/public/appliances/cortaix-factory/cortaix-icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/appliances/cortaix-factory/cortaix-logo-dark.svg b/frontend/public/appliances/cortaix-factory/cortaix-logo-dark.svg new file mode 100644 index 000000000..ed03d239c --- /dev/null +++ b/frontend/public/appliances/cortaix-factory/cortaix-logo-dark.svg @@ -0,0 +1,15 @@ + + cortAIx — Artificial Intelligence by Thales + + cortAIx + Artificial Intelligence by + + + + + + + + + + diff --git a/frontend/public/appliances/cortaix-factory/cortaix-logo-forcedcolors.svg b/frontend/public/appliances/cortaix-factory/cortaix-logo-forcedcolors.svg new file mode 100644 index 000000000..e35890e2b --- /dev/null +++ b/frontend/public/appliances/cortaix-factory/cortaix-logo-forcedcolors.svg @@ -0,0 +1,15 @@ + + cortAIx — Artificial Intelligence by Thales + + cortAIx + Artificial Intelligence by + + + + + + + + + + diff --git a/frontend/public/appliances/cortaix-factory/cortaix-logo-highcontrast.svg b/frontend/public/appliances/cortaix-factory/cortaix-logo-highcontrast.svg new file mode 100644 index 000000000..d6676004f --- /dev/null +++ b/frontend/public/appliances/cortaix-factory/cortaix-logo-highcontrast.svg @@ -0,0 +1,15 @@ + + cortAIx — Artificial Intelligence by Thales + + cortAIx + Artificial Intelligence by + + + + + + + + + + diff --git a/frontend/public/appliances/cortaix-factory/cortaix-logo-light.svg b/frontend/public/appliances/cortaix-factory/cortaix-logo-light.svg new file mode 100644 index 000000000..c169e200b --- /dev/null +++ b/frontend/public/appliances/cortaix-factory/cortaix-logo-light.svg @@ -0,0 +1,15 @@ + + cortAIx — Artificial Intelligence by Thales + + cortAIx + Artificial Intelligence by + + + + + + + + + + diff --git a/frontend/public/appliances/cortaix-factory/cortaix-logo-mono.svg b/frontend/public/appliances/cortaix-factory/cortaix-logo-mono.svg new file mode 100644 index 000000000..7ec988e01 --- /dev/null +++ b/frontend/public/appliances/cortaix-factory/cortaix-logo-mono.svg @@ -0,0 +1,15 @@ + + cortAIx — Artificial Intelligence by Thales + + cortAIx + Artificial Intelligence by + + + + + + + + + + diff --git a/frontend/public/appliances/cortaix-factory/cortaix-logo-white.svg b/frontend/public/appliances/cortaix-factory/cortaix-logo-white.svg new file mode 100644 index 000000000..0e12faf0b --- /dev/null +++ b/frontend/public/appliances/cortaix-factory/cortaix-logo-white.svg @@ -0,0 +1,15 @@ + + cortAIx — Artificial Intelligence by Thales + + cortAIx + Artificial Intelligence by + + + + + + + + + + diff --git a/frontend/public/appliances/cortaix-factory/cortaix-logo.svg b/frontend/public/appliances/cortaix-factory/cortaix-logo.svg new file mode 100644 index 000000000..c169e200b --- /dev/null +++ b/frontend/public/appliances/cortaix-factory/cortaix-logo.svg @@ -0,0 +1,15 @@ + + cortAIx — Artificial Intelligence by Thales + + cortAIx + Artificial Intelligence by + + + + + + + + + + diff --git a/frontend/public/appliances/cortaix-factory/cortaix-tui.ans b/frontend/public/appliances/cortaix-factory/cortaix-tui.ans new file mode 100644 index 000000000..c777d8ad8 --- /dev/null +++ b/frontend/public/appliances/cortaix-factory/cortaix-tui.ans @@ -0,0 +1,8 @@ + ██████ ██████ █████  ██████ ██████ ██████ ██ ██ + ██  ██ ██ ██ ██  ██  ██ ██  ██  ██ ██ + ██  ██ ██ █████   ██  ██████  ██   ████  + ██  ██ ██ ██ ██   ██  ██ ██  ██   ██  + ██  ██ ██ ██ ██  ██  ██ ██  ██  ██ ██ + ██████ ██████ ██ ██  ██  ██ ██ ██████ ██ ██ + + Artificial Intelligence by THALES diff --git a/frontend/public/appliances/cortaix-factory/cortaix-tui.txt b/frontend/public/appliances/cortaix-factory/cortaix-tui.txt new file mode 100644 index 000000000..e3c1852c9 --- /dev/null +++ b/frontend/public/appliances/cortaix-factory/cortaix-tui.txt @@ -0,0 +1,8 @@ + ██████ ██████ █████ ██████ ██████ ██████ ██ ██ + ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + ██ ██ ██ █████ ██ ██████ ██ ████ + ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + ██████ ██████ ██ ██ ██ ██ ██ ██████ ██ ██ + + Artificial Intelligence by THALES diff --git a/frontend/public/appliances/cortaix-factory/favicon.svg b/frontend/public/appliances/cortaix-factory/favicon.svg new file mode 100644 index 000000000..f53ac85f2 --- /dev/null +++ b/frontend/public/appliances/cortaix-factory/favicon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/appliances/cortaix-factory/fonts/nunitosans-700.woff2 b/frontend/public/appliances/cortaix-factory/fonts/nunitosans-700.woff2 new file mode 100644 index 000000000..a931cf985 Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/fonts/nunitosans-700.woff2 differ diff --git a/frontend/public/appliances/cortaix-factory/fonts/nunitosans-900.woff2 b/frontend/public/appliances/cortaix-factory/fonts/nunitosans-900.woff2 new file mode 100644 index 000000000..756faf02f Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/fonts/nunitosans-900.woff2 differ diff --git a/frontend/public/appliances/cortaix-factory/fonts/roboto-300.woff2 b/frontend/public/appliances/cortaix-factory/fonts/roboto-300.woff2 new file mode 100644 index 000000000..fb6526fa1 Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/fonts/roboto-300.woff2 differ diff --git a/frontend/public/appliances/cortaix-factory/fonts/roboto-400-italic.woff2 b/frontend/public/appliances/cortaix-factory/fonts/roboto-400-italic.woff2 new file mode 100644 index 000000000..04367cab9 Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/fonts/roboto-400-italic.woff2 differ diff --git a/frontend/public/appliances/cortaix-factory/fonts/roboto-400.woff2 b/frontend/public/appliances/cortaix-factory/fonts/roboto-400.woff2 new file mode 100644 index 000000000..77e42594c Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/fonts/roboto-400.woff2 differ diff --git a/frontend/public/appliances/cortaix-factory/fonts/roboto-500.woff2 b/frontend/public/appliances/cortaix-factory/fonts/roboto-500.woff2 new file mode 100644 index 000000000..171a2f6ab Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/fonts/roboto-500.woff2 differ diff --git a/frontend/public/appliances/cortaix-factory/fonts/roboto-700.woff2 b/frontend/public/appliances/cortaix-factory/fonts/roboto-700.woff2 new file mode 100644 index 000000000..85b8ace25 Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/fonts/roboto-700.woff2 differ diff --git a/frontend/public/appliances/cortaix-factory/fonts/robotomono-400.woff2 b/frontend/public/appliances/cortaix-factory/fonts/robotomono-400.woff2 new file mode 100644 index 000000000..6008f0241 Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/fonts/robotomono-400.woff2 differ diff --git a/frontend/public/appliances/cortaix-factory/fonts/robotomono-500.woff2 b/frontend/public/appliances/cortaix-factory/fonts/robotomono-500.woff2 new file mode 100644 index 000000000..5392e0374 Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/fonts/robotomono-500.woff2 differ diff --git a/frontend/public/appliances/cortaix-factory/fonts/robotomono-700.woff2 b/frontend/public/appliances/cortaix-factory/fonts/robotomono-700.woff2 new file mode 100644 index 000000000..10e1335a3 Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/fonts/robotomono-700.woff2 differ diff --git a/frontend/public/appliances/cortaix-factory/icons/apple-touch-icon-180.png b/frontend/public/appliances/cortaix-factory/icons/apple-touch-icon-180.png new file mode 100644 index 000000000..b447c9334 Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/icons/apple-touch-icon-180.png differ diff --git a/frontend/public/appliances/cortaix-factory/icons/favicon-16.png b/frontend/public/appliances/cortaix-factory/icons/favicon-16.png new file mode 100644 index 000000000..2f7d88e8d Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/icons/favicon-16.png differ diff --git a/frontend/public/appliances/cortaix-factory/icons/favicon-32.png b/frontend/public/appliances/cortaix-factory/icons/favicon-32.png new file mode 100644 index 000000000..b35d5934c Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/icons/favicon-32.png differ diff --git a/frontend/public/appliances/cortaix-factory/icons/icon-1024.png b/frontend/public/appliances/cortaix-factory/icons/icon-1024.png new file mode 100644 index 000000000..082a90e2b Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/icons/icon-1024.png differ diff --git a/frontend/public/appliances/cortaix-factory/icons/icon-128.png b/frontend/public/appliances/cortaix-factory/icons/icon-128.png new file mode 100644 index 000000000..67233fb8c Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/icons/icon-128.png differ diff --git a/frontend/public/appliances/cortaix-factory/icons/icon-16.png b/frontend/public/appliances/cortaix-factory/icons/icon-16.png new file mode 100644 index 000000000..9337358e1 Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/icons/icon-16.png differ diff --git a/frontend/public/appliances/cortaix-factory/icons/icon-180.png b/frontend/public/appliances/cortaix-factory/icons/icon-180.png new file mode 100644 index 000000000..b447c9334 Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/icons/icon-180.png differ diff --git a/frontend/public/appliances/cortaix-factory/icons/icon-192.png b/frontend/public/appliances/cortaix-factory/icons/icon-192.png new file mode 100644 index 000000000..973334ede Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/icons/icon-192.png differ diff --git a/frontend/public/appliances/cortaix-factory/icons/icon-256.png b/frontend/public/appliances/cortaix-factory/icons/icon-256.png new file mode 100644 index 000000000..3f4bbac26 Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/icons/icon-256.png differ diff --git a/frontend/public/appliances/cortaix-factory/icons/icon-32.png b/frontend/public/appliances/cortaix-factory/icons/icon-32.png new file mode 100644 index 000000000..e80221429 Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/icons/icon-32.png differ diff --git a/frontend/public/appliances/cortaix-factory/icons/icon-48.png b/frontend/public/appliances/cortaix-factory/icons/icon-48.png new file mode 100644 index 000000000..8f78e231f Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/icons/icon-48.png differ diff --git a/frontend/public/appliances/cortaix-factory/icons/icon-512.png b/frontend/public/appliances/cortaix-factory/icons/icon-512.png new file mode 100644 index 000000000..2388d04aa Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/icons/icon-512.png differ diff --git a/frontend/public/appliances/cortaix-factory/icons/icon-64.png b/frontend/public/appliances/cortaix-factory/icons/icon-64.png new file mode 100644 index 000000000..b2968af24 Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/icons/icon-64.png differ diff --git a/frontend/public/appliances/cortaix-factory/icons/maskable-512.png b/frontend/public/appliances/cortaix-factory/icons/maskable-512.png new file mode 100644 index 000000000..2b6bca84a Binary files /dev/null and b/frontend/public/appliances/cortaix-factory/icons/maskable-512.png differ diff --git a/frontend/public/appliances/cortaix-factory/site.webmanifest b/frontend/public/appliances/cortaix-factory/site.webmanifest new file mode 100644 index 000000000..6ee27a033 --- /dev/null +++ b/frontend/public/appliances/cortaix-factory/site.webmanifest @@ -0,0 +1,13 @@ +{ + "name": "cortAIx", + "short_name": "cortAIx", + "description": "cortAIx — Artificial Intelligence by Thales", + "theme_color": "#2B276D", + "background_color": "#1B1945", + "display": "standalone", + "icons": [ + { "src": "brand/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "brand/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "brand/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/frontend/public/appliances/cortaix-factory/surfaces/agent-chip.svg b/frontend/public/appliances/cortaix-factory/surfaces/agent-chip.svg new file mode 100644 index 000000000..bbf6f24ce --- /dev/null +++ b/frontend/public/appliances/cortaix-factory/surfaces/agent-chip.svg @@ -0,0 +1,21 @@ + + cortAIx Agent + + + + + + + + + + + + + + + + + AGENT + cortAIx + diff --git a/frontend/public/appliances/cortaix-factory/surfaces/gui-sidebar-mark.svg b/frontend/public/appliances/cortaix-factory/surfaces/gui-sidebar-mark.svg new file mode 100644 index 000000000..5014a9003 --- /dev/null +++ b/frontend/public/appliances/cortaix-factory/surfaces/gui-sidebar-mark.svg @@ -0,0 +1,21 @@ + + cortAIx Factory — GUI sidebar + + + + + + + + + + + + + + + + + cortAIx + + diff --git a/frontend/public/appliances/cortaix-factory/surfaces/mobile-homescreen.svg b/frontend/public/appliances/cortaix-factory/surfaces/mobile-homescreen.svg new file mode 100644 index 000000000..5e857c498 --- /dev/null +++ b/frontend/public/appliances/cortaix-factory/surfaces/mobile-homescreen.svg @@ -0,0 +1,22 @@ + + cortAIx — mobile home screen + + + + + + + + + + + + + + + + + + cortAIx + Factory + diff --git a/frontend/public/appliances/cortaix-factory/surfaces/plugin-tile.svg b/frontend/public/appliances/cortaix-factory/surfaces/plugin-tile.svg new file mode 100644 index 000000000..c7883e572 --- /dev/null +++ b/frontend/public/appliances/cortaix-factory/surfaces/plugin-tile.svg @@ -0,0 +1,22 @@ + + cortAIx Plugin + + + + + + + + + + + + + + + + + PLUGIN + cortAIx Factory + Thales · design system bridge + diff --git a/frontend/public/appliances/cortaix-factory/surfaces/skill-tile.svg b/frontend/public/appliances/cortaix-factory/surfaces/skill-tile.svg new file mode 100644 index 000000000..e8fac40ea --- /dev/null +++ b/frontend/public/appliances/cortaix-factory/surfaces/skill-tile.svg @@ -0,0 +1,24 @@ + + cortAIx Skill + + + + + + + + + + + + + + + + + SKILL + cortAIx Factory + Trusted-AI operator skill + + v2.0 + diff --git a/frontend/public/appliances/cortaix-factory/surfaces/vscode-activity.svg b/frontend/public/appliances/cortaix-factory/surfaces/vscode-activity.svg new file mode 100644 index 000000000..e45504446 --- /dev/null +++ b/frontend/public/appliances/cortaix-factory/surfaces/vscode-activity.svg @@ -0,0 +1,19 @@ + + cortAIx — VS Code activity bar + + + + + + + + + + + + + + + + + diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 15cbf97d8..987efe282 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1,10 +1,11 @@ -const CACHE_NAME = 'local-studio-v9'; +const CACHE_NAME = 'local-studio-v10'; const STATIC_ASSETS = [ '/', '/chat', '/recipes', '/logs', '/manifest.json', + '/manifest.webmanifest', ]; // Install event - cache static assets diff --git a/frontend/scripts/complete-standalone-build.mjs b/frontend/scripts/complete-standalone-build.mjs index 60194692e..8644b7bb9 100644 --- a/frontend/scripts/complete-standalone-build.mjs +++ b/frontend/scripts/complete-standalone-build.mjs @@ -61,6 +61,33 @@ for (const dependencyPath of runtimeDependencyPaths) { } } +const nestedRuntimeDependencies = [ + [ + "node_modules/@earendil-works/pi-ai", + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai", + ], + [ + "node_modules/typebox", + "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox", + ], +]; + +for (const [sourcePath, destinationPath] of nestedRuntimeDependencies) { + const destination = resolve(standaloneRoot, destinationPath); + if (existsSync(destination)) continue; + cpSync(resolve(projectRoot, sourcePath), destination, { recursive: true }); +} + +const piAiManifest = JSON.parse( + readFileSync(resolve(projectRoot, "node_modules/@earendil-works/pi-ai/package.json"), "utf8"), +); +for (const dependency of Object.keys(piAiManifest.dependencies ?? {})) { + const dependencyPath = resolve(projectRoot, "node_modules", dependency); + const destination = resolve(standaloneRoot, "node_modules", dependency); + if (existsSync(destination)) continue; + cpSync(dependencyPath, destination, { recursive: true }); +} + const tracedPiPackageDirectory = resolve(standaloneRoot, ".next/node_modules/@earendil-works"); if (existsSync(tracedPiPackageDirectory)) { const packageTargets = new Map([ diff --git a/frontend/scripts/link-services-node-modules.mjs b/frontend/scripts/link-services-node-modules.mjs index 9920e7235..5d5dd5d79 100644 --- a/frontend/scripts/link-services-node-modules.mjs +++ b/frontend/scripts/link-services-node-modules.mjs @@ -3,10 +3,13 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; const frontendDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); -const servicesDir = path.join(path.dirname(frontendDir), "services"); -const linkPath = path.join(servicesDir, "node_modules"); +const workspaceDir = path.dirname(frontendDir); +const linkPaths = [ + path.join(workspaceDir, "services", "node_modules"), + path.join(workspaceDir, "shared", "node_modules"), +]; -const existingEntryKind = () => { +const existingEntryKind = (linkPath) => { try { const stat = lstatSync(linkPath); if (stat.isSymbolicLink()) return "link"; @@ -16,25 +19,26 @@ const existingEntryKind = () => { } }; -const removeExistingEntry = () => { +const removeExistingEntry = (linkPath) => { rmSync(linkPath, { recursive: true, force: true }); }; -const createLink = () => { +const createLink = (linkPath) => { if (process.platform === "win32") { symlinkSync(path.join(frontendDir, "node_modules"), linkPath, "junction"); return; } - symlinkSync(path.join("..", "frontend", "node_modules"), linkPath, "dir"); + const relativeTarget = path.relative(path.dirname(linkPath), path.join(frontendDir, "node_modules")); + symlinkSync(relativeTarget, linkPath, "dir"); }; -mkdirSync(servicesDir, { recursive: true }); -const kind = existingEntryKind(); -if (kind === "directory") { - console.error( - `[link-services-node-modules] ${linkPath} is a real directory; leaving it alone.`, - ); - process.exit(0); +for (const linkPath of linkPaths) { + mkdirSync(path.dirname(linkPath), { recursive: true }); + const kind = existingEntryKind(linkPath); + if (kind === "directory") { + console.error(`[link-services-node-modules] ${linkPath} is a real directory; leaving it alone.`); + continue; + } + if (kind !== "missing") removeExistingEntry(linkPath); + createLink(linkPath); } -if (kind !== "missing") removeExistingEntry(); -createLink(); diff --git a/frontend/scripts/run-electron-builder.mjs b/frontend/scripts/run-electron-builder.mjs new file mode 100644 index 000000000..06d99d071 --- /dev/null +++ b/frontend/scripts/run-electron-builder.mjs @@ -0,0 +1,79 @@ +import { spawnSync } from "node:child_process"; +import process from "node:process"; +import { resolveApplianceProfile } from "../../shared/agent/appliance-profile.mjs"; + +function readValue(name, fallback) { + const value = process.env[name]; + if (typeof value !== "string") return fallback; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : fallback; +} + +const mode = process.argv[2] ?? "dist"; +const appliance = resolveApplianceProfile(); +const applianceId = appliance.applianceId; +const brandAppId = appliance.desktopAppId; +const brandAppName = appliance.appName; +const brandDevAppId = appliance.desktopDevAppId; +const brandDevAppName = appliance.desktopDevAppName; + +const baseArgs = [ + "--config", + "desktop/electron-builder.yml", + `--config.mac.icon=${appliance.desktopIconPath}`, +]; + +const macCategory = readValue("LOCAL_STUDIO_MAC_CATEGORY", ""); +const macIdentity = readValue("LOCAL_STUDIO_MAC_IDENTITY", ""); +if (macCategory) { + baseArgs.push(`--config.mac.category=${macCategory}`); +} +if (macIdentity) { + baseArgs.push(`--config.mac.identity=${macIdentity}`); +} + +const stableBrandArgs = [ + `--config.appId=${brandAppId}`, + `--config.productName=${brandAppName}`, + "--config.extraMetadata.localStudioChannel=stable", + `--config.extraMetadata.localStudioAppliance=${applianceId}`, + `--config.extraMetadata.localStudioBrandAppName=${brandAppName}`, + `--config.extraMetadata.localStudioBrandDevAppName=${brandDevAppName}`, +]; + +const modeArgsByName = { + dist: [...stableBrandArgs], + "dist-dev": [ + `--config.appId=${brandDevAppId}`, + `--config.productName=${brandDevAppName}`, + "--config.extraMetadata.localStudioChannel=dev", + `--config.extraMetadata.localStudioAppliance=${applianceId}`, + `--config.extraMetadata.localStudioBrandAppName=${brandAppName}`, + `--config.extraMetadata.localStudioBrandDevAppName=${brandDevAppName}`, + "--config.directories.output=dist-desktop-dev", + ], + "dist-notarized": [...stableBrandArgs, "--config.mac.notarize=true"], + pack: ["--dir", ...stableBrandArgs], +}; + +const modeArgs = modeArgsByName[mode]; +if (!modeArgs) { + console.error(`Unknown electron-builder mode: ${mode}`); + process.exit(1); +} + +const result = spawnSync("electron-builder", [...baseArgs, ...modeArgs, ...process.argv.slice(3)], { + env: { + ...process.env, + LOCAL_STUDIO_BRAND_APP_ID: brandAppId, + LOCAL_STUDIO_BRAND_APP_NAME: brandAppName, + }, + stdio: "inherit", + shell: process.platform === "win32", +}); + +if (typeof result.status === "number") { + process.exit(result.status); +} + +process.exit(1); diff --git a/frontend/scripts/validate-appliances.mjs b/frontend/scripts/validate-appliances.mjs new file mode 100644 index 000000000..3f20c7605 --- /dev/null +++ b/frontend/scripts/validate-appliances.mjs @@ -0,0 +1,92 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { APPLIANCE_PROFILES } from "../../shared/agent/appliance-profile.mjs"; + +const frontend = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const requiredFields = [ + "applianceId", + "appName", + "shortName", + "description", + "themeColor", + "iconSvgPath", + "logoLightPath", + "logoDarkPath", + "logoHighContrastPath", + "logoForcedColorsPath", + "icon192Path", + "icon512Path", + "appleTouchIconPath", + "desktopAppId", + "desktopDevAppId", + "desktopDevAppName", + "desktopIconPath", + "defaultThemeId", + "fontFamily", + "fontMonoFamily", + "handlingLevel", + "classificationCode", + "classificationLabel", +]; + +for (const [id, profile] of Object.entries(APPLIANCE_PROFILES)) { + if (profile.applianceId !== id) throw new Error(`${id} applianceId does not match its key`); + for (const field of requiredFields) { + if (typeof profile[field] !== "string" || profile[field].trim().length === 0) { + throw new Error(`${id}.${field} must be a non-empty string`); + } + } + for (const assetPath of [ + profile.iconSvgPath, + profile.logoLightPath, + profile.logoDarkPath, + profile.logoHighContrastPath, + profile.logoForcedColorsPath, + profile.icon192Path, + profile.icon512Path, + profile.appleTouchIconPath, + ]) { + const file = path.join(frontend, "public", assetPath.replace(/^\/+/, "")); + if (!existsSync(file)) throw new Error(`${id} web asset is missing: ${file}`); + } + const desktopIcon = path.join(frontend, profile.desktopIconPath); + if (!existsSync(desktopIcon)) throw new Error(`${id} desktop icon is missing: ${desktopIcon}`); +} + +const cortaix = APPLIANCE_PROFILES["cortaix-factory"]; +if (cortaix.defaultThemeId !== "cortaix-dark") { + throw new Error("cortAIx Factory must default to cortaix-dark"); +} +if ( + cortaix.allowedThemeIds.length !== 2 || + !cortaix.allowedThemeIds.includes("cortaix-light") || + !cortaix.allowedThemeIds.includes("cortaix-dark") +) { + throw new Error("cortAIx Factory must expose exactly its light and dark themes"); +} + +const tokens = readFileSync(path.join(frontend, "src/app/styles/globals/tokens.css"), "utf8"); +for (const token of ["--proof", "--emergency", "--signal", "--signal-bright", "--signal-deep"]) { + if (!tokens.includes(token)) throw new Error(`cortAIx semantic token is missing: ${token}`); +} + +const shell = [ + "left-sidebar.tsx", + "authority-footer.tsx", + "appliance-brand-mark.tsx", +] + .map((file) => readFileSync(path.join(frontend, "src/features/shell", file), "utf8")) + .join("\n"); +for (const requirement of [ + "Skip to content", + 'role="contentinfo"', + 'data-handling-origin="derived"', + "mode changes deployment, not governance semantics", + 'id="main-content"', +]) { + if (!shell.includes(requirement)) + throw new Error(`cortAIx shell requirement is missing: ${requirement}`); +} + +console.log(`Validated ${Object.keys(APPLIANCE_PROFILES).length} appliance profiles`); diff --git a/frontend/src/app/api/agent/abort/route.ts b/frontend/src/app/api/agent/abort/route.ts index cf06108fa..1c7d0c7b1 100644 --- a/frontend/src/app/api/agent/abort/route.ts +++ b/frontend/src/app/api/agent/abort/route.ts @@ -6,7 +6,7 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function POST(request: NextRequest): Promise { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; return proxyToAgentRuntime(request); } diff --git a/frontend/src/app/api/agent/access-fabric/access-fabric-routes.test.ts b/frontend/src/app/api/agent/access-fabric/access-fabric-routes.test.ts new file mode 100644 index 000000000..b48fbad63 --- /dev/null +++ b/frontend/src/app/api/agent/access-fabric/access-fabric-routes.test.ts @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { NextRequest } from "next/server"; +import { GET, PUT } from "./route"; +import { POST as probe } from "./probe/route"; +import { POST as plan } from "./plan/route"; +import { POST as apply, DELETE as offboard } from "./apply/route"; + +let directory = ""; +const original = { + dataDir: process.env.LOCAL_STUDIO_DATA_DIR, + nodeEnv: process.env.NODE_ENV, + token: process.env.LOCAL_STUDIO_FRONTEND_TOKEN, + appliance: process.env.LOCAL_STUDIO_APPLIANCE, + hosts: process.env.LOCAL_STUDIO_ACCESS_FABRIC_HOSTS, +}; + +const request = (pathname: string, method = "GET", body?: unknown, headers?: HeadersInit) => + new NextRequest(`http://localhost${pathname}`, { + method, + headers: { ...(body === undefined ? {} : { "Content-Type": "application/json" }), ...headers }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + +const profile = () => ({ + version: 1, + classification: "C2", + machine: { id: "tensorprime", sshTarget: "scientist@tensorprime" }, + netbird: { + enabled: true, + managementUrl: "https://api.netbird.io", + sourceGroupId: "grp_scientists", + machineGroupId: "grp_tensorprime", + ports: [22], + credentialRef: "vault:access:netbird", + }, + boundary: { + enabled: false, + controllerUrl: "", + scopeId: "", + targetIds: [], + sessionMaxSeconds: 3600, + credentialRef: "vault:access:boundary", + }, + updatedAt: "2026-07-28T00:00:00.000Z", +}); + +beforeEach(async () => { + directory = await mkdtemp(path.join(tmpdir(), "access-fabric-routes-")); + process.env.LOCAL_STUDIO_APPLIANCE = "cortaix-factory"; + process.env.LOCAL_STUDIO_ACCESS_FABRIC_HOSTS = ""; +}); + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }); + if (original.dataDir === undefined) delete process.env.LOCAL_STUDIO_DATA_DIR; + else process.env.LOCAL_STUDIO_DATA_DIR = original.dataDir; + if (original.nodeEnv === undefined) Reflect.deleteProperty(process.env, "NODE_ENV"); + else Reflect.set(process.env, "NODE_ENV", original.nodeEnv); + if (original.token === undefined) delete process.env.LOCAL_STUDIO_FRONTEND_TOKEN; + else process.env.LOCAL_STUDIO_FRONTEND_TOKEN = original.token; + if (original.appliance === undefined) delete process.env.LOCAL_STUDIO_APPLIANCE; + else process.env.LOCAL_STUDIO_APPLIANCE = original.appliance; + if (original.hosts === undefined) delete process.env.LOCAL_STUDIO_ACCESS_FABRIC_HOSTS; + else process.env.LOCAL_STUDIO_ACCESS_FABRIC_HOSTS = original.hosts; +}); + +describe("access fabric authenticated routes", () => { + it("rejects a shared production deployment without OIDC", async () => { + delete process.env.LOCAL_STUDIO_DATA_DIR; + Reflect.set(process.env, "NODE_ENV", "production"); + process.env.LOCAL_STUDIO_FRONTEND_TOKEN = "route-token"; + const response = await GET(request("/api/agent/access-fabric")); + assert.equal(response.status, 503); + }); + + it("validates input and persists redacted state across route calls", async () => { + process.env.LOCAL_STUDIO_DATA_DIR = directory; + const malformed = await PUT( + request("/api/agent/access-fabric", "PUT", { profile: { classification: "C2" } }), + ); + assert.equal(malformed.status, 400); + const saved = await PUT( + request("/api/agent/access-fabric", "PUT", { profile: profile(), credentials: [] }), + ); + assert.equal(saved.status, 200); + const loaded = await GET(request("/api/agent/access-fabric")); + const body = await loaded.text(); + assert.equal(loaded.status, 200); + assert.equal(body.includes("tensorprime"), true); + assert.equal(body.includes("credentialRef"), true); + assert.equal(body.includes("route-token"), false); + }); + + it("fails closed through probe, plan, apply, and leaves empty offboard idempotent", async () => { + process.env.LOCAL_STUDIO_DATA_DIR = directory; + await PUT(request("/api/agent/access-fabric", "PUT", { profile: profile() })); + assert.equal( + (await probe(request("/api/agent/access-fabric/probe", "POST", { target: "netbird" }))) + .status, + 401, + ); + assert.equal((await plan(request("/api/agent/access-fabric/plan", "POST"))).status, 409); + assert.equal((await apply(request("/api/agent/access-fabric/apply", "POST"))).status, 409); + assert.equal((await offboard(request("/api/agent/access-fabric/apply", "DELETE"))).status, 200); + }); + + it("runs plan, apply, persisted receipt, and offboard through the route boundary", async () => { + process.env.LOCAL_STUDIO_DATA_DIR = directory; + const localProfile = profile(); + localProfile.netbird.enabled = false; + await PUT(request("/api/agent/access-fabric", "PUT", { profile: localProfile })); + assert.equal((await plan(request("/api/agent/access-fabric/plan", "POST"))).status, 200); + const applied = await apply(request("/api/agent/access-fabric/apply", "POST")); + assert.equal(applied.status, 200); + assert.notEqual((await applied.json()).receipt, null); + const persisted = await GET(request("/api/agent/access-fabric")); + assert.notEqual((await persisted.json()).receipt, null); + assert.equal((await offboard(request("/api/agent/access-fabric/apply", "DELETE"))).status, 200); + const cleared = await GET(request("/api/agent/access-fabric")); + assert.equal((await cleared.json()).receipt, null); + }); +}); diff --git a/frontend/src/app/api/agent/access-fabric/apply/route.ts b/frontend/src/app/api/agent/access-fabric/apply/route.ts new file mode 100644 index 000000000..b42c001aa --- /dev/null +++ b/frontend/src/app/api/agent/access-fabric/apply/route.ts @@ -0,0 +1,32 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { Effect } from "effect"; +import { httpAccessFabricTransport } from "@local-studio/agent-runtime/access-fabric-http"; +import { + AccessFabricError, + applyAccessFabric, + offboardAccessFabric, +} from "@local-studio/agent-runtime/access-fabric-service"; +import { requireApiAccess } from "@/lib/auth/guard"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const run = async (request: NextRequest, operation: "apply" | "offboard") => { + const denied = await requireApiAccess(request); + if (denied) return denied; + try { + const effect = + operation === "apply" + ? applyAccessFabric(httpAccessFabricTransport) + : offboardAccessFabric(httpAccessFabricTransport); + return NextResponse.json(await Effect.runPromise(effect)); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Access fabric operation failed" }, + { status: error instanceof AccessFabricError ? error.status : 500 }, + ); + } +}; + +export const POST = (request: NextRequest) => run(request, "apply"); +export const DELETE = (request: NextRequest) => run(request, "offboard"); diff --git a/frontend/src/app/api/agent/access-fabric/plan/route.ts b/frontend/src/app/api/agent/access-fabric/plan/route.ts new file mode 100644 index 000000000..f6c2ad615 --- /dev/null +++ b/frontend/src/app/api/agent/access-fabric/plan/route.ts @@ -0,0 +1,23 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { Effect } from "effect"; +import { + AccessFabricError, + planAccessFabric, +} from "@local-studio/agent-runtime/access-fabric-service"; +import { requireApiAccess } from "@/lib/auth/guard"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: NextRequest) { + const denied = await requireApiAccess(request); + if (denied) return denied; + try { + return NextResponse.json(await Effect.runPromise(planAccessFabric())); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Access fabric plan failed" }, + { status: error instanceof AccessFabricError ? error.status : 500 }, + ); + } +} diff --git a/frontend/src/app/api/agent/access-fabric/probe/route.ts b/frontend/src/app/api/agent/access-fabric/probe/route.ts new file mode 100644 index 000000000..b6d40833d --- /dev/null +++ b/frontend/src/app/api/agent/access-fabric/probe/route.ts @@ -0,0 +1,28 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { Effect, Schema } from "effect"; +import { AccessFabricProbeInputSchema } from "@local-studio/agent-runtime/access-fabric-contract"; +import { httpAccessFabricTransport } from "@local-studio/agent-runtime/access-fabric-http"; +import { + AccessFabricError, + probeAccessFabric, +} from "@local-studio/agent-runtime/access-fabric-service"; +import { requireApiAccess } from "@/lib/auth/guard"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: NextRequest) { + const denied = await requireApiAccess(request); + if (denied) return denied; + try { + const { target } = Schema.decodeUnknownSync(AccessFabricProbeInputSchema)(await request.json()); + return NextResponse.json( + await Effect.runPromise(probeAccessFabric(target, httpAccessFabricTransport)), + ); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Access fabric probe failed" }, + { status: error instanceof AccessFabricError ? error.status : 400 }, + ); + } +} diff --git a/frontend/src/app/api/agent/access-fabric/route.ts b/frontend/src/app/api/agent/access-fabric/route.ts new file mode 100644 index 000000000..73bf606ae --- /dev/null +++ b/frontend/src/app/api/agent/access-fabric/route.ts @@ -0,0 +1,39 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { Effect, Schema } from "effect"; +import { AccessFabricSaveSchema } from "@local-studio/agent-runtime/access-fabric-contract"; +import { + AccessFabricError, + getAccessFabricState, + saveAccessFabric, +} from "@local-studio/agent-runtime/access-fabric-service"; +import { requireApiAccess } from "@/lib/auth/guard"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const failure = (error: unknown, fallbackStatus = 500) => + NextResponse.json( + { error: error instanceof Error ? error.message : "Access fabric request failed" }, + { status: error instanceof AccessFabricError ? error.status : fallbackStatus }, + ); + +export async function GET(request: NextRequest) { + const denied = await requireApiAccess(request); + if (denied) return denied; + try { + return NextResponse.json(await Effect.runPromise(getAccessFabricState())); + } catch (error) { + return failure(error); + } +} + +export async function PUT(request: NextRequest) { + const denied = await requireApiAccess(request); + if (denied) return denied; + try { + const input = Schema.decodeUnknownSync(AccessFabricSaveSchema)(await request.json()); + return NextResponse.json(await Effect.runPromise(saveAccessFabric(input))); + } catch (error) { + return failure(error, 400); + } +} diff --git a/frontend/src/app/api/agent/access-fabric/sessions/cancel/route.ts b/frontend/src/app/api/agent/access-fabric/sessions/cancel/route.ts new file mode 100644 index 000000000..f6b71ce3b --- /dev/null +++ b/frontend/src/app/api/agent/access-fabric/sessions/cancel/route.ts @@ -0,0 +1,32 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { Effect, Schema } from "effect"; +import { AccessFabricCancelSessionSchema } from "@local-studio/agent-runtime/access-fabric-contract"; +import { httpAccessFabricTransport } from "@local-studio/agent-runtime/access-fabric-http"; +import { + AccessFabricError, + cancelAccessFabricBoundarySession, +} from "@local-studio/agent-runtime/access-fabric-service"; +import { requireApiAccess } from "@/lib/auth/guard"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: NextRequest) { + const denied = await requireApiAccess(request); + if (denied) return denied; + try { + const { sessionId } = Schema.decodeUnknownSync(AccessFabricCancelSessionSchema)( + await request.json(), + ); + return NextResponse.json( + await Effect.runPromise( + cancelAccessFabricBoundarySession(sessionId, httpAccessFabricTransport), + ), + ); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Boundary session cancellation failed" }, + { status: error instanceof AccessFabricError ? error.status : 400 }, + ); + } +} diff --git a/frontend/src/app/api/agent/accounts/google/authorize/route.ts b/frontend/src/app/api/agent/accounts/google/authorize/route.ts index be85c04a1..314c1c9c9 100644 --- a/frontend/src/app/api/agent/accounts/google/authorize/route.ts +++ b/frontend/src/app/api/agent/accounts/google/authorize/route.ts @@ -15,7 +15,7 @@ const GoogleAccountInputSchema = Schema.Struct({ }); export async function POST(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; let input: typeof GoogleAccountInputSchema.Type; try { @@ -37,7 +37,7 @@ export async function POST(request: NextRequest) { } export async function DELETE(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; let input: typeof GoogleAccountInputSchema.Type; try { diff --git a/frontend/src/app/api/agent/accounts/google/route.ts b/frontend/src/app/api/agent/accounts/google/route.ts index 86187eb36..e75940008 100644 --- a/frontend/src/app/api/agent/accounts/google/route.ts +++ b/frontend/src/app/api/agent/accounts/google/route.ts @@ -41,7 +41,7 @@ function closeGoogleConnections(): void { } export async function GET(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; try { return NextResponse.json({ account: await Effect.runPromise(getGoogleAccount()) }); @@ -51,7 +51,7 @@ export async function GET(request: NextRequest) { } export async function PUT(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; let input: typeof GoogleClientInputSchema.Type; try { @@ -70,7 +70,7 @@ export async function PUT(request: NextRequest) { } export async function DELETE(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; let input: typeof GoogleAccountInputSchema.Type; try { diff --git a/frontend/src/app/api/agent/automations/[id]/route.ts b/frontend/src/app/api/agent/automations/[id]/route.ts index a71c8080a..0dbb36fd0 100644 --- a/frontend/src/app/api/agent/automations/[id]/route.ts +++ b/frontend/src/app/api/agent/automations/[id]/route.ts @@ -6,13 +6,13 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function PATCH(request: NextRequest): Promise { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; return proxyToAgentRuntime(request); } export async function DELETE(request: NextRequest): Promise { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; return proxyToAgentRuntime(request); } diff --git a/frontend/src/app/api/agent/automations/[id]/run/route.ts b/frontend/src/app/api/agent/automations/[id]/run/route.ts index cf06108fa..1c7d0c7b1 100644 --- a/frontend/src/app/api/agent/automations/[id]/run/route.ts +++ b/frontend/src/app/api/agent/automations/[id]/run/route.ts @@ -6,7 +6,7 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function POST(request: NextRequest): Promise { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; return proxyToAgentRuntime(request); } diff --git a/frontend/src/app/api/agent/automations/route.ts b/frontend/src/app/api/agent/automations/route.ts index 2b86d9f1d..a37ab230f 100644 --- a/frontend/src/app/api/agent/automations/route.ts +++ b/frontend/src/app/api/agent/automations/route.ts @@ -6,13 +6,13 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET(request: NextRequest): Promise { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; return proxyToAgentRuntime(request); } export async function POST(request: NextRequest): Promise { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; return proxyToAgentRuntime(request); } diff --git a/frontend/src/app/api/agent/comments/route.ts b/frontend/src/app/api/agent/comments/route.ts index 3e33be8c5..3c4ec77f5 100644 --- a/frontend/src/app/api/agent/comments/route.ts +++ b/frontend/src/app/api/agent/comments/route.ts @@ -8,7 +8,7 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; const cwd = request.nextUrl.searchParams.get("cwd")?.trim() ?? ""; const rel = request.nextUrl.searchParams.get("path")?.trim() ?? ""; @@ -26,7 +26,7 @@ export async function GET(request: NextRequest) { } export async function POST(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; let body: { cwd?: string; path?: string; line?: number; body?: string }; try { @@ -53,7 +53,7 @@ export async function POST(request: NextRequest) { } export async function DELETE(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; const cwd = request.nextUrl.searchParams.get("cwd")?.trim() ?? ""; const rel = request.nextUrl.searchParams.get("path")?.trim() ?? ""; diff --git a/frontend/src/app/api/agent/compact/route.ts b/frontend/src/app/api/agent/compact/route.ts index cf06108fa..1c7d0c7b1 100644 --- a/frontend/src/app/api/agent/compact/route.ts +++ b/frontend/src/app/api/agent/compact/route.ts @@ -6,7 +6,7 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function POST(request: NextRequest): Promise { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; return proxyToAgentRuntime(request); } diff --git a/frontend/src/app/api/agent/connectors/call/route.ts b/frontend/src/app/api/agent/connectors/call/route.ts index 04bb2398d..93b0b4f05 100644 --- a/frontend/src/app/api/agent/connectors/call/route.ts +++ b/frontend/src/app/api/agent/connectors/call/route.ts @@ -19,7 +19,7 @@ const ConnectorToolCallSchema = Schema.Struct({ }); export async function GET(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; await Effect.runPromise(refreshEnabledPluginConnectors()); const connectors = await enabledConnectors(); @@ -42,7 +42,7 @@ export async function GET(request: NextRequest) { } export async function POST(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; let body: typeof ConnectorToolCallSchema.Type; try { diff --git a/frontend/src/app/api/agent/connectors/route.ts b/frontend/src/app/api/agent/connectors/route.ts index 10a06e94d..ed8712968 100644 --- a/frontend/src/app/api/agent/connectors/route.ts +++ b/frontend/src/app/api/agent/connectors/route.ts @@ -16,14 +16,14 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; const connectors = await listConnectors(); return NextResponse.json({ connectors: connectors.map(toConnectorView) }); } export async function POST(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; let body: typeof ConnectorUpsertInputSchema.Type; try { @@ -66,7 +66,7 @@ export async function POST(request: NextRequest) { } export async function DELETE(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; const id = request.nextUrl.searchParams.get("id") ?? ""; if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 }); diff --git a/frontend/src/app/api/agent/connectors/ssh-server-path/route.ts b/frontend/src/app/api/agent/connectors/ssh-server-path/route.ts index 18fd466cf..39dfc1cb0 100644 --- a/frontend/src/app/api/agent/connectors/ssh-server-path/route.ts +++ b/frontend/src/app/api/agent/connectors/ssh-server-path/route.ts @@ -6,7 +6,7 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; return NextResponse.json({ path: resolveBundledMcpServerPath("ssh-remote.mjs") }); } diff --git a/frontend/src/app/api/agent/connectors/test/route.ts b/frontend/src/app/api/agent/connectors/test/route.ts index ab79fb292..a7bd4bec7 100644 --- a/frontend/src/app/api/agent/connectors/test/route.ts +++ b/frontend/src/app/api/agent/connectors/test/route.ts @@ -9,7 +9,7 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function POST(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; let body: typeof ConnectorTestInputSchema.Type; try { diff --git a/frontend/src/app/api/agent/fs/file/route.ts b/frontend/src/app/api/agent/fs/file/route.ts index 8ab0d5abc..3b4171dcd 100644 --- a/frontend/src/app/api/agent/fs/file/route.ts +++ b/frontend/src/app/api/agent/fs/file/route.ts @@ -8,7 +8,7 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; const cwd = request.nextUrl.searchParams.get("cwd")?.trim() ?? ""; const relPath = request.nextUrl.searchParams.get("path")?.trim() ?? ""; @@ -27,7 +27,7 @@ export async function GET(request: NextRequest) { } export async function PUT(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; const cwd = request.nextUrl.searchParams.get("cwd")?.trim() ?? ""; const relPath = request.nextUrl.searchParams.get("path")?.trim() ?? ""; diff --git a/frontend/src/app/api/agent/git/route.ts b/frontend/src/app/api/agent/git/route.ts index ccd4d5995..146074df9 100644 --- a/frontend/src/app/api/agent/git/route.ts +++ b/frontend/src/app/api/agent/git/route.ts @@ -8,7 +8,7 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; const { cwd, error } = assertGitCwd(request.nextUrl.searchParams.get("cwd")); if (error) return error; @@ -20,7 +20,7 @@ export async function GET(request: NextRequest) { } export async function POST(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; const { cwd, error } = assertGitCwd(request.nextUrl.searchParams.get("cwd")); if (error) return error; diff --git a/frontend/src/app/api/agent/goal/route.ts b/frontend/src/app/api/agent/goal/route.ts index 4ca8abd48..cee08d682 100644 --- a/frontend/src/app/api/agent/goal/route.ts +++ b/frontend/src/app/api/agent/goal/route.ts @@ -6,19 +6,19 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET(request: NextRequest): Promise { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; return proxyToAgentRuntime(request); } export async function PUT(request: NextRequest): Promise { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; return proxyToAgentRuntime(request); } export async function DELETE(request: NextRequest): Promise { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; return proxyToAgentRuntime(request); } diff --git a/frontend/src/app/api/agent/lifecycle/apply/route.ts b/frontend/src/app/api/agent/lifecycle/apply/route.ts new file mode 100644 index 000000000..32da9cfff --- /dev/null +++ b/frontend/src/app/api/agent/lifecycle/apply/route.ts @@ -0,0 +1,14 @@ +import type { NextRequest } from "next/server"; +import { requireApiAccess } from "@/lib/auth/guard"; +import { proxyAgentLifecycle } from "../proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: NextRequest) { + return (await requireApiAccess(request)) ?? proxyAgentLifecycle(request); +} + +export async function DELETE(request: NextRequest) { + return (await requireApiAccess(request)) ?? proxyAgentLifecycle(request); +} diff --git a/frontend/src/app/api/agent/lifecycle/lifecycle-proxy.test.ts b/frontend/src/app/api/agent/lifecycle/lifecycle-proxy.test.ts new file mode 100644 index 000000000..91edec3c9 --- /dev/null +++ b/frontend/src/app/api/agent/lifecycle/lifecycle-proxy.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; +import { NextRequest } from "next/server"; +import { proxyAgentLifecycle } from "./proxy"; +import { PUT as plan } from "./plan/route"; + +const originalToken = process.env.LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN; +const originalRuntimeUrl = process.env.LOCAL_STUDIO_AGENT_RUNTIME_URL; +const originalFetch = globalThis.fetch; +const originalDataDir = process.env.LOCAL_STUDIO_DATA_DIR; +const originalFrontendBase = process.env.LOCAL_STUDIO_FRONTEND_BASE; + +afterEach(() => { + if (originalToken === undefined) delete process.env.LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN; + else process.env.LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN = originalToken; + if (originalRuntimeUrl === undefined) delete process.env.LOCAL_STUDIO_AGENT_RUNTIME_URL; + else process.env.LOCAL_STUDIO_AGENT_RUNTIME_URL = originalRuntimeUrl; + globalThis.fetch = originalFetch; + if (originalDataDir === undefined) delete process.env.LOCAL_STUDIO_DATA_DIR; + else process.env.LOCAL_STUDIO_DATA_DIR = originalDataDir; + if (originalFrontendBase === undefined) delete process.env.LOCAL_STUDIO_FRONTEND_BASE; + else process.env.LOCAL_STUDIO_FRONTEND_BASE = originalFrontendBase; +}); + +describe("agent lifecycle server credential proxy", () => { + it("fails closed without a configured lifecycle credential", async () => { + delete process.env.LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN; + const response = await proxyAgentLifecycle( + new Request("http://localhost/api/agent/lifecycle", { + headers: { authorization: "Bearer browser-value" }, + }), + ); + assert.equal(response.status, 503); + }); + + it("removes browser authorization and injects only the server credential", async () => { + process.env.LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN = "server-only-value"; + process.env.LOCAL_STUDIO_AGENT_RUNTIME_URL = "http://127.0.0.1:18081"; + let authorization = ""; + globalThis.fetch = async (_input, init) => { + authorization = new Headers(init?.headers).get("authorization") ?? ""; + return Response.json({ ok: true }); + }; + const response = await proxyAgentLifecycle( + new Request("http://localhost/api/agent/lifecycle", { + headers: { authorization: "Bearer browser-value" }, + }), + ); + assert.equal(response.status, 200); + assert.equal(authorization, "Bearer server-only-value"); + assert.equal(await response.text(), '{"ok":true}'); + }); + + it("binds plan locality and endpoint to trusted server state", async () => { + process.env.LOCAL_STUDIO_DATA_DIR = "/tmp/local-studio-lifecycle-test"; + process.env.LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN = "server-only-value"; + process.env.LOCAL_STUDIO_FRONTEND_BASE = "http://127.0.0.1:3000"; + let upstream: unknown; + globalThis.fetch = async (_input, init) => { + upstream = JSON.parse( + new TextDecoder().decode( + init?.body instanceof ArrayBuffer + ? init.body + : new TextEncoder().encode(String(init?.body)), + ), + ); + return Response.json({ + version: 1, + profile: null, + receipt: null, + recovery: null, + updatedAt: new Date(0).toISOString(), + }); + }; + const response = await plan( + new NextRequest("http://attacker.invalid/api/agent/lifecycle/plan", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ profile: { version: 1 } }), + }), + ); + assert.equal(response.status, 200); + assert.equal( + Reflect.get(Reflect.get(upstream as object, "locality"), "inferenceEndpoint"), + "http://127.0.0.1:3000/api/agent/onboarding/inference/v1", + ); + assert.equal( + Reflect.get(Reflect.get(upstream as object, "locality"), "executionHome"), + process.env.HOME, + ); + }); + + it("fails closed when the trusted frontend base is absent", async () => { + process.env.LOCAL_STUDIO_DATA_DIR = "/tmp/local-studio-lifecycle-test"; + process.env.LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN = "server-only-value"; + delete process.env.LOCAL_STUDIO_FRONTEND_BASE; + const response = await plan( + new NextRequest("http://localhost/api/agent/lifecycle/plan", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ profile: { version: 1 } }), + }), + ); + assert.equal(response.status, 503); + }); +}); diff --git a/frontend/src/app/api/agent/lifecycle/plan/route.ts b/frontend/src/app/api/agent/lifecycle/plan/route.ts new file mode 100644 index 000000000..a40f8f09a --- /dev/null +++ b/frontend/src/app/api/agent/lifecycle/plan/route.ts @@ -0,0 +1,60 @@ +import type { NextRequest } from "next/server"; +import os from "node:os"; +import { readJsonRequestWithinLimit } from "@shared/agent/agent-turn-body"; +import { requireApiAccess } from "@/lib/auth/guard"; +import { proxyAgentLifecycle } from "../proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const inferenceEndpoint = () => { + const raw = process.env.LOCAL_STUDIO_FRONTEND_BASE?.trim(); + if (!raw) throw new Error("Frontend base URL is not configured"); + const endpoint = new URL(raw); + const loopback = ["127.0.0.1", "::1", "localhost"].includes(endpoint.hostname); + if ( + !["http:", "https:"].includes(endpoint.protocol) || + (endpoint.protocol === "http:" && !loopback) || + endpoint.username || + endpoint.password || + endpoint.search || + endpoint.hash + ) { + throw new Error("Frontend base URL is invalid"); + } + return `${endpoint.toString().replace(/\/+$/, "")}/api/agent/onboarding/inference/v1`; +}; + +export async function PUT(request: NextRequest) { + const denied = await requireApiAccess(request); + if (denied) return denied; + const decoded = await readJsonRequestWithinLimit(request, 1024 * 1024); + if (!decoded.ok) return Response.json({ error: decoded.error }, { status: decoded.status }); + const body = decoded.value; + const profile = + body && typeof body === "object" && "profile" in body ? Reflect.get(body, "profile") : body; + let endpoint: string; + try { + endpoint = inferenceEndpoint(); + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : "Frontend base URL is invalid" }, + { status: 503 }, + ); + } + const upstream = new Request(request.url, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + profile, + locality: { + machineId: "local-host", + accessProfileId: "local-loopback", + executionHome: os.homedir(), + inferenceEndpoint: endpoint, + credentialRef: "keyring:runtime:inference", + }, + }), + }); + return proxyAgentLifecycle(upstream, 1024 * 1024); +} diff --git a/frontend/src/app/api/agent/lifecycle/proxy.ts b/frontend/src/app/api/agent/lifecycle/proxy.ts new file mode 100644 index 000000000..ddc0f4c08 --- /dev/null +++ b/frontend/src/app/api/agent/lifecycle/proxy.ts @@ -0,0 +1,12 @@ +import { proxyToAgentRuntime } from "@/app/api/agent/proxy-to-runtime"; + +export const proxyAgentLifecycle = (request: Request, bodyLimitBytes?: number) => { + const token = process.env.LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN?.trim(); + if (!token) { + return Response.json({ error: "Agent lifecycle API is not configured" }, { status: 503 }); + } + return proxyToAgentRuntime(request, { + authorization: `Bearer ${token}`, + ...(bodyLimitBytes ? { bodyLimitBytes } : {}), + }); +}; diff --git a/frontend/src/app/api/agent/lifecycle/recover/route.ts b/frontend/src/app/api/agent/lifecycle/recover/route.ts new file mode 100644 index 000000000..fd7016333 --- /dev/null +++ b/frontend/src/app/api/agent/lifecycle/recover/route.ts @@ -0,0 +1,10 @@ +import type { NextRequest } from "next/server"; +import { requireApiAccess } from "@/lib/auth/guard"; +import { proxyAgentLifecycle } from "../proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: NextRequest) { + return (await requireApiAccess(request)) ?? proxyAgentLifecycle(request); +} diff --git a/frontend/src/app/api/agent/lifecycle/route.ts b/frontend/src/app/api/agent/lifecycle/route.ts new file mode 100644 index 000000000..d7fc1c617 --- /dev/null +++ b/frontend/src/app/api/agent/lifecycle/route.ts @@ -0,0 +1,10 @@ +import type { NextRequest } from "next/server"; +import { requireApiAccess } from "@/lib/auth/guard"; +import { proxyAgentLifecycle } from "./proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + return (await requireApiAccess(request)) ?? proxyAgentLifecycle(request); +} diff --git a/frontend/src/app/api/agent/onboarding/apply/route.ts b/frontend/src/app/api/agent/onboarding/apply/route.ts new file mode 100644 index 000000000..5c0415f74 --- /dev/null +++ b/frontend/src/app/api/agent/onboarding/apply/route.ts @@ -0,0 +1,14 @@ +import type { NextRequest } from "next/server"; +import { requireApiAccess } from "@/lib/auth/guard"; +import { proxyAgentOnboarding } from "../proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: NextRequest) { + return (await requireApiAccess(request)) ?? proxyAgentOnboarding(request); +} + +export async function DELETE(request: NextRequest) { + return (await requireApiAccess(request)) ?? proxyAgentOnboarding(request); +} diff --git a/frontend/src/app/api/agent/onboarding/dev-stack-auth.test.ts b/frontend/src/app/api/agent/onboarding/dev-stack-auth.test.ts new file mode 100644 index 000000000..aa11d9905 --- /dev/null +++ b/frontend/src/app/api/agent/onboarding/dev-stack-auth.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { describe, test } from "node:test"; + +const devScript = readFileSync( + new URL("../../../../../../scripts/dev.sh", import.meta.url), + "utf8", +); + +describe("full development stack onboarding authority", () => { + test("shares one ephemeral internal token with frontend and runtime", () => { + assert.match(devScript, /randomBytes\(32\)\.toString\("base64url"\)/); + assert.match(devScript, /export LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN="\$STACK_AGENT_TOKEN"/); + assert.match(devScript, /export LOCAL_STUDIO_PROVISIONING_TOKEN="\$STACK_AGENT_TOKEN"/); + assert.doesNotMatch(devScript, /printf.*STACK_AGENT_TOKEN/); + }); +}); diff --git a/frontend/src/app/api/agent/onboarding/inference/[...path]/route.ts b/frontend/src/app/api/agent/onboarding/inference/[...path]/route.ts new file mode 100644 index 000000000..de4bdb87e --- /dev/null +++ b/frontend/src/app/api/agent/onboarding/inference/[...path]/route.ts @@ -0,0 +1,21 @@ +import type { NextRequest } from "next/server"; +import { requireApiAccess } from "@/lib/auth/guard"; +import { proxyAgentOnboarding } from "../../proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type RouteContext = { params: Promise<{ path: string[] }> }; + +async function handle(request: NextRequest, context: RouteContext) { + const denied = await requireApiAccess(request); + if (denied) return denied; + const { path } = await context.params; + const upstreamPath = `/api/agent/onboarding/inference/${path + .map((segment) => encodeURIComponent(segment)) + .join("/")}`; + return proxyAgentOnboarding(request, upstreamPath, 4 * 1024 * 1024); +} + +export const GET = handle; +export const POST = handle; diff --git a/frontend/src/app/api/agent/onboarding/onboarding-proxy.test.ts b/frontend/src/app/api/agent/onboarding/onboarding-proxy.test.ts new file mode 100644 index 000000000..df83651c9 --- /dev/null +++ b/frontend/src/app/api/agent/onboarding/onboarding-proxy.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; +import { proxyAgentOnboarding } from "./proxy"; + +const original = { + fetch: globalThis.fetch, + lifecycle: process.env.LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN, + onboarding: process.env.LOCAL_STUDIO_AGENT_ONBOARDING_TOKEN, + runtime: process.env.LOCAL_STUDIO_AGENT_RUNTIME_URL, +}; + +afterEach(() => { + globalThis.fetch = original.fetch; + for (const [name, value] of [ + ["LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN", original.lifecycle], + ["LOCAL_STUDIO_AGENT_ONBOARDING_TOKEN", original.onboarding], + ["LOCAL_STUDIO_AGENT_RUNTIME_URL", original.runtime], + ] as const) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } +}); + +describe("agent onboarding front door", () => { + it("fails closed without an internal service credential", async () => { + delete process.env.LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN; + delete process.env.LOCAL_STUDIO_AGENT_ONBOARDING_TOKEN; + const response = await proxyAgentOnboarding( + new Request("http://localhost/api/agent/onboarding"), + ); + assert.equal(response.status, 503); + }); + + it("replaces browser authorization and preserves the onboarding path", async () => { + process.env.LOCAL_STUDIO_AGENT_ONBOARDING_TOKEN = "onboarding-secret"; + process.env.LOCAL_STUDIO_AGENT_RUNTIME_URL = "http://127.0.0.1:18081"; + let target = ""; + let authorization = ""; + globalThis.fetch = async (input, init) => { + target = String(input); + authorization = new Headers(init?.headers).get("authorization") ?? ""; + return Response.json({ profile: null }); + }; + const response = await proxyAgentOnboarding( + new Request("http://localhost/api/agent/onboarding", { + headers: { authorization: "Bearer browser-secret" }, + }), + ); + assert.equal(response.status, 200); + assert.equal(target, "http://127.0.0.1:18081/api/agent/onboarding"); + assert.equal(authorization, "Bearer onboarding-secret"); + }); + + it("bounds credential bodies before runtime forwarding", async () => { + process.env.LOCAL_STUDIO_AGENT_ONBOARDING_TOKEN = "onboarding-secret"; + let contacted = false; + globalThis.fetch = async () => { + contacted = true; + return Response.json({}); + }; + const response = await proxyAgentOnboarding( + new Request("http://localhost/api/agent/onboarding", { + method: "PUT", + body: "x".repeat(1024 * 1024 + 1), + }), + undefined, + 1024 * 1024, + ); + assert.equal(response.status, 413); + assert.equal(contacted, false); + }); +}); diff --git a/frontend/src/app/api/agent/onboarding/probe/route.ts b/frontend/src/app/api/agent/onboarding/probe/route.ts new file mode 100644 index 000000000..3635645ec --- /dev/null +++ b/frontend/src/app/api/agent/onboarding/probe/route.ts @@ -0,0 +1,10 @@ +import type { NextRequest } from "next/server"; +import { requireApiAccess } from "@/lib/auth/guard"; +import { proxyAgentOnboarding } from "../proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: NextRequest) { + return (await requireApiAccess(request)) ?? proxyAgentOnboarding(request, undefined, 1024 * 1024); +} diff --git a/frontend/src/app/api/agent/onboarding/proxy.ts b/frontend/src/app/api/agent/onboarding/proxy.ts new file mode 100644 index 000000000..1fbd77005 --- /dev/null +++ b/frontend/src/app/api/agent/onboarding/proxy.ts @@ -0,0 +1,19 @@ +import { proxyToAgentRuntime } from "@/app/api/agent/proxy-to-runtime"; + +export const proxyAgentOnboarding = ( + request: Request, + upstreamPath?: string, + bodyLimitBytes?: number, +) => { + const token = + process.env.LOCAL_STUDIO_AGENT_ONBOARDING_TOKEN?.trim() || + process.env.LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN?.trim(); + if (!token) { + return Response.json({ error: "Agent onboarding API is not configured" }, { status: 503 }); + } + return proxyToAgentRuntime(request, { + authorization: `Bearer ${token}`, + ...(upstreamPath ? { upstreamPath } : {}), + ...(bodyLimitBytes ? { bodyLimitBytes } : {}), + }); +}; diff --git a/frontend/src/app/api/agent/onboarding/route.ts b/frontend/src/app/api/agent/onboarding/route.ts new file mode 100644 index 000000000..4918730e5 --- /dev/null +++ b/frontend/src/app/api/agent/onboarding/route.ts @@ -0,0 +1,14 @@ +import type { NextRequest } from "next/server"; +import { requireApiAccess } from "@/lib/auth/guard"; +import { proxyAgentOnboarding } from "./proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + return (await requireApiAccess(request)) ?? proxyAgentOnboarding(request); +} + +export async function PUT(request: NextRequest) { + return (await requireApiAccess(request)) ?? proxyAgentOnboarding(request, undefined, 1024 * 1024); +} diff --git a/frontend/src/app/api/agent/onboarding/search/route.ts b/frontend/src/app/api/agent/onboarding/search/route.ts new file mode 100644 index 000000000..3635645ec --- /dev/null +++ b/frontend/src/app/api/agent/onboarding/search/route.ts @@ -0,0 +1,10 @@ +import type { NextRequest } from "next/server"; +import { requireApiAccess } from "@/lib/auth/guard"; +import { proxyAgentOnboarding } from "../proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: NextRequest) { + return (await requireApiAccess(request)) ?? proxyAgentOnboarding(request, undefined, 1024 * 1024); +} diff --git a/frontend/src/app/api/agent/plan/route.ts b/frontend/src/app/api/agent/plan/route.ts index 1070b0b13..a3c0ef789 100644 --- a/frontend/src/app/api/agent/plan/route.ts +++ b/frontend/src/app/api/agent/plan/route.ts @@ -6,14 +6,14 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; const sessionId = request.nextUrl.searchParams.get("sessionId"); return Response.json(await readAgentPlan(sessionId)); } export async function POST(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; const sessionId = request.nextUrl.searchParams.get("sessionId"); const body = (await request.json().catch(() => null)) as { diff --git a/frontend/src/app/api/agent/plugins/[id]/route.ts b/frontend/src/app/api/agent/plugins/[id]/route.ts index 0fc6552a6..0fe0e33ed 100644 --- a/frontend/src/app/api/agent/plugins/[id]/route.ts +++ b/frontend/src/app/api/agent/plugins/[id]/route.ts @@ -10,7 +10,7 @@ export const dynamic = "force-dynamic"; const PluginActivationSchema = Schema.Struct({ enabled: Schema.Boolean }); export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; let body: typeof PluginActivationSchema.Type; try { diff --git a/frontend/src/app/api/agent/plugins/route.ts b/frontend/src/app/api/agent/plugins/route.ts index 13b32c297..ec31c0594 100644 --- a/frontend/src/app/api/agent/plugins/route.ts +++ b/frontend/src/app/api/agent/plugins/route.ts @@ -7,7 +7,7 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; const plugins = await Effect.runPromise(listPluginRuntimeViews()); return NextResponse.json({ plugins }); diff --git a/frontend/src/app/api/agent/pr/merge/route.ts b/frontend/src/app/api/agent/pr/merge/route.ts index 5f9eea61e..69b0d5a61 100644 --- a/frontend/src/app/api/agent/pr/merge/route.ts +++ b/frontend/src/app/api/agent/pr/merge/route.ts @@ -35,13 +35,16 @@ async function validateBody(request: NextRequest): Promise { try { assertWorkspaceRoot(path.resolve(cwd)); } catch (error) { - return jsonError(error instanceof Error ? error.message : "cwd is not an allowed workspace", 403); + return jsonError( + error instanceof Error ? error.message : "cwd is not an allowed workspace", + 403, + ); } return null; } export async function POST(request: NextRequest): Promise { - const denied = requireApiAccess(request) ?? denyCrossSite(request); + const denied = (await requireApiAccess(request)) ?? denyCrossSite(request); if (denied) return denied; const invalid = await validateBody(request); if (invalid) return invalid; diff --git a/frontend/src/app/api/agent/pr/route.ts b/frontend/src/app/api/agent/pr/route.ts index 4e2f3f4aa..6ad186ac8 100644 --- a/frontend/src/app/api/agent/pr/route.ts +++ b/frontend/src/app/api/agent/pr/route.ts @@ -20,13 +20,16 @@ function validateCwd(rawCwd: string | null): Response | null { try { assertWorkspaceRoot(path.resolve(cwd)); } catch (error) { - return jsonError(error instanceof Error ? error.message : "cwd is not an allowed workspace", 403); + return jsonError( + error instanceof Error ? error.message : "cwd is not an allowed workspace", + 403, + ); } return null; } export async function GET(request: NextRequest): Promise { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; const invalid = validateCwd(request.nextUrl.searchParams.get("cwd")); if (invalid) return invalid; diff --git a/frontend/src/app/api/agent/providers/[providerId]/login/route.ts b/frontend/src/app/api/agent/providers/[providerId]/login/route.ts index cf06108fa..1c7d0c7b1 100644 --- a/frontend/src/app/api/agent/providers/[providerId]/login/route.ts +++ b/frontend/src/app/api/agent/providers/[providerId]/login/route.ts @@ -6,7 +6,7 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function POST(request: NextRequest): Promise { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; return proxyToAgentRuntime(request); } diff --git a/frontend/src/app/api/agent/providers/[providerId]/logout/route.ts b/frontend/src/app/api/agent/providers/[providerId]/logout/route.ts index cf06108fa..1c7d0c7b1 100644 --- a/frontend/src/app/api/agent/providers/[providerId]/logout/route.ts +++ b/frontend/src/app/api/agent/providers/[providerId]/logout/route.ts @@ -6,7 +6,7 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function POST(request: NextRequest): Promise { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; return proxyToAgentRuntime(request); } diff --git a/frontend/src/app/api/agent/providers/login/[jobId]/cancel/route.ts b/frontend/src/app/api/agent/providers/login/[jobId]/cancel/route.ts index cf06108fa..1c7d0c7b1 100644 --- a/frontend/src/app/api/agent/providers/login/[jobId]/cancel/route.ts +++ b/frontend/src/app/api/agent/providers/login/[jobId]/cancel/route.ts @@ -6,7 +6,7 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function POST(request: NextRequest): Promise { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; return proxyToAgentRuntime(request); } diff --git a/frontend/src/app/api/agent/providers/login/[jobId]/respond/route.ts b/frontend/src/app/api/agent/providers/login/[jobId]/respond/route.ts index cf06108fa..1c7d0c7b1 100644 --- a/frontend/src/app/api/agent/providers/login/[jobId]/respond/route.ts +++ b/frontend/src/app/api/agent/providers/login/[jobId]/respond/route.ts @@ -6,7 +6,7 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function POST(request: NextRequest): Promise { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; return proxyToAgentRuntime(request); } diff --git a/frontend/src/app/api/agent/providers/login/[jobId]/route.ts b/frontend/src/app/api/agent/providers/login/[jobId]/route.ts index 239204014..836f6158b 100644 --- a/frontend/src/app/api/agent/providers/login/[jobId]/route.ts +++ b/frontend/src/app/api/agent/providers/login/[jobId]/route.ts @@ -6,7 +6,7 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET(request: NextRequest): Promise { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; return proxyToAgentRuntime(request); } diff --git a/frontend/src/app/api/agent/providers/route.ts b/frontend/src/app/api/agent/providers/route.ts index 239204014..836f6158b 100644 --- a/frontend/src/app/api/agent/providers/route.ts +++ b/frontend/src/app/api/agent/providers/route.ts @@ -6,7 +6,7 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET(request: NextRequest): Promise { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; return proxyToAgentRuntime(request); } diff --git a/frontend/src/app/api/agent/provisioning/provisioning-routes.test.ts b/frontend/src/app/api/agent/provisioning/provisioning-routes.test.ts new file mode 100644 index 000000000..4b9cbe3bd --- /dev/null +++ b/frontend/src/app/api/agent/provisioning/provisioning-routes.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; +import { NextRequest } from "next/server"; +import { proxyProvisioning } from "./proxy"; +import { POST as setup } from "./setup/route"; + +const original = { + fetch: globalThis.fetch, + lifecycle: process.env.LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN, + provisioning: process.env.LOCAL_STUDIO_PROVISIONING_TOKEN, + runtime: process.env.LOCAL_STUDIO_AGENT_RUNTIME_URL, + dataDir: process.env.LOCAL_STUDIO_DATA_DIR, +}; + +afterEach(() => { + globalThis.fetch = original.fetch; + for (const [name, value] of [ + ["LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN", original.lifecycle], + ["LOCAL_STUDIO_PROVISIONING_TOKEN", original.provisioning], + ["LOCAL_STUDIO_AGENT_RUNTIME_URL", original.runtime], + ["LOCAL_STUDIO_DATA_DIR", original.dataDir], + ] as const) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } +}); + +describe("provisioning coordinator front door", () => { + it("fails closed without a server credential", async () => { + delete process.env.LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN; + delete process.env.LOCAL_STUDIO_PROVISIONING_TOKEN; + const response = await proxyProvisioning( + new Request("http://localhost/api/agent/provisioning"), + "/api/provisioning", + ); + assert.equal(response.status, 503); + }); + + it("rewrites the upstream path and replaces browser authorization", async () => { + process.env.LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN = "lifecycle-secret"; + process.env.LOCAL_STUDIO_PROVISIONING_TOKEN = "provisioning-secret"; + process.env.LOCAL_STUDIO_AGENT_RUNTIME_URL = "http://127.0.0.1:18081"; + let target = ""; + let authorization = ""; + globalThis.fetch = async (input, init) => { + target = String(input); + authorization = new Headers(init?.headers).get("authorization") ?? ""; + return Response.json({ phase: "idle" }); + }; + const response = await proxyProvisioning( + new Request("http://localhost/api/agent/provisioning?view=lineage", { + headers: { authorization: "Bearer browser-secret" }, + }), + "/api/provisioning", + ); + assert.equal(response.status, 200); + assert.equal(target, "http://127.0.0.1:18081/api/provisioning?view=lineage"); + assert.equal(authorization, "Bearer provisioning-secret"); + }); + + it("bounds setup bodies before contacting the runtime", async () => { + process.env.LOCAL_STUDIO_DATA_DIR = "/tmp/local-studio-provisioning-test"; + process.env.LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN = "lifecycle-secret"; + let contacted = false; + globalThis.fetch = async () => { + contacted = true; + return Response.json({}); + }; + const response = await setup( + new NextRequest("http://localhost/api/agent/provisioning/setup", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ padding: "x".repeat(1024 * 1024) }), + }), + ); + assert.equal(response.status, 413); + assert.equal(contacted, false); + }); +}); diff --git a/frontend/src/app/api/agent/provisioning/proxy.ts b/frontend/src/app/api/agent/provisioning/proxy.ts new file mode 100644 index 000000000..a2af7b71d --- /dev/null +++ b/frontend/src/app/api/agent/provisioning/proxy.ts @@ -0,0 +1,22 @@ +import { proxyToAgentRuntime } from "@/app/api/agent/proxy-to-runtime"; + +export const proxyProvisioning = ( + request: Request, + upstreamPath: string, + bodyLimitBytes?: number, +) => { + const token = + process.env.LOCAL_STUDIO_PROVISIONING_TOKEN?.trim() || + process.env.LOCAL_STUDIO_AGENT_LIFECYCLE_TOKEN?.trim(); + if (!token) { + return Response.json( + { error: "Provisioning coordinator API is not configured" }, + { status: 503 }, + ); + } + return proxyToAgentRuntime(request, { + authorization: `Bearer ${token}`, + upstreamPath, + ...(bodyLimitBytes ? { bodyLimitBytes } : {}), + }); +}; diff --git a/frontend/src/app/api/agent/provisioning/reconcile/route.ts b/frontend/src/app/api/agent/provisioning/reconcile/route.ts new file mode 100644 index 000000000..42c6b3dbb --- /dev/null +++ b/frontend/src/app/api/agent/provisioning/reconcile/route.ts @@ -0,0 +1,12 @@ +import type { NextRequest } from "next/server"; +import { requireApiAccess } from "@/lib/auth/guard"; +import { proxyProvisioning } from "../proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: NextRequest) { + return ( + (await requireApiAccess(request)) ?? proxyProvisioning(request, "/api/provisioning/reconcile") + ); +} diff --git a/frontend/src/app/api/agent/provisioning/recover/route.ts b/frontend/src/app/api/agent/provisioning/recover/route.ts new file mode 100644 index 000000000..c16f202a4 --- /dev/null +++ b/frontend/src/app/api/agent/provisioning/recover/route.ts @@ -0,0 +1,12 @@ +import type { NextRequest } from "next/server"; +import { requireApiAccess } from "@/lib/auth/guard"; +import { proxyProvisioning } from "../proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: NextRequest) { + return ( + (await requireApiAccess(request)) ?? proxyProvisioning(request, "/api/provisioning/recover") + ); +} diff --git a/frontend/src/app/api/agent/provisioning/route.ts b/frontend/src/app/api/agent/provisioning/route.ts new file mode 100644 index 000000000..c89f99a41 --- /dev/null +++ b/frontend/src/app/api/agent/provisioning/route.ts @@ -0,0 +1,14 @@ +import type { NextRequest } from "next/server"; +import { requireApiAccess } from "@/lib/auth/guard"; +import { proxyProvisioning } from "./proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + return (await requireApiAccess(request)) ?? proxyProvisioning(request, "/api/provisioning"); +} + +export async function DELETE(request: NextRequest) { + return (await requireApiAccess(request)) ?? proxyProvisioning(request, "/api/provisioning"); +} diff --git a/frontend/src/app/api/agent/provisioning/setup/route.ts b/frontend/src/app/api/agent/provisioning/setup/route.ts new file mode 100644 index 000000000..71db2eff5 --- /dev/null +++ b/frontend/src/app/api/agent/provisioning/setup/route.ts @@ -0,0 +1,13 @@ +import type { NextRequest } from "next/server"; +import { requireApiAccess } from "@/lib/auth/guard"; +import { proxyProvisioning } from "../proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: NextRequest) { + return ( + (await requireApiAccess(request)) ?? + proxyProvisioning(request, "/api/provisioning/setup", 1024 * 1024) + ); +} diff --git a/frontend/src/app/api/agent/proxy-to-runtime.ts b/frontend/src/app/api/agent/proxy-to-runtime.ts index 3bdcc99b9..da3879a0a 100644 --- a/frontend/src/app/api/agent/proxy-to-runtime.ts +++ b/frontend/src/app/api/agent/proxy-to-runtime.ts @@ -1,10 +1,26 @@ import { readRequestBytesWithinLimit } from "@shared/agent/agent-turn-body"; +import { enterpriseAuthConfig } from "@/lib/auth/enterprise-config"; +import { ENTERPRISE_SESSION_COOKIE, getEnterpriseSession } from "@/lib/auth/enterprise-session"; +import { acquireEnterpriseAccessToken } from "@/lib/auth/token-broker"; +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"; -const HOP_BY_HOP_REQUEST_HEADERS = ["host", "connection", "content-length", "accept-encoding"]; +const HOP_BY_HOP_REQUEST_HEADERS = [ + "host", + "connection", + "content-length", + "accept-encoding", + "cookie", + "authorization", + "x-spiffe-jwt-svid", +]; const DEFAULT_AGENT_RUNTIME_URL = "http://127.0.0.1:8081"; type AgentRuntimeProxyOptions = { bodyLimitBytes?: number; + authorization?: string; + upstreamPath?: string; }; export function agentRuntimeBaseUrl(): string { @@ -18,10 +34,45 @@ export async function proxyToAgentRuntime( ): Promise { const base = agentRuntimeBaseUrl(); const url = new URL(request.url); - const target = `${base}${url.pathname}${url.search}`; + const target = `${base}${options.upstreamPath ?? url.pathname}${url.search}`; const headers = new Headers(request.headers); for (const name of HOP_BY_HOP_REQUEST_HEADERS) headers.delete(name); + for (const name of [...headers.keys()]) { + if (name.startsWith("x-local-studio-enterprise-")) headers.delete(name); + } + const enterpriseSession = await getEnterpriseSession( + request.headers + .get("cookie") + ?.split(";") + .map((entry) => entry.trim().split("=")) + .find(([name]) => name === ENTERPRISE_SESSION_COOKIE)?.[1], + enterpriseAuthConfig(), + ); + if (enterpriseSession) { + const lease = await acquireEnterpriseAccessToken(enterpriseSession); + headers.set("x-local-studio-enterprise-token", lease.accessToken); + } + const workload = loadWorkloadIdentityConfig(); + if (workload && workload.mode !== "disabled") { + try { + const identity = await fetchJwtSvid( + workload, + workload.agent_runtime_audience, + workload.frontend_id, + request.signal, + ); + headers.set("x-spiffe-jwt-svid", identity.svid); + } catch { + if (workload.mode === "required") { + return Response.json({ error: "SPIFFE workload identity unavailable" }, { status: 503 }); + } + } + } + if (options.authorization !== undefined) { + headers.delete("authorization"); + if (options.authorization) headers.set("authorization", options.authorization); + } let body: ArrayBuffer | undefined; if (request.method !== "GET" && request.method !== "HEAD") { @@ -37,13 +88,23 @@ export async function proxyToAgentRuntime( let upstream: Response; try { - upstream = await fetch(target, { + const requestInit = { method: request.method, headers, body, signal: request.signal, cache: "no-store", - }); + } satisfies RequestInit; + upstream = + workload && workload.mode !== "disabled" + ? await fetchWithX509Svid( + workload, + workload.frontend_id, + workload.agent_runtime_id, + target, + requestInit, + ) + : await fetch(target, requestInit); } catch (error) { if (request.signal.aborted) throw error; return Response.json( diff --git a/frontend/src/app/api/agent/runtime/extension-ui/route.ts b/frontend/src/app/api/agent/runtime/extension-ui/route.ts index 615965590..0c8625d17 100644 --- a/frontend/src/app/api/agent/runtime/extension-ui/route.ts +++ b/frontend/src/app/api/agent/runtime/extension-ui/route.ts @@ -6,7 +6,7 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function POST(request: NextRequest): Promise { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; return proxyToAgentRuntime(request, { bodyLimitBytes: 40_000 }); } diff --git a/frontend/src/app/api/agent/subagents/route.ts b/frontend/src/app/api/agent/subagents/route.ts index 2b86d9f1d..a37ab230f 100644 --- a/frontend/src/app/api/agent/subagents/route.ts +++ b/frontend/src/app/api/agent/subagents/route.ts @@ -6,13 +6,13 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET(request: NextRequest): Promise { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; return proxyToAgentRuntime(request); } export async function POST(request: NextRequest): Promise { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; return proxyToAgentRuntime(request); } diff --git a/frontend/src/app/api/agent/terminal/pty/[action]/route.ts b/frontend/src/app/api/agent/terminal/pty/[action]/route.ts index d818d1b1e..f27bc6fab 100644 --- a/frontend/src/app/api/agent/terminal/pty/[action]/route.ts +++ b/frontend/src/app/api/agent/terminal/pty/[action]/route.ts @@ -55,7 +55,7 @@ export async function GET( ): Promise { const { action } = await context.params; if (action !== "stream") return jsonError("Unknown action", 404); - const denied = requireApiAccess(request) ?? denyCrossSite(request); + const denied = (await requireApiAccess(request)) ?? denyCrossSite(request); if (denied) return denied; return proxyToAgentRuntime(request); } @@ -66,7 +66,7 @@ export async function POST( ): Promise { const { action } = await context.params; if (!POST_ACTIONS.has(action)) return jsonError("Unknown action", 404); - const denied = requireApiAccess(request) ?? denyCrossSite(request); + const denied = (await requireApiAccess(request)) ?? denyCrossSite(request); if (denied) return denied; if (action === "open") { const invalid = await validateOpenBody(request); diff --git a/frontend/src/app/api/agent/terminal/resolve-cwd/route.ts b/frontend/src/app/api/agent/terminal/resolve-cwd/route.ts index 7f00d9d21..f15e52570 100644 --- a/frontend/src/app/api/agent/terminal/resolve-cwd/route.ts +++ b/frontend/src/app/api/agent/terminal/resolve-cwd/route.ts @@ -14,7 +14,7 @@ function expandTilde(target: string): string { } export async function POST(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; let body: unknown; try { diff --git a/frontend/src/app/api/agent/terminal/route.ts b/frontend/src/app/api/agent/terminal/route.ts index 1a80479e4..1f1cc720e 100644 --- a/frontend/src/app/api/agent/terminal/route.ts +++ b/frontend/src/app/api/agent/terminal/route.ts @@ -35,7 +35,7 @@ function assertTerminalCwd( } export async function POST(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; const { cwd, error } = assertTerminalCwd(request); if (error) return error; diff --git a/frontend/src/app/api/agent/turn/route.ts b/frontend/src/app/api/agent/turn/route.ts index fbd9c7d3c..7b056f9a3 100644 --- a/frontend/src/app/api/agent/turn/route.ts +++ b/frontend/src/app/api/agent/turn/route.ts @@ -7,7 +7,7 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function POST(request: NextRequest): Promise { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; return proxyToAgentRuntime(request, { bodyLimitBytes: AGENT_TURN_BODY_LIMIT_BYTES }); } diff --git a/frontend/src/app/api/agent/workload-identity/route.ts b/frontend/src/app/api/agent/workload-identity/route.ts new file mode 100644 index 000000000..674bbe0f4 --- /dev/null +++ b/frontend/src/app/api/agent/workload-identity/route.ts @@ -0,0 +1,165 @@ +import { + ControllerWorkloadProbeSchema, + type WorkloadIdentityEvidence, +} from "@local-studio/contracts/workload-identity"; +import { loadWorkloadIdentityConfig } from "@local-studio/agent-runtime/spiffe-config"; +import { fetchJwtSvid, validateJwtSvid } from "@local-studio/agent-runtime/spiffe-workload-api"; +import { agentRuntimeBaseUrl } from "../proxy-to-runtime"; +import { currentX509Svid, fetchWithX509Svid } from "@local-studio/agent-runtime/spiffe-x509"; +import { getApiSettings } from "@local-studio/agent-runtime/settings-service"; +import { enterpriseAuthConfig } from "@/lib/auth/enterprise-config"; +import { ENTERPRISE_SESSION_COOKIE, getEnterpriseSession } from "@/lib/auth/enterprise-session"; +import { acquireEnterpriseAccessToken } from "@/lib/auth/token-broker"; +import { Schema } from "effect"; + +const unavailable = (required: boolean, detail: string): WorkloadIdentityEvidence => ({ + configured: true, + required, + state: "contradicted", + spiffe_id: null, + trust_domain: null, + audience: null, + expires_at: null, + checked_at: new Date().toISOString(), + jwt_svid_validated: false, + x509_mtls: "not_verified", + detail, +}); + +export async function GET(request: Request): Promise { + const config = loadWorkloadIdentityConfig(); + if (!config || config.mode === "disabled") { + return Response.json({ + configured: false, + required: false, + state: "unconfigured", + spiffe_id: null, + trust_domain: null, + audience: null, + expires_at: null, + checked_at: null, + jwt_svid_validated: false, + x509_mtls: "not_verified", + detail: "SPIFFE workload identity is not enabled.", + } satisfies WorkloadIdentityEvidence); + } + try { + const token = await fetchJwtSvid( + config, + config.agent_runtime_audience, + config.frontend_id, + request.signal, + ); + const runtime = await fetchWithX509Svid( + config, + config.frontend_id, + config.agent_runtime_id, + `${agentRuntimeBaseUrl()}/ready`, + { + headers: { "x-spiffe-jwt-svid": token.svid }, + signal: request.signal, + cache: "no-store", + }, + ); + if (!runtime.ok) throw new Error("Agent runtime rejected workload identity"); + const settings = await getApiSettings(); + const controllerToken = await fetchJwtSvid( + config, + config.controller_audience, + config.frontend_id, + request.signal, + ); + const controllerHeaders = new Headers({ "x-spiffe-jwt-svid": controllerToken.svid }); + const session = await getEnterpriseSession( + request.headers + .get("cookie") + ?.split(";") + .map((entry) => entry.trim().split("=")) + .find(([name]) => name === ENTERPRISE_SESSION_COOKIE)?.[1], + enterpriseAuthConfig(), + ); + if (session) { + const lease = await acquireEnterpriseAccessToken(session); + controllerHeaders.set("authorization", `Bearer ${lease.accessToken}`); + } else if (settings.apiKey) { + controllerHeaders.set("authorization", `Bearer ${settings.apiKey}`); + } + const controller = await fetchWithX509Svid( + config, + config.frontend_id, + config.controller_id, + `${settings.backendUrl.replace(/\/+$/u, "")}/environment/workload-identity`, + { headers: controllerHeaders, signal: request.signal, cache: "no-store" }, + ); + if (!controller.ok) throw new Error("Controller rejected workload identity"); + const controllerEvidence = Schema.decodeUnknownSync(ControllerWorkloadProbeSchema)( + await controller.json(), + ); + if ( + !controllerEvidence.observed || + controllerEvidence.jwt_svid !== true || + (config.x509_mtls === "required" && controllerEvidence.x509_mtls !== true) + ) { + throw new Error("Controller workload probe failed"); + } + const validated = await validateJwtSvid( + config, + config.agent_runtime_audience, + token.svid, + [config.frontend_id], + request.signal, + ); + const x509 = currentX509Svid(config, config.frontend_id); + const mtlsObserved = config.x509_mtls === "required" && Boolean(x509); + return Response.json({ + configured: true, + required: config.mode === "required", + state: "observed", + spiffe_id: validated.spiffeId, + trust_domain: config.trust_domain, + audience: config.agent_runtime_audience, + expires_at: validated.expiresAt, + checked_at: new Date().toISOString(), + jwt_svid_validated: true, + x509_mtls: + config.x509_mtls === "required" + ? mtlsObserved + ? "observed" + : "contradicted" + : config.x509_mtls === "optional" + ? "not_verified" + : "disabled", + x509_svid_expires_at: x509?.expiresAt ?? null, + x509_svid_serial: x509?.serialNumber ?? null, + rotation_generation: x509?.generation ?? 0, + hops: [ + { + source: config.frontend_id, + destination: config.agent_runtime_id, + jwt_svid: true, + x509_mtls: mtlsObserved, + peer_id: config.agent_runtime_id, + }, + { + source: config.frontend_id, + destination: config.controller_id, + jwt_svid: true, + x509_mtls: mtlsObserved, + peer_id: config.controller_id, + }, + { + source: config.controller_id, + destination: config.agent_runtime_id, + jwt_svid: true, + x509_mtls: controllerEvidence.x509_mtls === true, + peer_id: config.agent_runtime_id, + }, + ], + detail: "SPIFFE workload identity was accepted on all commissioned service hops.", + } satisfies WorkloadIdentityEvidence); + } catch { + return Response.json( + unavailable(config.mode === "required", "SPIFFE Workload API issuance or validation failed."), + ); + } +} diff --git a/frontend/src/app/api/auth/backchannel-logout/[issuer]/route.ts b/frontend/src/app/api/auth/backchannel-logout/[issuer]/route.ts new file mode 100644 index 000000000..fdc0721cc --- /dev/null +++ b/frontend/src/app/api/auth/backchannel-logout/[issuer]/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from "next/server"; +import { readRequestBytesWithinLimit } from "@shared/agent/agent-turn-body"; +import { emitEnterpriseAudit } from "@/lib/auth/enterprise-audit"; +import { enterpriseIssuer } from "@/lib/auth/enterprise-config"; +import { takeEnterpriseSessionsForLogout } from "@/lib/auth/enterprise-session"; +import { revokeOidcSession, verifyBackchannelLogoutToken } from "@/lib/auth/oidc-client"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +const noStore = { "cache-control": "no-store" }; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ issuer: string }> }, +) { + try { + if ( + request.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() !== + "application/x-www-form-urlencoded" + ) { + throw new Error("OIDC back-channel logout content type is invalid"); + } + const body = await readRequestBytesWithinLimit(request, 64 * 1024); + if (!body.ok) throw new Error("OIDC back-channel logout body is invalid"); + const form = new URLSearchParams(new TextDecoder().decode(body.value)); + const tokens = form.getAll("logout_token"); + if (tokens.length !== 1 || !tokens[0]) { + throw new Error("OIDC back-channel logout token is missing"); + } + const { issuer: issuerId } = await params; + const issuer = enterpriseIssuer(issuerId); + const token = await verifyBackchannelLogoutToken(issuer, tokens[0]); + const deletion = await takeEnterpriseSessionsForLogout( + token.issuer, + issuer.id, + token.jti, + token.expiresAt, + { + ...(token.sid ? { sid: token.sid } : {}), + ...(token.subject ? { subject: token.subject } : {}), + }, + ); + const { result } = deletion; + if (result.replayed) throw new Error("OIDC back-channel logout token was replayed"); + const revocations = issuer.scopes.includes("offline_access") + ? [] + : await Promise.allSettled( + deletion.sessions + .filter((session) => session.refreshToken) + .map((session) => revokeOidcSession(issuer, session.refreshToken)), + ); + const revocationFailed = revocations.some( + (revocation) => revocation.status === "rejected" || revocation.value === false, + ); + const revocationState = issuer.scopes.includes("offline_access") + ? "retained_offline" + : revocations.length === 0 + ? "not_available" + : revocationFailed + ? "failed" + : "observed"; + emitEnterpriseAudit({ + event: "backchannel_logout", + issuer_id: issuer.id, + reason: + result.deleted === 0 + ? "session_not_found" + : `sessions_deleted:${result.deleted};revocation:${revocationState}`, + }); + return new NextResponse(null, { status: 200, headers: noStore }); + } catch { + emitEnterpriseAudit({ + event: "session_denied", + reason: "backchannel_logout_validation_failed", + }); + return NextResponse.json( + { error: "OIDC back-channel logout rejected" }, + { status: 400, headers: noStore }, + ); + } +} diff --git a/frontend/src/app/api/auth/callback/[issuer]/route.ts b/frontend/src/app/api/auth/callback/[issuer]/route.ts new file mode 100644 index 000000000..e33b417fc --- /dev/null +++ b/frontend/src/app/api/auth/callback/[issuer]/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { enterpriseAuthConfig, enterpriseIssuer } from "@/lib/auth/enterprise-config"; +import { + consumeAuthorizationFlow, + createEnterpriseSession, + ENTERPRISE_FLOW_COOKIE, + ENTERPRISE_SESSION_COOKIE, + normalizeOidcClaims, + oidcSessionIdFromClaims, +} from "@/lib/auth/enterprise-session"; +import { redeemAuthorizationCode } from "@/lib/auth/oidc-client"; +import { emitEnterpriseAudit } from "@/lib/auth/enterprise-audit"; +import { requestUsesHttps } from "@/lib/auth/request-context"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ issuer: string }> }, +) { + try { + const { issuer: issuerId } = await params; + const code = request.nextUrl.searchParams.get("code") ?? ""; + const state = request.nextUrl.searchParams.get("state") ?? ""; + const flow = await consumeAuthorizationFlow( + request.cookies.get(ENTERPRISE_FLOW_COOKIE)?.value ?? "", + state, + issuerId, + ); + if (!code) throw new Error("OIDC callback does not match login"); + const issuer = enterpriseIssuer(issuerId); + const tokens = await redeemAuthorizationCode(issuer, flow, code); + const config = enterpriseAuthConfig(); + if (!config) throw new Error("Enterprise authentication is not configured"); + const principal = normalizeOidcClaims(tokens.claims, issuer); + const session = await createEnterpriseSession(principal, tokens.accessToken, config, { + ...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}), + ...(tokens.idToken ? { idToken: tokens.idToken } : {}), + ...(tokens.accountId ? { accountId: tokens.accountId } : {}), + ...(oidcSessionIdFromClaims(tokens.claims) + ? { oidcSessionId: oidcSessionIdFromClaims(tokens.claims) } + : {}), + }); + emitEnterpriseAudit({ + event: "login", + subject: principal.subject, + issuer_id: principal.issuer_id, + tenant: principal.tenant, + }); + const response = NextResponse.redirect(new URL(flow.returnTo, request.url)); + response.cookies.delete(ENTERPRISE_FLOW_COOKIE); + response.cookies.set(ENTERPRISE_SESSION_COOKIE, session.id, { + httpOnly: true, + sameSite: "lax", + secure: requestUsesHttps(request), + path: "/", + maxAge: config.session_absolute_seconds, + }); + return response; + } catch { + emitEnterpriseAudit({ event: "session_denied", reason: "callback_validation_failed" }); + const response = NextResponse.json( + { error: "OIDC callback validation failed" }, + { status: 401 }, + ); + response.cookies.delete(ENTERPRISE_FLOW_COOKIE); + return response; + } +} diff --git a/frontend/src/app/api/auth/login/[issuer]/route.ts b/frontend/src/app/api/auth/login/[issuer]/route.ts new file mode 100644 index 000000000..22f9f3f28 --- /dev/null +++ b/frontend/src/app/api/auth/login/[issuer]/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { enterpriseIssuer } from "@/lib/auth/enterprise-config"; +import { createAuthorizationFlow, ENTERPRISE_FLOW_COOKIE } from "@/lib/auth/enterprise-session"; +import { authorizationUrl } from "@/lib/auth/oidc-client"; +import { requestUsesHttps } from "@/lib/auth/request-context"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ issuer: string }> }, +) { + try { + const { issuer: issuerId } = await params; + const issuer = enterpriseIssuer(issuerId); + const redirectUri = new URL(`/api/auth/callback/${issuer.id}`, request.url).toString(); + const flow = await createAuthorizationFlow( + issuer.id, + redirectUri, + request.nextUrl.searchParams.get("returnTo") ?? "/", + ); + const response = NextResponse.redirect(await authorizationUrl(issuer, flow)); + response.cookies.set(ENTERPRISE_FLOW_COOKIE, flow.id, { + httpOnly: true, + sameSite: "lax", + secure: requestUsesHttps(request), + path: "/api/auth", + maxAge: 600, + }); + return response; + } catch { + return NextResponse.json({ error: "OIDC login could not be started" }, { status: 400 }); + } +} diff --git a/frontend/src/app/api/auth/logout/route.ts b/frontend/src/app/api/auth/logout/route.ts new file mode 100644 index 000000000..dbc05bfae --- /dev/null +++ b/frontend/src/app/api/auth/logout/route.ts @@ -0,0 +1,98 @@ +import { createHash, timingSafeEqual } from "node:crypto"; +import { NextRequest, NextResponse } from "next/server"; +import { + consumeIssuerLogoutTicket, + createIssuerLogoutTicket, + deleteEnterpriseSession, + ENTERPRISE_SESSION_COOKIE, +} from "@/lib/auth/enterprise-session"; +import { emitEnterpriseAudit } from "@/lib/auth/enterprise-audit"; +import { enterpriseIssuer } from "@/lib/auth/enterprise-config"; +import { + issuerLogoutUrl, + removeOidcSessionAccount, + revokeOidcSession, +} from "@/lib/auth/oidc-client"; +import { CSRF_COOKIE, CSRF_HEADER } from "@/lib/security/request-boundary"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const returnLocation = (request: NextRequest): string => { + const value = request.nextUrl.searchParams.get("returnTo") ?? "/settings#enterprise"; + if (!value.startsWith("/") || value.startsWith("//")) return "/settings#enterprise"; + return value; +}; + +const validCsrf = (request: NextRequest): boolean => { + const cookie = request.cookies.get(CSRF_COOKIE)?.value ?? ""; + const header = request.headers.get(CSRF_HEADER) ?? ""; + if (!cookie || !header) return false; + return timingSafeEqual( + createHash("sha256").update(cookie, "utf8").digest(), + createHash("sha256").update(header, "utf8").digest(), + ); +}; + +export async function POST(request: NextRequest) { + if (!validCsrf(request)) { + return NextResponse.json({ error: "CSRF validation failed" }, { status: 403 }); + } + const session = await deleteEnterpriseSession( + request.cookies.get(ENTERPRISE_SESSION_COOKIE)?.value, + ); + const returnTo = returnLocation(request); + let logoutPath: string | null = null; + let revocation: "not_available" | "observed" | "failed" = "not_available"; + if (session) { + let issuer: ReturnType | null = null; + try { + issuer = enterpriseIssuer(session.principal.issuer_id); + } catch { + revocation = "failed"; + } + if (issuer) { + try { + await removeOidcSessionAccount(issuer, session.accountId); + const revoked = await revokeOidcSession(issuer, session.refreshToken); + if (revoked) revocation = "observed"; + } catch { + revocation = "failed"; + } + try { + const target = await issuerLogoutUrl( + issuer, + session.idToken, + new URL(returnTo, request.url).toString(), + ); + if (target) { + const ticket = await createIssuerLogoutTicket(target, returnTo); + logoutPath = `/api/auth/logout?ticket=${encodeURIComponent(ticket)}`; + } + } catch {} + } + } + emitEnterpriseAudit({ + event: "logout", + ...(session + ? { + subject: session.principal.subject, + issuer_id: session.principal.issuer_id, + tenant: session.principal.tenant, + } + : { reason: "session_not_found" }), + }); + const response = NextResponse.json({ + authenticated: false, + revocation, + logout_path: logoutPath, + }); + response.cookies.delete(ENTERPRISE_SESSION_COOKIE); + return response; +} + +export async function GET(request: NextRequest) { + const ticket = await consumeIssuerLogoutTicket(request.nextUrl.searchParams.get("ticket") ?? ""); + const target = ticket?.url ?? new URL("/settings#enterprise", request.url).toString(); + return NextResponse.redirect(target, 303); +} diff --git a/frontend/src/app/api/auth/session/route.ts b/frontend/src/app/api/auth/session/route.ts new file mode 100644 index 000000000..8a8e800ce --- /dev/null +++ b/frontend/src/app/api/auth/session/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { enterpriseAuthConfig } from "@/lib/auth/enterprise-config"; +import { ENTERPRISE_SESSION_COOKIE, getEnterpriseSession } from "@/lib/auth/enterprise-session"; +import { acquireEnterpriseAccessToken } from "@/lib/auth/token-broker"; +import { requestUsesHttps } from "@/lib/auth/request-context"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + const config = enterpriseAuthConfig(); + const sessionId = request.cookies.get(ENTERPRISE_SESSION_COOKIE)?.value; + const existing = await getEnterpriseSession(sessionId, config); + let session = existing; + if (session) { + try { + session = (await acquireEnterpriseAccessToken(session)).session; + } catch { + session = null; + } + } + const response = NextResponse.json({ + mode: config?.mode ?? "local", + issuers: + config?.issuers.map(({ id, kind, tenant, realm }) => ({ + id, + kind, + ...(tenant ? { tenant } : {}), + ...(realm ? { realm } : {}), + })) ?? [], + authenticated: Boolean(session), + principal: session?.principal ?? null, + expires_at: session ? new Date(session.absoluteExpiresAt).toISOString() : null, + }); + if (!session && sessionId) { + response.cookies.delete(ENTERPRISE_SESSION_COOKIE); + } else if (session && session.id !== sessionId) { + response.cookies.set(ENTERPRISE_SESSION_COOKIE, session.id, { + httpOnly: true, + sameSite: "lax", + secure: requestUsesHttps(request), + path: "/", + maxAge: Math.max(Math.floor((session.absoluteExpiresAt - Date.now()) / 1000), 1), + }); + } + return response; +} diff --git a/frontend/src/app/api/local-agents/route.ts b/frontend/src/app/api/local-agents/route.ts index d71dab68f..716ed1996 100644 --- a/frontend/src/app/api/local-agents/route.ts +++ b/frontend/src/app/api/local-agents/route.ts @@ -47,8 +47,48 @@ async function resolveModelImages(core: ApiCore, recipe: RecipeWithStatus, model return inferVisionSupport(`${modelId} ${recipe.name} ${recipe.model_path}`); } +async function attachRemoteModel(input: { + remoteModel: object; + modelId: string; + targets: LocalAgentId[]; +}) { + const candidate = input.remoteModel as Record; + if ( + typeof candidate["baseUrl"] !== "string" || + !/^https?:\/\//.test(candidate["baseUrl"]) || + typeof candidate["displayName"] !== "string" + ) { + return jsonError("remoteModel requires an HTTP baseUrl and displayName"); + } + const contextWindow = + typeof candidate["contextWindow"] === "number" && + Number.isInteger(candidate["contextWindow"]) && + candidate["contextWindow"] > 0 + ? candidate["contextWindow"] + : 131072; + try { + const results = await attachModelToAgents({ + home: os.homedir(), + targets: input.targets, + model: { + modelId: input.modelId, + displayName: candidate["displayName"], + baseUrl: candidate["baseUrl"].replace(/\/+$/, ""), + apiKey: "local-studio", + contextWindow, + maxTokens: contextWindow, + reasoning: candidate["reasoning"] !== false, + images: candidate["images"] === true, + }, + }); + return NextResponse.json({ results }); + } catch (error) { + return jsonError(errorMessage(error, "Failed to attach remote model to local agents"), 500); + } +} + export async function POST(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; let body: unknown; @@ -57,7 +97,11 @@ export async function POST(request: NextRequest) { } catch { return jsonError("Invalid JSON body"); } - const { modelId, targets } = (body ?? {}) as { modelId?: unknown; targets?: unknown }; + const { modelId, targets, remoteModel } = (body ?? {}) as { + modelId?: unknown; + targets?: unknown; + remoteModel?: unknown; + }; if (typeof modelId !== "string" || !modelId.trim()) { return jsonError("modelId is required"); } @@ -67,6 +111,10 @@ export async function POST(request: NextRequest) { ); } + if (remoteModel && typeof remoteModel === "object" && !Array.isArray(remoteModel)) { + return attachRemoteModel({ remoteModel, modelId, targets }); + } + const settings = await getApiSettings(); const backendUrl = settings.backendUrl.replace(/\/+$/, ""); const core = createApiCore({ diff --git a/frontend/src/app/api/proxy/[...path]/proxy-credential-boundary.test.ts b/frontend/src/app/api/proxy/[...path]/proxy-credential-boundary.test.ts new file mode 100644 index 000000000..b6dbc240b --- /dev/null +++ b/frontend/src/app/api/proxy/[...path]/proxy-credential-boundary.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { describe, test } from "node:test"; +import { NextRequest } from "next/server"; +import { buildProxyRequestHeaders, getForwardedSearchParams } from "./proxy-fetch"; + +describe("query credential boundary", () => { + test("detects and strips proxy API keys without converting them to bearer headers", () => { + const request = new NextRequest( + "http://localhost/api/proxy/v1/models?api_key=secret&access_token=other&limit=2", + ); + assert.deepEqual(getForwardedSearchParams(request), { + credentialQueryPresent: true, + searchParams: "limit=2", + }); + assert.equal(buildProxyRequestHeaders(request, "").has("authorization"), false); + }); + + test("uses only an authorization header or persisted controller credential", () => { + const request = new NextRequest("http://localhost/api/proxy/v1/models", { + headers: { authorization: "Bearer incoming" }, + }); + assert.equal( + buildProxyRequestHeaders(request, "persisted").get("authorization"), + "Bearer incoming", + ); + const source = readFileSync(new URL("../../../../proxy.ts", import.meta.url), "utf8"); + assert.doesNotMatch(source, /searchParams\.get\("token"\)/u); + }); + + test("forwards only bounded scientific evidence references", () => { + const accepted = new NextRequest("http://localhost/api/proxy/ai/v1/responses", { + headers: { "x-local-studio-scientific-submission-id": "submission-01" }, + }); + const rejected = new NextRequest("http://localhost/api/proxy/ai/v1/responses", { + headers: { "x-local-studio-scientific-submission-id": "../other-submission" }, + }); + assert.equal( + buildProxyRequestHeaders(accepted, "").get("x-local-studio-scientific-submission-id"), + "submission-01", + ); + assert.equal( + buildProxyRequestHeaders(rejected, "").has("x-local-studio-scientific-submission-id"), + false, + ); + }); +}); diff --git a/frontend/src/app/api/proxy/[...path]/proxy-fetch.ts b/frontend/src/app/api/proxy/[...path]/proxy-fetch.ts index c5b68703e..1f4bb6973 100644 --- a/frontend/src/app/api/proxy/[...path]/proxy-fetch.ts +++ b/frontend/src/app/api/proxy/[...path]/proxy-fetch.ts @@ -60,14 +60,15 @@ export function buildFallbackTargetUrl({ } export function getForwardedSearchParams(request: NextRequest): { - apiKeyQuery: string | null; + credentialQueryPresent: boolean; searchParams: string; } { const url = new URL(request.url); const forwardedParams = new URLSearchParams(url.searchParams); - const apiKeyQuery = forwardedParams.get("api_key"); - if (apiKeyQuery) forwardedParams.delete("api_key"); - return { apiKeyQuery, searchParams: forwardedParams.toString() }; + const credentialKeys = ["api_key", "key", "token", "access_token", "auth_token"]; + const credentialQueryPresent = credentialKeys.some((key) => forwardedParams.has(key)); + for (const key of credentialKeys) forwardedParams.delete(key); + return { credentialQueryPresent, searchParams: forwardedParams.toString() }; } const DEFAULT_REQUEST_BODY_LIMIT = 32 * 1024 * 1024; @@ -120,24 +121,23 @@ export const readProxyRequestBody = async ( return body; }; -export function buildProxyRequestHeaders( - request: NextRequest, - apiKey: string, - apiKeyQuery: string | null, - allowQueryApiKey: boolean, -): Headers { +export function buildProxyRequestHeaders(request: NextRequest, apiKey: string): Headers { const headers = new Headers(); const accept = request.headers.get("accept"); const contentType = request.headers.get("content-type"); const incomingAuth = request.headers.get("authorization"); const suppressAuth = request.headers.get("x-backend-suppress-auth") === "1"; + const scientificSubmissionId = request.headers + .get("x-local-studio-scientific-submission-id") + ?.trim(); if (accept) headers.set("Accept", accept); if (contentType) headers.set("Content-Type", contentType); + if (scientificSubmissionId && /^[A-Za-z0-9._:-]{1,128}$/u.test(scientificSubmissionId)) { + headers.set("X-Local-Studio-Scientific-Submission-ID", scientificSubmissionId); + } if (suppressAuth) return headers; if (incomingAuth) headers.set("Authorization", incomingAuth); - else if (allowQueryApiKey && apiKeyQuery) headers.set("Authorization", `Bearer ${apiKeyQuery}`); else if (apiKey) headers.set("Authorization", `Bearer ${apiKey}`); - else if (apiKeyQuery) headers.set("Authorization", `Bearer ${apiKeyQuery}`); return headers; } @@ -151,6 +151,7 @@ export async function fetchWithOptionalFallback( path: string[]; overrideUsed: boolean; strictOverride: boolean; + fetcher?: (url: string, init: RequestInit) => Promise; }, ): Promise<{ response: Response; usedFallback: boolean }> { const canFallback = Boolean( @@ -169,7 +170,11 @@ export async function fetchWithOptionalFallback( // Do not auto-follow redirects: a compromised/misbehaving upstream must // not be able to bounce the proxy (with its bearer key) to an arbitrary // location. Redirects are surfaced to the caller as-is. - return await fetch(url, { ...init, signal: controller.signal, redirect: "manual" }); + return await (context.fetcher ?? fetch)(url, { + ...init, + signal: controller.signal, + redirect: "manual", + }); } finally { clearTimeout(timeoutId); } diff --git a/frontend/src/app/api/proxy/[...path]/proxy-target.ts b/frontend/src/app/api/proxy/[...path]/proxy-target.ts index b7a7912e6..6eaadd1e6 100644 --- a/frontend/src/app/api/proxy/[...path]/proxy-target.ts +++ b/frontend/src/app/api/proxy/[...path]/proxy-target.ts @@ -1,5 +1,6 @@ import { NextResponse, type NextRequest } from "next/server"; import { getApiSettings } from "@local-studio/agent-runtime/settings-service"; +import { readControllerCredential } from "@local-studio/agent-runtime/controller-credential-store"; import type { ClientInfo } from "./proxy-logging"; const OVERRIDE_ALLOWLIST_ENV_KEY = "LOCAL_STUDIO_PROXY_OVERRIDE_ALLOWLIST"; @@ -127,7 +128,7 @@ export async function resolveProxyTarget( } return { - apiKey: settings.apiKey, + apiKey: overrideUrl ? await readControllerCredential(overrideUrl) : settings.apiKey, backendUrl: overrideUrl ?? defaultBackendUrl, blockedOverrideCleared: false, defaultBackendUrl, diff --git a/frontend/src/app/api/proxy/[...path]/route.ts b/frontend/src/app/api/proxy/[...path]/route.ts index ee80ea99f..4d381d371 100644 --- a/frontend/src/app/api/proxy/[...path]/route.ts +++ b/frontend/src/app/api/proxy/[...path]/route.ts @@ -13,6 +13,12 @@ import { } from "./proxy-fetch"; import { toProxyNextResponse } from "./proxy-response"; import { resolveProxyTarget } from "./proxy-target"; +import { enterpriseAuthConfig } from "@/lib/auth/enterprise-config"; +import { ENTERPRISE_SESSION_COOKIE, getEnterpriseSession } from "@/lib/auth/enterprise-session"; +import { acquireEnterpriseAccessToken } from "@/lib/auth/token-broker"; +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"; export async function GET( request: NextRequest, @@ -51,11 +57,15 @@ async function handleRequest(request: NextRequest, method: string, path: string[ const client = getClientInfo(request); try { + const { credentialQueryPresent, searchParams } = getForwardedSearchParams(request); + if (credentialQueryPresent) { + return NextResponse.json( + { error: "Query-string credentials are not accepted" }, + { status: 400 }, + ); + } const target = await resolveProxyTarget(request, client); if ("blockedResponse" in target) return target.blockedResponse; - - // Never forward credentials to the controller as query params. - const { apiKeyQuery, searchParams } = getForwardedSearchParams(request); const targetUrl = buildTargetUrl(target.backendUrl, path, searchParams); const fallbackTargetUrl = buildFallbackTargetUrl({ defaultBackendUrl: target.defaultBackendUrl, @@ -63,16 +73,36 @@ async function handleRequest(request: NextRequest, method: string, path: string[ path, searchParams, }); - const hasAuth = Boolean(request.headers.get("authorization")); + const enterpriseSession = await getEnterpriseSession( + request.cookies.get(ENTERPRISE_SESSION_COOKIE)?.value, + enterpriseAuthConfig(), + ); + const hasAuth = Boolean(request.headers.get("authorization") || enterpriseSession); logProxyAccess({ client, hasAuth, method, overrideUrl: target.overrideUrl, path }); const body = await readProxyRequestBody(request, method, proxyRequestBodyLimit(path)); - const headers = buildProxyRequestHeaders( - request, - target.apiKey, - apiKeyQuery, - Boolean(target.overrideUrl), - ); + const headers = buildProxyRequestHeaders(request, target.apiKey); + if (enterpriseSession) { + const lease = await acquireEnterpriseAccessToken(enterpriseSession); + headers.set("Authorization", `Bearer ${lease.accessToken}`); + } + const workload = loadWorkloadIdentityConfig(); + let workloadFetcher: ((url: string, init: RequestInit) => Promise) | undefined; + if ( + workload && + workload.mode !== "disabled" && + target.backendUrl === target.defaultBackendUrl + ) { + const identity = await fetchJwtSvid( + workload, + workload.controller_audience, + workload.frontend_id, + request.signal, + ); + headers.set("x-spiffe-jwt-svid", identity.svid); + workloadFetcher = (url, init) => + fetchWithX509Svid(workload, workload.frontend_id, workload.controller_id, url, init); + } const { response, usedFallback } = await fetchWithOptionalFallback( targetUrl, @@ -84,6 +114,7 @@ async function handleRequest(request: NextRequest, method: string, path: string[ path, overrideUsed: Boolean(target.overrideUrl), strictOverride: target.strictOverride, + ...(workloadFetcher ? { fetcher: workloadFetcher } : {}), }, ); diff --git a/frontend/src/app/api/settings/controller-credential/route.ts b/frontend/src/app/api/settings/controller-credential/route.ts new file mode 100644 index 000000000..c096595ff --- /dev/null +++ b/frontend/src/app/api/settings/controller-credential/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { writeControllerCredential } from "@local-studio/agent-runtime/controller-credential-store"; +import { requireApiAccess } from "@/lib/auth/guard"; +import { normalizeControllerUrl } from "@/lib/api/controllers"; + +export const runtime = "nodejs"; + +export async function POST(request: NextRequest) { + const denied = await requireApiAccess(request); + if (denied) return denied; + try { + const body = (await request.json()) as { backendUrl?: unknown; apiKey?: unknown }; + const backendUrl = + typeof body.backendUrl === "string" ? normalizeControllerUrl(body.backendUrl) : ""; + const apiKey = typeof body.apiKey === "string" ? body.apiKey.trim() : ""; + if (!backendUrl || apiKey.length > 32_768) { + return NextResponse.json({ error: "Invalid controller credential" }, { status: 400 }); + } + await writeControllerCredential(backendUrl, apiKey); + return NextResponse.json({ success: true, hasApiKey: Boolean(apiKey) }); + } catch { + return NextResponse.json( + { error: "Controller credential could not be stored" }, + { status: 500 }, + ); + } +} diff --git a/frontend/src/app/api/settings/route.ts b/frontend/src/app/api/settings/route.ts index 831825bb2..1210923ff 100644 --- a/frontend/src/app/api/settings/route.ts +++ b/frontend/src/app/api/settings/route.ts @@ -22,7 +22,7 @@ export async function GET() { } export async function POST(request: NextRequest) { - const denied = requireApiAccess(request); + const denied = await requireApiAccess(request); if (denied) return denied; try { const update = (await request.json()) as Partial; diff --git a/frontend/src/app/api/setup/commissioning/commissioning-route.test.ts b/frontend/src/app/api/setup/commissioning/commissioning-route.test.ts new file mode 100644 index 000000000..b6e9962d8 --- /dev/null +++ b/frontend/src/app/api/setup/commissioning/commissioning-route.test.ts @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import { afterEach, beforeEach, describe, test } from "node:test"; +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createServer, type Server } from "node:http"; +import { NextRequest } from "next/server"; +import { GET, POST, PUT } from "./route"; +import type { + SetupCommissioningProfile, + SetupCommissioningSave, +} from "@local-studio/contracts/setup-commissioning"; + +let directory = ""; +let server: Server | null = null; +const original = { + dataDir: process.env.LOCAL_STUDIO_DATA_DIR, + nodeEnv: process.env.NODE_ENV, + allowlist: process.env.LOCAL_STUDIO_SETUP_PROBE_ALLOWLIST, + token: process.env.LOCAL_STUDIO_FRONTEND_TOKEN, +}; + +const request = (method = "GET", body?: unknown, headers?: HeadersInit) => + new NextRequest("http://localhost/api/setup/commissioning", { + method, + headers: { ...(body === undefined ? {} : { "content-type": "application/json" }), ...headers }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + +const saveInput = (profile: SetupCommissioningProfile): SetupCommissioningSave => ({ + revision: profile.revision, + requirements: profile.requirements, + oidc: { + enabled: profile.oidc.enabled, + kind: profile.oidc.kind, + issuer: profile.oidc.issuer, + client_id: profile.oidc.client_id, + audience: profile.oidc.audience, + tenant_or_realm: profile.oidc.tenant_or_realm, + }, + tensorprime_probes: profile.tensorprime_probes.map(({ probe: _probe, ...entry }) => entry), +}); + +beforeEach(async () => { + directory = await mkdtemp(path.join(tmpdir(), "setup-commissioning-")); + process.env.LOCAL_STUDIO_DATA_DIR = directory; + Reflect.set(process.env, "NODE_ENV", "test"); + process.env.LOCAL_STUDIO_SETUP_PROBE_ALLOWLIST = "127.0.0.1"; +}); + +afterEach(async () => { + if (server) { + await new Promise((resolve, reject) => + server?.close((error) => (error ? reject(error) : resolve())), + ); + server = null; + } + await rm(directory, { recursive: true, force: true }); + for (const [key, value] of Object.entries(original)) { + const name = + key === "dataDir" + ? "LOCAL_STUDIO_DATA_DIR" + : key === "nodeEnv" + ? "NODE_ENV" + : key === "allowlist" + ? "LOCAL_STUDIO_SETUP_PROBE_ALLOWLIST" + : "LOCAL_STUDIO_FRONTEND_TOKEN"; + if (value === undefined) delete process.env[name]; + else Reflect.set(process.env, name, value); + } +}); + +describe("setup commissioning route", () => { + test("fails closed for a shared deployment without enterprise OIDC", async () => { + Reflect.set(process.env, "NODE_ENV", "production"); + process.env.LOCAL_STUDIO_FRONTEND_TOKEN = "route-token"; + assert.equal((await GET(request())).status, 503); + }); + + test("persists only schema-approved metadata with revision conflict protection", async () => { + const initial = (await (await GET(request())).json()) as SetupCommissioningProfile; + assert.equal(initial.tensorprime_probes.length, 4); + const input = saveInput(initial); + const [first, second] = await Promise.all([ + PUT(request("PUT", input)), + PUT(request("PUT", input)), + ]); + assert.deepEqual([first.status, second.status].sort(), [200, 409]); + const content = await (await GET(request())).text(); + assert.equal(content.includes("api_key"), false); + assert.equal( + (await stat(path.join(directory, "setup-commissioning.json"))).mode & 0o777, + 0o600, + ); + }); + + test("rejects excess secret fields, embedded credentials, and oversized bodies", async () => { + const initial = (await (await GET(request())).json()) as SetupCommissioningProfile; + const input = saveInput(initial); + assert.equal( + (await PUT(request("PUT", { ...input, api_key: "must-not-be-accepted" }))).status, + 400, + ); + const embedded = { + ...input, + tensorprime_probes: input.tensorprime_probes.map((entry, index) => + index === 0 ? { ...entry, base_url: "http://user:secret@127.0.0.1" } : entry, + ), + }; + assert.equal((await PUT(request("PUT", embedded))).status, 400); + const insecureIssuer = { + ...input, + oidc: { + ...input.oidc, + enabled: true, + issuer: "http://login.example.com", + client_id: "client", + audience: "audience", + }, + }; + assert.equal((await PUT(request("PUT", insecureIssuer))).status, 400); + assert.equal( + (await PUT(request("PUT", {}, { "content-length": String(64 * 1024 + 1) }))).status, + 400, + ); + }); + + test("probes an allowlisted route and records redirect denial as contradicted evidence", async () => { + server = createServer((incoming, response) => { + if (incoming.url === "/redirect") { + response.writeHead(302, { location: "http://127.0.0.1/private" }).end(); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ data: [{ id: "model" }] })); + }); + await new Promise((resolve) => server?.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Fixture did not bind"); + const initial = (await (await GET(request())).json()) as SetupCommissioningProfile; + const baseInput = saveInput(initial); + const input = { + ...baseInput, + tensorprime_probes: baseInput.tensorprime_probes.map((entry) => + entry.id === "api" + ? { + ...entry, + base_url: `http://127.0.0.1:${address.port}`, + host_header: "", + probe_path: "/v1/models", + } + : entry, + ), + }; + assert.equal((await PUT(request("PUT", input))).status, 200); + const observed = (await ( + await POST(request("POST", { target: "api" })) + ).json()) as SetupCommissioningProfile; + assert.equal( + observed.tensorprime_probes.find(({ id }) => id === "api")?.probe.state, + "observed", + ); + const redirectBase = saveInput(observed); + const redirectedInput = { + ...redirectBase, + tensorprime_probes: redirectBase.tensorprime_probes.map((entry) => + entry.id === "api" ? { ...entry, probe_path: "/redirect" } : entry, + ), + }; + assert.equal((await PUT(request("PUT", redirectedInput))).status, 200); + const contradicted = (await ( + await POST(request("POST", { target: "api" })) + ).json()) as SetupCommissioningProfile; + assert.equal( + contradicted.tensorprime_probes.find(({ id }) => id === "api")?.probe.state, + "contradicted", + ); + }); +}); diff --git a/frontend/src/app/api/setup/commissioning/route.ts b/frontend/src/app/api/setup/commissioning/route.ts new file mode 100644 index 000000000..12d97bdc0 --- /dev/null +++ b/frontend/src/app/api/setup/commissioning/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + SetupCommissioningProbeInputSchema, + SetupCommissioningSaveSchema, +} from "@local-studio/contracts/setup-commissioning"; +import { Schema } from "effect"; +import { requireApiAccess } from "@/lib/auth/guard"; +import { + loadSetupCommissioningProfile, + saveSetupCommissioningProfile, + updateSetupCommissioningProbe, +} from "@/lib/setup-commissioning-store"; +import { probeSetupTarget } from "@/lib/setup-commissioning-probe"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const decodeSave = Schema.decodeUnknownSync(SetupCommissioningSaveSchema, { + onExcessProperty: "error", +}); +const decodeProbe = Schema.decodeUnknownSync(SetupCommissioningProbeInputSchema, { + onExcessProperty: "error", +}); + +const denied = (message: string, status = 400) => NextResponse.json({ error: message }, { status }); + +const boundedJson = async (request: NextRequest, maximum: number): Promise => { + const declared = Number(request.headers.get("content-length") ?? 0); + if (declared > maximum) throw new Error("Commissioning request body is too large"); + const text = await request.text(); + if (Buffer.byteLength(text, "utf8") > maximum) { + throw new Error("Commissioning request body is too large"); + } + return JSON.parse(text) as unknown; +}; + +export async function GET(request: NextRequest) { + const rejection = await requireApiAccess(request); + if (rejection) return rejection; + try { + return NextResponse.json(await loadSetupCommissioningProfile()); + } catch { + return denied("Commissioning profile could not be loaded", 500); + } +} + +export async function PUT(request: NextRequest) { + const rejection = await requireApiAccess(request); + if (rejection) return rejection; + try { + return NextResponse.json( + await saveSetupCommissioningProfile(decodeSave(await boundedJson(request, 64 * 1024))), + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Commissioning profile is invalid"; + return denied(message, message.includes("changed") ? 409 : 400); + } +} + +export async function POST(request: NextRequest) { + const rejection = await requireApiAccess(request); + if (rejection) return rejection; + let target: ReturnType["target"]; + try { + target = decodeProbe(await boundedJson(request, 1024)).target; + } catch { + return denied("Commissioning probe target is invalid"); + } + const profile = await loadSetupCommissioningProfile(); + return NextResponse.json( + await updateSetupCommissioningProbe(target, await probeSetupTarget(profile, target)), + ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index c725aaf70..29296db4d 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1,5 +1,6 @@ @import "../../node_modules/tailwindcss" source("../"); @import "@xterm/xterm/css/xterm.css"; +@import "./styles/globals/cortaix-fonts.css"; @import "./styles/globals/tokens.css"; @import "./styles/globals/base.css"; @import "./styles/globals/mobile.css"; diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index e7c0fce44..5ba8e7f28 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -3,6 +3,7 @@ import Script from "next/script"; import "./globals.css"; import { LeftSidebar } from "@/features/shell/left-sidebar"; import { getThemeBootstrapScript } from "@/lib/theme-runtime"; +import { BRAND_PROFILE } from "@/lib/brand-profile"; import { Providers } from "./providers"; export const viewport: Viewport = { @@ -11,12 +12,12 @@ export const viewport: Viewport = { maximumScale: 1, userScalable: false, viewportFit: "cover", - themeColor: "#0a0a0a", + themeColor: BRAND_PROFILE.themeColor, }; export const metadata: Metadata = { - title: "Local Studio", - description: "Model management for vLLM and SGLang", + title: BRAND_PROFILE.appName, + description: BRAND_PROFILE.description, // The manifest link is written by hand in below: it needs // crossorigin="use-credentials" (Next's `manifest` field can't set it), or an // access-gated deployment serves the login page instead of the manifest and @@ -24,15 +25,15 @@ export const metadata: Metadata = { appleWebApp: { capable: true, statusBarStyle: "black-translucent", - title: "Local Studio", + title: BRAND_PROFILE.appName, }, icons: { icon: [ - { url: "/mocks/logo-1.svg", type: "image/svg+xml" }, - { url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }, - { url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" }, + { url: BRAND_PROFILE.iconSvgPath, type: "image/svg+xml" }, + { url: BRAND_PROFILE.icon192Path, sizes: "192x192", type: "image/png" }, + { url: BRAND_PROFILE.icon512Path, sizes: "512x512", type: "image/png" }, ], - apple: [{ url: "/icons/apple-touch-icon.png", sizes: "180x180", type: "image/png" }], + apple: [{ url: BRAND_PROFILE.appleTouchIconPath, sizes: "180x180", type: "image/png" }], }, }; @@ -81,11 +82,16 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - - - + + + diff --git a/frontend/src/app/manifest.webmanifest/route.ts b/frontend/src/app/manifest.webmanifest/route.ts new file mode 100644 index 000000000..87d5348f9 --- /dev/null +++ b/frontend/src/app/manifest.webmanifest/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from "next/server"; +import { BRAND_PROFILE } from "@/lib/brand-profile"; + +export function GET(): Response { + return NextResponse.json( + { + id: "/", + name: BRAND_PROFILE.appName, + short_name: BRAND_PROFILE.shortName, + description: BRAND_PROFILE.description, + start_url: "/", + scope: "/", + display: "standalone", + background_color: BRAND_PROFILE.themeColor, + theme_color: BRAND_PROFILE.themeColor, + orientation: "portrait-primary", + icons: [ + { + src: BRAND_PROFILE.icon192Path, + sizes: "192x192", + type: "image/png", + purpose: "any", + }, + { + src: BRAND_PROFILE.icon512Path, + sizes: "512x512", + type: "image/png", + purpose: "any", + }, + { + src: BRAND_PROFILE.icon192Path, + sizes: "192x192", + type: "image/png", + purpose: "maskable", + }, + { + src: BRAND_PROFILE.icon512Path, + sizes: "512x512", + type: "image/png", + purpose: "maskable", + }, + ], + categories: ["utilities", "developer tools"], + screenshots: [], + shortcuts: [ + { + name: "Chat", + short_name: "Chat", + description: "Open the chat interface", + url: "/chat", + icons: [{ src: BRAND_PROFILE.icon192Path, sizes: "192x192" }], + }, + { + name: "Recipes", + short_name: "Recipes", + description: "Manage model recipes", + url: "/recipes", + icons: [{ src: BRAND_PROFILE.icon192Path, sizes: "192x192" }], + }, + ], + }, + { + headers: { + "content-type": "application/manifest+json; charset=utf-8", + "cache-control": "public, max-age=300", + }, + }, + ); +} diff --git a/frontend/src/app/science/page.tsx b/frontend/src/app/science/page.tsx new file mode 100644 index 000000000..e4c2bd318 --- /dev/null +++ b/frontend/src/app/science/page.tsx @@ -0,0 +1 @@ +export { default } from "@/features/science/scientific-workbench-page"; diff --git a/frontend/src/app/scientist/dashboard/page.tsx b/frontend/src/app/scientist/dashboard/page.tsx new file mode 100644 index 000000000..69d73555e --- /dev/null +++ b/frontend/src/app/scientist/dashboard/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { ScientistWalkthrough } from "@/features/scientist/scientist-walkthrough"; + +export default function ScientistDashboardPage() { + return ; +} diff --git a/frontend/src/app/scientist/experiments/page.tsx b/frontend/src/app/scientist/experiments/page.tsx new file mode 100644 index 000000000..f2df605f9 --- /dev/null +++ b/frontend/src/app/scientist/experiments/page.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { Suspense } from "react"; +import { useSearchParams } from "next/navigation"; +import { ExperimentTracker } from "@/features/scientist/experiment-tracker"; + +function ExperimentsContent() { + const searchParams = useSearchParams(); + const projectId = searchParams.get("project") ?? ""; + return ( +
+ {projectId ? ( + + ) : ( +
+ No project selected. Open a project first to track experiments. +
+ )} +
+ ); +} + +export default function ExperimentsPage() { + return ( + + + + ); +} diff --git a/frontend/src/app/scientist/page.tsx b/frontend/src/app/scientist/page.tsx new file mode 100644 index 000000000..1c27faf0b --- /dev/null +++ b/frontend/src/app/scientist/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { ScientistIntakeForm } from "@/features/scientist/scientist-intake-form"; + +export default function ScientistSetupPage() { + return ; +} diff --git a/frontend/src/app/scientist/process/page.tsx b/frontend/src/app/scientist/process/page.tsx new file mode 100644 index 000000000..aeedc6455 --- /dev/null +++ b/frontend/src/app/scientist/process/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { ProcessExpressionForm } from "@/features/scientist/process-expression-form"; + +export default function ProcessExpressionPage() { + return ; +} diff --git a/frontend/src/app/styles/globals/cortaix-fonts.css b/frontend/src/app/styles/globals/cortaix-fonts.css new file mode 100644 index 000000000..a60fd56ec --- /dev/null +++ b/frontend/src/app/styles/globals/cortaix-fonts.css @@ -0,0 +1,79 @@ +@font-face { + font-family: "Nunito Sans"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("/appliances/cortaix-factory/fonts/nunitosans-700.woff2") format("woff2"); +} + +@font-face { + font-family: "Nunito Sans"; + font-style: normal; + font-weight: 900; + font-display: swap; + src: url("/appliances/cortaix-factory/fonts/nunitosans-900.woff2") format("woff2"); +} + +@font-face { + font-family: "Roboto"; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url("/appliances/cortaix-factory/fonts/roboto-400-italic.woff2") format("woff2"); +} + +@font-face { + font-family: "Roboto"; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url("/appliances/cortaix-factory/fonts/roboto-300.woff2") format("woff2"); +} + +@font-face { + font-family: "Roboto"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("/appliances/cortaix-factory/fonts/roboto-400.woff2") format("woff2"); +} + +@font-face { + font-family: "Roboto"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("/appliances/cortaix-factory/fonts/roboto-500.woff2") format("woff2"); +} + +@font-face { + font-family: "Roboto"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("/appliances/cortaix-factory/fonts/roboto-700.woff2") format("woff2"); +} + +@font-face { + font-family: "Roboto Mono"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("/appliances/cortaix-factory/fonts/robotomono-400.woff2") format("woff2"); +} + +@font-face { + font-family: "Roboto Mono"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("/appliances/cortaix-factory/fonts/robotomono-500.woff2") format("woff2"); +} + +@font-face { + font-family: "Roboto Mono"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("/appliances/cortaix-factory/fonts/robotomono-700.woff2") format("woff2"); +} diff --git a/frontend/src/app/styles/globals/tokens.css b/frontend/src/app/styles/globals/tokens.css index b18eb3cb6..3e0dd44fb 100644 --- a/frontend/src/app/styles/globals/tokens.css +++ b/frontend/src/app/styles/globals/tokens.css @@ -165,6 +165,7 @@ ────────────────────────────────────────────────────────────────────────── */ .theme-zai-light, :root[data-theme="zai-light"], +:root[data-theme="cortaix-light"], :root[data-theme="paper"] { color-scheme: light; @@ -342,6 +343,7 @@ .theme-zai-dark, .dark, :root[data-theme="zai-dark"], +:root[data-theme="cortaix-dark"], :root[data-theme="omlx-dark"] { color-scheme: dark; @@ -521,6 +523,7 @@ .theme-zai-dark, .dark, :root[data-theme="zai-dark"], +:root[data-theme="cortaix-dark"], :root[data-theme="omlx-dark"] { --bg: var(--color-background); --fg: var(--color-foreground); @@ -556,6 +559,7 @@ .theme-zai-light, :root[data-theme="zai-light"], +:root[data-theme="cortaix-light"], :root[data-theme="paper"] { --bg: var(--color-background); --fg: var(--color-foreground); @@ -803,3 +807,358 @@ hr { color: transparent; animation: zai-gradient-pan 6s ease infinite; } + +:root[data-contrast-mode="high"] { + --color-border: #ffffff; + --color-border-light: #bfbfbf; + --color-border-heavy: #ffffff; + --color-border-hover: #ffffff; + --color-hover: #242424; + --color-selected: #333333; + --color-input-border: #ffffff; + --color-input-border-hover: #ffffff; + --color-input-border-focused: #ffffff; + --color-foreground-subtle: #e6e6e6; + --color-foreground-subtlest: #cfcfcf; + --link: #7dd3fc; + --color-destructive: #ff8a80; + --color-warning: #ffd166; + --color-success: #86efac; + --ui-border: var(--color-border); + --ui-separator: var(--color-border-light); +} + +:root[data-theme="zai-light"][data-contrast-mode="high"], +:root[data-theme="cortaix-light"][data-contrast-mode="high"], +:root[data-theme="paper"][data-contrast-mode="high"] { + color-scheme: light; + --color-background: #ffffff; + --color-background-win-alt: #ffffff; + --color-background-alt: #ffffff; + --color-header: #ffffff; + --color-panel: #ffffff; + --color-sidebar: #f2f2f2; + --color-surface: #f2f2f2; + --color-surface-hover: #e6e6e6; + --color-card: #ffffff; + --color-popover: #ffffff; + --color-popover-header: #f2f2f2; + --color-input: #ffffff; + --color-foreground: #000000; + --color-brand: #000000; + --bg: #ffffff; + --fg: #000000; + --surface: #f2f2f2; + --surface-2: #e6e6e6; + --surface-3: #f2f2f2; + --rail: #f2f2f2; + --sidebar-bg: #f2f2f2; + --composer: #f2f2f2; + --composer-footer: #f2f2f2; + --bubble: #e6e6e6; +} + +:root:not([data-theme="zai-light"]):not([data-theme="cortaix-light"]):not( + [data-theme="paper"] + )[data-contrast-mode="high"] { + color-scheme: dark; + --color-background: #000000; + --color-background-win-alt: #000000; + --color-background-alt: #000000; + --color-header: #000000; + --color-panel: #000000; + --color-sidebar: #0d0d0d; + --color-surface: #101010; + --color-surface-hover: #1a1a1a; + --color-card: #101010; + --color-popover: #0d0d0d; + --color-popover-header: #141414; + --color-input: #0d0d0d; + --color-foreground: #ffffff; + --color-brand: #ffffff; + --bg: #000000; + --fg: #ffffff; + --surface: #101010; + --surface-2: #1a1a1a; + --surface-3: #141414; + --rail: #0d0d0d; + --sidebar-bg: #0d0d0d; + --composer: #0d0d0d; + --composer-footer: #0d0d0d; + --bubble: #141414; +} + +@media (forced-colors: active), (prefers-contrast: more) { + :root[data-contrast-preference="auto"] { + --color-border: #ffffff; + --color-border-light: #bfbfbf; + --color-border-heavy: #ffffff; + --color-border-hover: #ffffff; + --color-hover: #242424; + --color-selected: #333333; + --color-input-border: #ffffff; + --color-input-border-hover: #ffffff; + --color-input-border-focused: #ffffff; + --color-foreground-subtle: #e6e6e6; + --color-foreground-subtlest: #cfcfcf; + } +} + +:root[data-appliance="cortaix-factory"] { + --radius-base: 6px; + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --surface-page: #f7f7f9; + --surface-card: #ffffff; + --surface-sunken: #eeeff3; + --surface-inverse: #131319; + --surface-brand: #041295; + --text-strong: #131319; + --text-body: #24252f; + --text-muted: #5d607e; + --text-disabled: #9a9db5; + --text-on-brand: #ffffff; + --text-link: #041295; + --brand: #041295; + --brand-hover: #030f77; + --brand-press: #020b59; + --color-brand: var(--brand); + --color-link: var(--text-link); + --link: var(--text-link); + --proof: #5b3a9e; + --proof-border: #8a63cc; + --proof-bg: #efeaf8; + --emergency: #b31800; + --emergency-bg: #fdecea; + --signal: #0584b7; + --signal-bright: #00b2dc; + --signal-deep: #2b276d; + --status-error: #e01e00; + --status-success: #178244; + --status-warning: #864900; + --status-info: #041295; + --rule: #1313191f; + --rule-solid: #13131938; + --border-strong: #1313198c; + --focus-ring: #6871bf; + --color-background: var(--surface-page); + --color-background-alt: var(--surface-sunken); + --color-background-win-alt: var(--surface-sunken); + --color-header: var(--surface-card); + --color-panel: var(--surface-page); + --color-sidebar: var(--surface-sunken); + --color-card: var(--surface-card); + --color-popover: var(--surface-card); + --color-popover-header: var(--surface-sunken); + --color-input: var(--surface-card); + --color-foreground: var(--text-strong); + --color-foreground-subtle: var(--text-muted); + --color-foreground-subtlest: #797d9c; + --color-border: var(--rule); + --color-border-light: #13131914; + --color-border-heavy: var(--rule-solid); + --color-border-hover: var(--rule-solid); + --color-destructive: var(--status-error); + --color-success: var(--status-success); + --color-warning: var(--status-warning); + --ui-bg: var(--surface-page); + --ui-fg: var(--text-strong); + --ui-muted: var(--text-muted); + --ui-surface: var(--surface-card); + --ui-border: var(--rule); + --ui-separator: #13131914; +} + +:root[data-appliance="cortaix-factory"][data-theme="cortaix-dark"] { + --surface-page: #131319; + --surface-card: #24252f; + --surface-sunken: #1b1c25; + --surface-inverse: #f7f7f9; + --surface-brand: #041295; + --text-strong: #f7f7f9; + --text-body: #dedee6; + --text-muted: #9a9db5; + --text-disabled: #4a4d65; + --text-on-brand: #ffffff; + --text-link: #9ba1d5; + --brand: #9ba1d5; + --brand-hover: #cdd0ea; + --brand-press: #b6bbe2; + --color-brand: var(--brand); + --color-link: var(--text-link); + --link: var(--text-link); + --proof: #c5b8e3; + --proof-border: #8a63cc; + --proof-bg: #261b40; + --emergency: #ec7866; + --emergency-bg: #3a1814; + --signal: #2fc0e2; + --signal-bright: #5fd2ec; + --signal-deep: #9ba1d5; + --status-error: #ec7866; + --status-success: #74b48f; + --status-warning: #e69433; + --status-info: #9ba1d5; + --rule: #f7f7f924; + --rule-solid: #f7f7f93d; + --border-strong: #f7f7f96b; + --focus-ring: #9ba1d5; + --color-background: var(--surface-page); + --color-background-alt: var(--surface-sunken); + --color-background-win-alt: var(--surface-sunken); + --color-header: var(--surface-card); + --color-panel: var(--surface-page); + --color-sidebar: var(--surface-sunken); + --color-card: var(--surface-card); + --color-popover: var(--surface-card); + --color-popover-header: var(--surface-sunken); + --color-input: var(--surface-sunken); + --color-foreground: var(--text-strong); + --color-foreground-subtle: var(--text-body); + --color-foreground-subtlest: var(--text-muted); + --color-border: var(--rule); + --color-border-light: #f7f7f914; + --color-border-heavy: var(--rule-solid); + --color-border-hover: var(--rule-solid); + --color-destructive: var(--status-error); + --color-success: var(--status-success); + --color-warning: var(--status-warning); + --ui-bg: var(--surface-page); + --ui-fg: var(--text-strong); + --ui-muted: var(--text-muted); + --ui-surface: var(--surface-card); + --ui-border: var(--rule); + --ui-separator: #f7f7f914; +} + +:root[data-appliance="cortaix-factory"][data-contrast-mode="high"] { + --surface-page: #000000; + --surface-card: #000000; + --surface-sunken: #000000; + --text-strong: #ffffff; + --text-body: #ffffff; + --text-muted: #ffffff; + --text-link: #cdd0ea; + --brand: #cdd0ea; + --proof: #e2dcf2; + --proof-border: #e2dcf2; + --proof-bg: #000000; + --emergency: #f3a599; + --emergency-bg: #000000; + --rule: #ffffff; + --rule-solid: #ffffff; + --border-strong: #ffffff; + --focus-ring: #cdd0ea; + --color-background: var(--surface-page); + --color-sidebar: var(--surface-sunken); + --color-card: var(--surface-card); + --color-foreground: var(--text-strong); + --color-foreground-subtle: var(--text-body); + --color-foreground-subtlest: var(--text-muted); + --color-border: var(--rule); + --color-border-light: var(--rule); + --color-border-heavy: var(--rule); + --ui-bg: var(--surface-page); + --ui-fg: var(--text-strong); + --ui-muted: var(--text-muted); + --ui-surface: var(--surface-card); + --ui-border: var(--rule); + --ui-separator: var(--rule); +} + +:root[data-appliance="cortaix-factory"][data-theme="cortaix-light"][data-contrast-mode="high"] { + --surface-page: #ffffff; + --surface-card: #ffffff; + --surface-sunken: #ffffff; + --text-strong: #131319; + --text-body: #131319; + --text-muted: #383a4b; + --text-link: #041295; + --brand: #041295; + --proof: #341f5c; + --proof-border: #341f5c; + --proof-bg: #ffffff; + --emergency: #861200; + --emergency-bg: #ffffff; + --rule: #000000; + --rule-solid: #000000; + --border-strong: #000000; + --focus-ring: #041295; +} + +@media (forced-colors: active) { + :root[data-appliance="cortaix-factory"] { + --surface-page: Canvas; + --surface-card: Canvas; + --surface-sunken: Canvas; + --text-strong: CanvasText; + --text-body: CanvasText; + --text-muted: CanvasText; + --text-link: LinkText; + --brand: LinkText; + --proof: CanvasText; + --proof-border: CanvasText; + --proof-bg: Canvas; + --emergency: CanvasText; + --emergency-bg: Canvas; + --signal: CanvasText; + --signal-bright: CanvasText; + --signal-deep: CanvasText; + --rule: CanvasText; + --rule-solid: CanvasText; + --border-strong: CanvasText; + --focus-ring: Highlight; + --color-background: Canvas; + --color-sidebar: Canvas; + --color-card: Canvas; + --color-foreground: CanvasText; + --color-foreground-subtle: CanvasText; + --color-foreground-subtlest: CanvasText; + --color-brand: LinkText; + --color-link: LinkText; + --link: LinkText; + --ui-bg: Canvas; + --ui-fg: CanvasText; + --ui-muted: CanvasText; + --ui-surface: Canvas; + --ui-border: CanvasText; + --ui-separator: CanvasText; + } +} + +.appliance-brand-mark__light, +.appliance-brand-mark__high-contrast, +.appliance-brand-mark__forced-colors { + display: none; +} + +:root[data-theme="cortaix-light"] .appliance-brand-mark__light { + display: block; +} + +:root[data-theme="cortaix-light"] .appliance-brand-mark__dark { + display: none; +} + +:root[data-contrast-mode="high"] .appliance-brand-mark__light, +:root[data-contrast-mode="high"] .appliance-brand-mark__dark { + display: none; +} + +:root[data-contrast-mode="high"] .appliance-brand-mark__high-contrast { + display: block; +} + +@media (forced-colors: active) { + .appliance-brand-mark__light, + .appliance-brand-mark__dark, + .appliance-brand-mark__high-contrast { + display: none; + } + + .appliance-brand-mark__forced-colors { + display: block; + forced-color-adjust: auto; + } +} diff --git a/frontend/src/app/welcome/page.tsx b/frontend/src/app/welcome/page.tsx new file mode 100644 index 000000000..0509436eb --- /dev/null +++ b/frontend/src/app/welcome/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { ModePicker } from "@/features/setup/mode-picker"; + +export default function WelcomePage() { + return ; +} diff --git a/frontend/src/features/agent/automations/automation-editor.tsx b/frontend/src/features/agent/automations/automation-editor.tsx index 83447a141..889d6ee48 100644 --- a/frontend/src/features/agent/automations/automation-editor.tsx +++ b/frontend/src/features/agent/automations/automation-editor.tsx @@ -5,6 +5,7 @@ import { useState } from "react"; import { Button, FormField, Input, Select, Textarea } from "@/ui"; import { Clock, Pause, Play, Plus, Trash2, X } from "@/ui/icon-registry"; import { useMountSubscription } from "@/hooks/use-mount-subscription"; +import { BRAND_PROFILE } from "@/lib/brand-profile"; import type { Automation, AutomationSchedule } from "@shared/agent/automation"; import type { AutomationModel } from "./automation-api"; import { @@ -124,7 +125,7 @@ export function AutomationEditor({