github-actions[bot] commited on
Commit
857a91b
·
0 Parent(s):

Deploy from 4dfd54f2

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +73 -0
  2. .gitattributes +34 -0
  3. .gitignore +31 -0
  4. Dockerfile +33 -0
  5. LICENSE +201 -0
  6. Makefile +282 -0
  7. README.md +36 -0
  8. api/audit.go +67 -0
  9. api/auth_test.go +106 -0
  10. api/cloud_test.go +119 -0
  11. api/handlers_auth.go +204 -0
  12. api/handlers_cloud.go +207 -0
  13. api/handlers_health.go +110 -0
  14. api/handlers_jobs.go +103 -0
  15. api/handlers_matrixshell.go +85 -0
  16. api/handlers_models.go +271 -0
  17. api/handlers_platform.go +84 -0
  18. api/handlers_sandbox.go +94 -0
  19. api/handlers_system.go +68 -0
  20. api/middleware_auth.go +71 -0
  21. api/middleware_ratelimit.go +86 -0
  22. api/models_test.go +79 -0
  23. api/openapi.go +83 -0
  24. api/openapi.yaml +651 -0
  25. api/production_test.go +160 -0
  26. api/server.go +180 -0
  27. api/server_test.go +88 -0
  28. app/main.py +29 -0
  29. assets/banner.svg +70 -0
  30. assets/logo.svg +25 -0
  31. clients/python/README.md +63 -0
  32. clients/python/pyproject.toml +65 -0
  33. clients/python/src/matrixcloud/__init__.py +18 -0
  34. clients/python/src/matrixcloud/cli.py +211 -0
  35. clients/python/src/matrixcloud/client.py +180 -0
  36. clients/python/src/matrixcloud/config.py +44 -0
  37. clients/python/src/matrixcloud/errors.py +18 -0
  38. clients/python/tests/test_client.py +92 -0
  39. clients/python/uv.lock +0 -0
  40. cmd/matrix-runtime/main.go +169 -0
  41. cmd/matrix-runtime/main_test.go +51 -0
  42. deploy/docker-compose/.env.example +11 -0
  43. deploy/docker-compose/docker-compose.yml +26 -0
  44. deploy/helm/matrix-runtime/Chart.yaml +16 -0
  45. deploy/helm/matrix-runtime/templates/_helpers.tpl +32 -0
  46. deploy/helm/matrix-runtime/templates/configmap.yaml +19 -0
  47. deploy/helm/matrix-runtime/templates/deployment.yaml +73 -0
  48. deploy/helm/matrix-runtime/templates/pvc.yaml +17 -0
  49. deploy/helm/matrix-runtime/templates/secret.yaml +19 -0
  50. deploy/helm/matrix-runtime/templates/service.yaml +15 -0
.env.example ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Matrix Runtime — example environment configuration
2
+ #
3
+ # Copy to .env and adjust. Every variable below is actually read by the binary
4
+ # (see internal/config, internal/auth, internal/email). NEVER commit real
5
+ # secrets — keep your filled-in .env out of version control.
6
+
7
+ # ---- Core --------------------------------------------------------------------
8
+ # Mode: local-dev | customer-agent | cloud-worker | hf-space
9
+ MATRIX_RUNTIME_MODE=customer-agent
10
+ # HTTP port. $PORT (PaaS convention) is also honored; if busy, the next free
11
+ # port is used automatically.
12
+ MATRIX_RUNTIME_PORT=8080
13
+ # Writable data directory (SQLite db, model cache, sandboxes, secret key).
14
+ MATRIX_RUNTIME_DATA_DIR=/var/lib/matrix-runtime
15
+ # Externally reachable base URL (used in links/emails). Optional.
16
+ MATRIX_RUNTIME_PUBLIC_URL=
17
+
18
+ # ---- API authentication ------------------------------------------------------
19
+ # Operator bearer token. REQUIRED in production modes — without it the API is
20
+ # unauthenticated for non-session callers (the readiness probe warns, and
21
+ # protected endpoints fail closed). Generate: openssl rand -hex 32
22
+ MATRIX_RUNTIME_API_TOKEN=
23
+
24
+ # ---- Database ----------------------------------------------------------------
25
+ # Default: a local SQLite file under the data dir. For multi-user / HA, set a
26
+ # PostgreSQL (e.g. Neon) URL — MatrixCloud isolates all objects in its own
27
+ # schema so the instance can be shared safely.
28
+ # MATRIXCLOUD_DATABASE_URL / MATRIX_RUNTIME_DB_URL / DATABASE_URL (first wins)
29
+ MATRIXCLOUD_DATABASE_URL=
30
+ MATRIXCLOUD_DB_SCHEMA=matrixcloud
31
+ # Override the SQLite path (when no Postgres URL is set).
32
+ MATRIX_RUNTIME_DB_PATH=
33
+
34
+ # ---- Secrets at rest ---------------------------------------------------------
35
+ # 32-byte key (hex or base64) used to encrypt BYO provider credentials
36
+ # (AES-256-GCM). If unset, a key is generated once into <data-dir>/secret.key.
37
+ # Set this explicitly in production so secrets survive a fresh data dir.
38
+ MATRIXCLOUD_SECRET_KEY=
39
+
40
+ # ---- Transactional email (Resend) -------------------------------------------
41
+ # Used for welcome/verification and password-reset emails. Without a key the
42
+ # sender runs in log-only mode (no email sent), which is fine for local dev.
43
+ RESEND_API_KEY=
44
+ MATRIXCLOUD_EMAIL_FROM=MatrixCloud <noreply@matrixhub.io>
45
+ # Public console URL used to build email links (reset/verify).
46
+ MATRIXCLOUD_APP_URL=https://cloud.matrixhub.io
47
+
48
+ # ---- Limits & retention ------------------------------------------------------
49
+ MATRIX_RUNTIME_MAX_TTL_SECONDS=600
50
+ MATRIX_RUNTIME_MAX_CONCURRENT_JOBS=5
51
+ # Requests/min per client IP on write+auth endpoints (0 disables).
52
+ MATRIX_RUNTIME_RATE_LIMIT_RPM=120
53
+ # Background retention (purge terminal jobs/scratch + prune old logs).
54
+ MATRIX_RUNTIME_JOB_RETENTION_HOURS=24
55
+ MATRIX_RUNTIME_LOG_RETENTION_HOURS=72
56
+ MATRIX_RUNTIME_CLEANUP_INTERVAL_MINUTES=15
57
+
58
+ # ---- MatrixShell -------------------------------------------------------------
59
+ # Executes commands in a local Python sandbox. OFF by default in production
60
+ # modes; ON by default only in local-dev. Set true/false to override.
61
+ MATRIX_SHELL_ENABLED=false
62
+
63
+ # ---- Control plane (hybrid join) --------------------------------------------
64
+ MATRIX_CLOUD_URL=https://cloud.matrixhub.io
65
+ MATRIX_RUNTIME_JOIN_TOKEN=
66
+ # Optional identity overrides.
67
+ MATRIX_RUNTIME_ID=
68
+ MATRIX_RUNTIME_WORKSPACE=
69
+
70
+ # ---- Hugging Face ------------------------------------------------------------
71
+ # Optional operator HF token (users can also bring their own per-workspace).
72
+ HF_TOKEN=
73
+ MATRIX_RUNTIME_HF_CACHE_DIR=
.gitattributes ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Keep source and text files LF on every platform (Windows/WSL included).
2
+ # This prevents CRLF checkouts that break `gofmt` and `make fmt-check`.
3
+
4
+ * text=auto eol=lf
5
+
6
+ *.go text eol=lf
7
+ *.mod text eol=lf
8
+ *.sum text eol=lf
9
+ *.js text eol=lf
10
+ *.jsx text eol=lf
11
+ *.css text eol=lf
12
+ *.html text eol=lf
13
+ *.json text eol=lf
14
+ *.yaml text eol=lf
15
+ *.yml text eol=lf
16
+ *.md text eol=lf
17
+ *.sh text eol=lf
18
+ *.svg text eol=lf
19
+ Makefile text eol=lf
20
+ Dockerfile text eol=lf
21
+ *.tmpl text eol=lf
22
+ *.in text eol=lf
23
+
24
+ # Shell scripts must stay LF and keep their exec bit semantics.
25
+ scripts/*.sh text eol=lf
26
+
27
+ # Binary assets — never normalise.
28
+ *.png binary
29
+ *.jpg binary
30
+ *.jpeg binary
31
+ *.gif binary
32
+ *.ico binary
33
+ *.woff binary
34
+ *.woff2 binary
.gitignore ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build output
2
+ /bin/
3
+ /dist/
4
+ /.devdata/
5
+
6
+ # Test / coverage artifacts
7
+ coverage.out
8
+ coverage.html
9
+
10
+ # Local runtime data
11
+ /var/lib/matrix-runtime/
12
+
13
+ # Editor / OS
14
+ *.swp
15
+ .DS_Store
16
+
17
+ # Local secrets / generated k8s secret
18
+ deploy/k8s/secret.yaml
19
+ deploy/docker-compose/.env
20
+
21
+ # Python (clients/python) — keep uv.lock, ignore envs/caches/builds
22
+ .venv/
23
+ **/.venv/
24
+ __pycache__/
25
+ *.py[cod]
26
+ .pytest_cache/
27
+ .ruff_cache/
28
+ .mypy_cache/
29
+ *.egg-info/
30
+ clients/python/dist/
31
+ clients/python/build/
Dockerfile ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MatrixCloud — Hugging Face Docker Space
2
+ #
3
+ # Self-contained: builds matrix-runtime from the source shipped in this Space
4
+ # repo (the deploy script / workflow push the full repo here). It does NOT clone
5
+ # GitHub, so it always builds exactly the committed code — no branch/cache drift.
6
+ # Serves the API + embedded console on port 7860 (HF's default app port); the
7
+ # launcher app/main.py preps the environment and execs the binary.
8
+
9
+ # ---- build ----
10
+ FROM golang:1.24-bookworm AS build
11
+ WORKDIR /src
12
+ COPY . .
13
+ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/matrix-runtime ./cmd/matrix-runtime
14
+
15
+ # ---- runtime ----
16
+ FROM debian:bookworm-slim
17
+ RUN apt-get update && apt-get install -y --no-install-recommends \
18
+ ca-certificates curl git nodejs npm python3 python3-pip pipx \
19
+ && rm -rf /var/lib/apt/lists/*
20
+ COPY --from=build /out/matrix-runtime /usr/local/bin/matrix-runtime
21
+ COPY requirements.txt /app/requirements.txt
22
+ RUN pip3 install --no-cache-dir --break-system-packages -r /app/requirements.txt
23
+ COPY app /app/app
24
+
25
+ # HF Spaces give you a writable /tmp; durable data should live in Postgres
26
+ # (set MATRIXCLOUD_DATABASE_URL). Listening port matches app_port in README.md.
27
+ ENV MATRIX_RUNTIME_MODE=cloud-worker \
28
+ MATRIX_RUNTIME_PORT=7860 \
29
+ MATRIX_RUNTIME_DATA_DIR=/tmp/matrixcloud \
30
+ MATRIX_SHELL_ENABLED=false \
31
+ HF_HOME=/tmp/hf
32
+ EXPOSE 7860
33
+ ENTRYPOINT ["python3", "/app/app/main.py"]
LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or Derivative
95
+ Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and do
117
+ not modify the License. You may add Your own attribution notices
118
+ within Derivative Works that You distribute, alongside or as an
119
+ addendum to the NOTICE text from the Work, provided that such
120
+ additional attribution notices cannot be construed as modifying
121
+ the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions for
125
+ use, reproduction, or distribution of Your modifications, or for any
126
+ such Derivative Works as a whole, provided Your use, reproduction,
127
+ and distribution of the Work otherwise complies with the conditions
128
+ stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Matrix Cloud / agent-matrix contributors
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
Makefile ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Matrix Runtime — Makefile
2
+ #
3
+ # Common targets:
4
+ # make help list every target
5
+ # make build build a local binary into ./bin
6
+ # make test full verification: fmt-check, vet, race tests + coverage
7
+ # make install build + install the binary (auto-uses sudo when needed)
8
+ # make uninstall remove an installed binary (and systemd unit if present)
9
+ #
10
+ # Install without root (user-local, no sudo):
11
+ # make install PREFIX=$HOME/.local
12
+ # Production install with systemd + data dir + service user (needs root):
13
+ # sudo make install INSTALL_SYSTEMD=1
14
+
15
+ SHELL := /usr/bin/env bash
16
+
17
+ # ---- Module / binary -------------------------------------------------------
18
+ MODULE := github.com/agent-matrix/matrix-runtime
19
+ BINARY := matrix-runtime
20
+ PKG := ./cmd/matrix-runtime
21
+ BIN_DIR := bin
22
+
23
+ # ---- Install locations (GNU-style; honour DESTDIR/PREFIX) ------------------
24
+ DESTDIR ?=
25
+ PREFIX ?= /usr/local
26
+ BINDIR ?= $(PREFIX)/bin
27
+
28
+ # Production install knobs
29
+ INSTALL_SYSTEMD ?= 0
30
+ SERVICE_USER ?= matrix
31
+ DATA_DIR ?= /var/lib/matrix-runtime
32
+ SYSTEMD_DIR ?= /etc/systemd/system
33
+ ENV_FILE ?= /etc/matrix-runtime/matrix-runtime.env
34
+
35
+ # ---- Version stamping ------------------------------------------------------
36
+ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
37
+ COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
38
+ DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
39
+
40
+ LDFLAGS := -s -w \
41
+ -X $(MODULE)/internal/config.Version=$(VERSION) \
42
+ -X $(MODULE)/internal/config.Commit=$(COMMIT) \
43
+ -X $(MODULE)/internal/config.Date=$(DATE)
44
+
45
+ # Static, reproducible production build.
46
+ GOFLAGS_PROD := -trimpath
47
+ CGO_ENABLED ?= 0
48
+
49
+ GO_FILES := $(shell find . -name '*.go' -not -path './legacy/*')
50
+
51
+ .DEFAULT_GOAL := build
52
+ # Run a shell script robustly even if a Windows/WSL checkout gave it CRLF line
53
+ # endings: strip CR first, then run with bash (no exec-bit dependency).
54
+ RUNSH = sh_strip_run() { sed -i 's/\r$$//' "$$1" 2>/dev/null || true; bash "$$1" "$${@:2}"; }; sh_strip_run
55
+
56
+ .PHONY: all build prod-build run test fmt fmt-check vet lint tidy coverage \
57
+ docker compose-up compose-down smoke install uninstall \
58
+ install-systemd release clean help web web-auto normalize \
59
+ venv py-install py-test py-lint setup
60
+
61
+ ## all: build the frontend bundle and the backend binary
62
+ all: web prod-build
63
+
64
+ ## normalize: strip CRLF -> LF from scripts, Makefile and Go (Windows/WSL fix)
65
+ normalize:
66
+ @for f in $$(find . -path ./legacy -prune -o \( -name '*.sh' -o -name '*.go' \) -print) Makefile; do \
67
+ sed -i 's/\r$$//' "$$f" 2>/dev/null || true; \
68
+ done
69
+ @echo "normalize: line endings set to LF"
70
+
71
+ ## web: build the enterprise console bundle (web/src -> web/static/app.js)
72
+ web:
73
+ @$(RUNSH) scripts/build-web.sh
74
+
75
+ # web-auto: rebuild the console bundle when a JS toolchain is available, else
76
+ # fall back to the committed web/static/app.js (already embedded in the binary).
77
+ # Best-effort: never fails the build (works offline / air-gapped / CRLF checkout).
78
+ web-auto:
79
+ @if command -v npx >/dev/null 2>&1; then \
80
+ $(RUNSH) scripts/build-web.sh || echo "web build failed — using committed web/static/app.js"; \
81
+ else \
82
+ echo "==> npx not found — using committed frontend (web/static/app.js, embedded)"; \
83
+ fi
84
+
85
+ ## build: compile a local binary into ./bin (backend API + embedded console)
86
+ build:
87
+ @mkdir -p $(BIN_DIR)
88
+ go build -trimpath -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/$(BINARY) $(PKG)
89
+ @echo "built $(BIN_DIR)/$(BINARY) ($(VERSION))"
90
+
91
+ ## prod-build: optimized, static, version-stamped binary
92
+ prod-build:
93
+ @mkdir -p $(BIN_DIR)
94
+ CGO_ENABLED=$(CGO_ENABLED) go build $(GOFLAGS_PROD) -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/$(BINARY) $(PKG)
95
+ @echo "built $(BIN_DIR)/$(BINARY) ($(VERSION), static)"
96
+
97
+ ## run: start the whole MatrixCloud (API + console + SQLite) in local-dev mode
98
+ run: web-auto
99
+ @echo "==> MatrixCloud starting — the console URL is printed below"
100
+ @echo " (if port 8080 is busy it falls back to the next free port; override with MATRIX_RUNTIME_PORT)"
101
+ go run -ldflags "$(LDFLAGS)" $(PKG) --mode local-dev
102
+
103
+ ## test: full verification gate (fmt, vet, race tests + coverage)
104
+ test: fmt-check vet
105
+ go test -race -covermode=atomic -coverprofile=coverage.out ./...
106
+ @go tool cover -func=coverage.out | tail -1
107
+
108
+ ## fmt: normalise line endings (CRLF->LF) and gofmt all non-legacy Go files
109
+ fmt:
110
+ @for f in $(GO_FILES); do sed -i 's/\r$$//' "$$f" 2>/dev/null || true; done
111
+ @gofmt -w $(GO_FILES)
112
+ @echo "gofmt: formatted $(words $(GO_FILES)) files (LF normalised)"
113
+
114
+ ## fmt-check: fail if any Go file has CRLF endings or is not gofmt-clean
115
+ fmt-check:
116
+ @crlf="$$(grep -lUP '\r$$' $(GO_FILES) 2>/dev/null || true)"; \
117
+ if [ -n "$$crlf" ]; then \
118
+ echo "These files have CRLF (Windows) line endings — gofmt requires LF:"; \
119
+ echo "$$crlf" | sed 's/^/ /'; \
120
+ echo ""; \
121
+ echo "Fix: make fmt"; \
122
+ echo "This repo ships .gitattributes to keep LF; if git re-introduces CRLF run:"; \
123
+ echo " git add --renormalize . && git checkout ."; \
124
+ exit 1; \
125
+ fi; \
126
+ unformatted="$$(gofmt -l $(GO_FILES))"; \
127
+ if [ -n "$$unformatted" ]; then \
128
+ echo "These files are not gofmt-clean:"; echo "$$unformatted" | sed 's/^/ /'; \
129
+ echo "Run 'make fmt'."; exit 1; \
130
+ fi; \
131
+ echo "gofmt: clean"
132
+
133
+ ## vet: run go vet
134
+ vet:
135
+ go vet ./...
136
+
137
+ ## lint: run golangci-lint if installed (optional)
138
+ lint:
139
+ @if command -v golangci-lint >/dev/null 2>&1; then \
140
+ golangci-lint run ./...; \
141
+ else \
142
+ echo "golangci-lint not installed; skipping (using 'go vet' via 'make vet')"; \
143
+ fi
144
+
145
+ ## tidy: ensure go.mod is tidy
146
+ tidy:
147
+ go mod tidy
148
+
149
+ ## coverage: open a coverage report
150
+ coverage: test
151
+ go tool cover -html=coverage.out -o coverage.html
152
+ @echo "wrote coverage.html"
153
+
154
+ # ---- Python client (clients/python) — uv-managed .venv ---------------------
155
+ PY_DIR := clients/python
156
+
157
+ ## venv: create clients/python/.venv with uv (fast) and install the client
158
+ venv:
159
+ @if command -v uv >/dev/null 2>&1; then \
160
+ cd $(PY_DIR) && uv sync --extra dev && \
161
+ echo "venv ready: $(PY_DIR)/.venv (run: source $(PY_DIR)/.venv/bin/activate)"; \
162
+ else \
163
+ echo "uv not found — install it for fast setup: https://docs.astral.sh/uv/"; \
164
+ echo "falling back to python venv + pip…"; \
165
+ cd $(PY_DIR) && python3 -m venv .venv && ./.venv/bin/pip install -e '.[dev]'; \
166
+ fi
167
+
168
+ ## py-install: install the matrixcloud client + CLI into the .venv
169
+ py-install: venv
170
+
171
+ ## py-test: run the Python client test suite
172
+ py-test:
173
+ @cd $(PY_DIR) && if command -v uv >/dev/null 2>&1; then uv run pytest; else ./.venv/bin/pytest; fi
174
+
175
+ ## py-lint: lint the Python client (ruff)
176
+ py-lint:
177
+ @cd $(PY_DIR) && if command -v uv >/dev/null 2>&1; then uv run ruff check .; else ./.venv/bin/ruff check .; fi
178
+
179
+ ## setup: build the runtime AND set up the Python client venv (full dev setup)
180
+ setup: build venv
181
+ @echo "setup complete — run 'make run' to start MatrixCloud, or 'cd $(PY_DIR) && uv run mxc status'"
182
+
183
+ ## docker: build the container image
184
+ docker:
185
+ docker build -t $(BINARY):$(VERSION) -t $(BINARY):local .
186
+
187
+ ## compose-up / compose-down: docker compose helpers
188
+ compose-up:
189
+ docker compose -f deploy/docker-compose/docker-compose.yml up --build
190
+
191
+ compose-down:
192
+ docker compose -f deploy/docker-compose/docker-compose.yml down
193
+
194
+ ## smoke: run the smoke test against a running instance
195
+ smoke:
196
+ @$(RUNSH) scripts/smoke-test.sh
197
+
198
+ ## install: install the runtime (backend API + embedded MatrixCloud console).
199
+ ## Auto-elevates with sudo when the target dir needs root. Use
200
+ ## PREFIX=$HOME/.local for a user install with no root. Set
201
+ ## INSTALL_SYSTEMD=1 (as root) to also install a systemd service.
202
+ install: web-auto prod-build
203
+ @bindir="$(DESTDIR)$(BINDIR)"; sudo=""; \
204
+ if [ "$$(id -u)" = "0" ] || [ -w "$$(dirname "$$bindir")" ] || [ -w "$$bindir" ]; then \
205
+ : ; \
206
+ elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then \
207
+ sudo="sudo"; echo "==> $$bindir needs root — using passwordless sudo"; \
208
+ else \
209
+ bindir="$$HOME/.local/bin"; \
210
+ echo "==> $(BINDIR) needs root and passwordless sudo is unavailable —"; \
211
+ echo " installing to $$bindir instead (override with PREFIX=... or run: sudo make install)"; \
212
+ fi; \
213
+ echo "==> installing $(BINARY) (backend + embedded console) to $$bindir"; \
214
+ $$sudo install -d "$$bindir" && \
215
+ $$sudo install -m 0755 "$(BIN_DIR)/$(BINARY)" "$$bindir/$(BINARY)" && \
216
+ echo "installed: $$bindir/$(BINARY)"; \
217
+ case ":$$PATH:" in *":$$bindir:"*) ;; *) echo "note: add $$bindir to your PATH — e.g. echo 'export PATH=\"$$bindir:\$$PATH\"' >> ~/.bashrc";; esac; \
218
+ echo "run it: $(BINARY) --mode local-dev # then open http://localhost:8080"
219
+ @if [ "$(INSTALL_SYSTEMD)" = "1" ]; then $(MAKE) install-systemd; else \
220
+ echo "(systemd service available via: sudo make install INSTALL_SYSTEMD=1)"; fi
221
+
222
+ ## install-systemd: provision service user, data dir, env file and unit
223
+ install-systemd:
224
+ @echo "==> provisioning systemd service"
225
+ @id -u "$(SERVICE_USER)" >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin "$(SERVICE_USER)"
226
+ install -d -o "$(SERVICE_USER)" -g "$(SERVICE_USER)" "$(DESTDIR)$(DATA_DIR)"
227
+ install -d "$(DESTDIR)$(dir $(ENV_FILE))"
228
+ @if [ ! -f "$(DESTDIR)$(ENV_FILE)" ]; then \
229
+ install -m 0640 deploy/systemd/matrix-runtime.env.example "$(DESTDIR)$(ENV_FILE)"; \
230
+ echo "installed env file: $(DESTDIR)$(ENV_FILE) (edit before starting)"; \
231
+ else echo "env file exists, leaving as-is: $(DESTDIR)$(ENV_FILE)"; fi
232
+ install -d "$(DESTDIR)$(SYSTEMD_DIR)"
233
+ sed -e "s|@BINDIR@|$(BINDIR)|g" \
234
+ -e "s|@ENV_FILE@|$(ENV_FILE)|g" \
235
+ -e "s|@DATA_DIR@|$(DATA_DIR)|g" \
236
+ -e "s|@SERVICE_USER@|$(SERVICE_USER)|g" \
237
+ deploy/systemd/matrix-runtime.service.in > "$(DESTDIR)$(SYSTEMD_DIR)/matrix-runtime.service"
238
+ @echo "installed unit: $(DESTDIR)$(SYSTEMD_DIR)/matrix-runtime.service"
239
+ @echo "next: sudo systemctl daemon-reload && sudo systemctl enable --now matrix-runtime"
240
+
241
+ ## uninstall: remove the installed binary (and systemd unit if present)
242
+ uninstall:
243
+ @bindir="$(DESTDIR)$(BINDIR)"; sudo=""; \
244
+ if [ "$$(id -u)" != "0" ] && [ -e "$$bindir/$(BINARY)" ] && [ ! -w "$$bindir" ]; then \
245
+ command -v sudo >/dev/null 2>&1 && sudo="sudo"; fi; \
246
+ $$sudo rm -f "$$bindir/$(BINARY)"; \
247
+ if [ -f "$(DESTDIR)$(SYSTEMD_DIR)/matrix-runtime.service" ]; then \
248
+ $$sudo rm -f "$(DESTDIR)$(SYSTEMD_DIR)/matrix-runtime.service"; \
249
+ echo "removed systemd unit (run: sudo systemctl daemon-reload)"; \
250
+ fi; \
251
+ echo "uninstalled $(BINARY)"
252
+
253
+ ## release: cross-compile static binaries into ./dist
254
+ release:
255
+ @mkdir -p dist
256
+ @for osarch in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do \
257
+ os=$${osarch%/*}; arch=$${osarch#*/}; \
258
+ out="dist/$(BINARY)-$$os-$$arch"; \
259
+ echo "building $$out"; \
260
+ CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch \
261
+ go build $(GOFLAGS_PROD) -ldflags "$(LDFLAGS)" -o "$$out" $(PKG) || exit 1; \
262
+ done
263
+ @echo "release artifacts in ./dist"
264
+
265
+ ## clean: remove build artifacts
266
+ clean:
267
+ rm -rf $(BIN_DIR) dist coverage.out coverage.html
268
+
269
+ ## help: list targets
270
+ help:
271
+ @printf "\n \033[1;32mMatrix Runtime\033[0m — the execution plane for Matrix Cloud\n"
272
+ @printf " backend API + embedded MatrixCloud console + SQLite, in one binary\n\n"
273
+ @printf " \033[1mTargets\033[0m\n"
274
+ @grep -E '^## [a-z][a-z-]*:' $(MAKEFILE_LIST) \
275
+ | sed -E 's/^## ([a-z-]+): /\1|/' \
276
+ | awk -F'|' '{printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
277
+ @printf "\n \033[1mQuick start\033[0m\n"
278
+ @printf " make run start everything → http://localhost:8080\n"
279
+ @printf " make build build bin/matrix-runtime\n"
280
+ @printf " make test fmt-check + vet + race tests + coverage\n"
281
+ @printf " sudo make install install to /usr/local/bin\n"
282
+ @printf " make install PREFIX=\$$HOME/.local user install, no root\n\n"
README.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: MatrixCloud
3
+ emoji: 🟢
4
+ colorFrom: green
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: apache-2.0
10
+ short_description: "Self-hostable execution plane for AI: MCP, models, agents."
11
+ ---
12
+
13
+ # MatrixCloud
14
+
15
+ The self-hostable **execution plane** for MatrixCloud: run MCP server sandboxes,
16
+ inspect Hugging Face models, use the MatrixShell operator terminal, and bring
17
+ your own HF token to run HF LLMs — all from one console.
18
+
19
+ This Space runs the single static `matrix-runtime` binary (API + embedded React
20
+ console). The container launcher is `app/main.py`; the binary is built from the
21
+ [`agent-matrix/matrix-runtime`](https://github.com/agent-matrix/matrix-runtime)
22
+ repository at build time.
23
+
24
+ ## Configure (Space → Settings → Variables and secrets)
25
+
26
+ **Secrets**
27
+ - `MATRIXCLOUD_DATABASE_URL` — PostgreSQL/Neon DSN (durable accounts across
28
+ rebuilds). Without it, data lives in ephemeral `/tmp` and resets on restart.
29
+ - `MATRIXCLOUD_SECRET_KEY` — `openssl rand -hex 32` (stable encryption key).
30
+ - `RESEND_API_KEY` — optional; enables welcome/password-reset emails.
31
+
32
+ **Variables**
33
+ - `MATRIXCLOUD_DB_SCHEMA=matrixcloud`
34
+ - `MATRIXCLOUD_APP_URL=https://<owner>-matrixcloud.hf.space`
35
+
36
+ Open the Space URL, create an account, and you're in.
api/audit.go ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "log"
5
+ "net"
6
+ "net/http"
7
+ "strings"
8
+
9
+ "github.com/agent-matrix/matrix-runtime/internal/store"
10
+ )
11
+
12
+ // clientIP best-effort extracts the caller IP, honoring common proxy headers
13
+ // (the API typically runs behind Cloudflare / an ingress).
14
+ func clientIP(r *http.Request) string {
15
+ if v := r.Header.Get("CF-Connecting-IP"); v != "" {
16
+ return v
17
+ }
18
+ if v := r.Header.Get("X-Forwarded-For"); v != "" {
19
+ return strings.TrimSpace(strings.Split(v, ",")[0])
20
+ }
21
+ if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
22
+ return host
23
+ }
24
+ return r.RemoteAddr
25
+ }
26
+
27
+ // audit records a sensitive action. It is best-effort: a nil store or a write
28
+ // error never affects the request outcome (only logged).
29
+ func (s *Server) audit(r *http.Request, workspaceID, actor, action, target, status string, meta map[string]any) {
30
+ if s.store == nil {
31
+ return
32
+ }
33
+ if err := s.store.RecordAudit(store.AuditEvent{
34
+ WorkspaceID: workspaceID,
35
+ Actor: actor,
36
+ Action: action,
37
+ Target: target,
38
+ IP: clientIP(r),
39
+ Status: status,
40
+ Meta: meta,
41
+ }); err != nil {
42
+ log.Printf("audit: could not record %s: %v", action, err)
43
+ }
44
+ }
45
+
46
+ // truncate shortens s to at most n characters for audit targets.
47
+ func truncate(s string, n int) string {
48
+ if len(s) <= n {
49
+ return s
50
+ }
51
+ return s[:n] + "…"
52
+ }
53
+
54
+ // handleCloudAudit returns the workspace's recent audit events.
55
+ func (s *Server) handleCloudAudit(w http.ResponseWriter, r *http.Request) {
56
+ u, ok := s.currentUser(r)
57
+ if !ok {
58
+ writeError(w, http.StatusUnauthorized, "not authenticated")
59
+ return
60
+ }
61
+ events, err := s.store.ListAudit(u.WorkspaceID, 200)
62
+ if err != nil {
63
+ writeError(w, http.StatusInternalServerError, "could not load audit log")
64
+ return
65
+ }
66
+ writeJSON(w, http.StatusOK, map[string]any{"events": events})
67
+ }
api/auth_test.go ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "path/filepath"
8
+ "testing"
9
+
10
+ "github.com/agent-matrix/matrix-runtime/internal/config"
11
+ "github.com/agent-matrix/matrix-runtime/internal/jobs"
12
+ "github.com/agent-matrix/matrix-runtime/internal/store"
13
+ )
14
+
15
+ func authServer(t *testing.T) *Server {
16
+ t.Helper()
17
+ cfg := config.Defaults(config.ModeLocalDev)
18
+ cfg.DataDir = t.TempDir()
19
+ st, err := store.Open(filepath.Join(t.TempDir(), "auth.db"))
20
+ if err != nil {
21
+ t.Fatal(err)
22
+ }
23
+ t.Cleanup(func() { _ = st.Close() })
24
+ return NewServer(cfg, jobs.NewManager(cfg), st)
25
+ }
26
+
27
+ func do(t *testing.T, srv *Server, method, path, token, body string) (*httptest.ResponseRecorder, map[string]any) {
28
+ t.Helper()
29
+ var rdr *httptest.ResponseRecorder = httptest.NewRecorder()
30
+ req := httptest.NewRequest(method, path, jsonBody(body))
31
+ if token != "" {
32
+ req.Header.Set("Authorization", "Bearer "+token)
33
+ }
34
+ srv.Handler().ServeHTTP(rdr, req)
35
+ var out map[string]any
36
+ _ = json.Unmarshal(rdr.Body.Bytes(), &out)
37
+ return rdr, out
38
+ }
39
+
40
+ func TestAuthFlow(t *testing.T) {
41
+ srv := authServer(t)
42
+
43
+ // signup
44
+ rec, body := do(t, srv, http.MethodPost, "/v1/auth/signup", "", `{"name":"Neo","email":"neo@zion.io","password":"redpill1"}`)
45
+ if rec.Code != http.StatusCreated {
46
+ t.Fatalf("signup status %d: %v", rec.Code, body)
47
+ }
48
+ token, _ := body["token"].(string)
49
+ if token == "" {
50
+ t.Fatal("expected a session token")
51
+ }
52
+ user, _ := body["user"].(map[string]any)
53
+ if user["role"] != "Owner" || user["workspace"] == "" {
54
+ t.Errorf("unexpected user %v", user)
55
+ }
56
+
57
+ // me with token
58
+ rec, body = do(t, srv, http.MethodGet, "/v1/auth/me", token, "")
59
+ if rec.Code != http.StatusOK {
60
+ t.Fatalf("me status %d", rec.Code)
61
+ }
62
+
63
+ // me without token -> 401
64
+ rec, _ = do(t, srv, http.MethodGet, "/v1/auth/me", "", "")
65
+ if rec.Code != http.StatusUnauthorized {
66
+ t.Fatalf("me-no-token status %d, want 401", rec.Code)
67
+ }
68
+
69
+ // duplicate signup -> 409
70
+ rec, _ = do(t, srv, http.MethodPost, "/v1/auth/signup", "", `{"name":"X","email":"neo@zion.io","password":"redpill1"}`)
71
+ if rec.Code != http.StatusConflict {
72
+ t.Fatalf("dup signup status %d, want 409", rec.Code)
73
+ }
74
+
75
+ // wrong login -> 401
76
+ rec, _ = do(t, srv, http.MethodPost, "/v1/auth/login", "", `{"email":"neo@zion.io","password":"nope"}`)
77
+ if rec.Code != http.StatusUnauthorized {
78
+ t.Fatalf("bad login status %d, want 401", rec.Code)
79
+ }
80
+
81
+ // correct login -> 200
82
+ rec, body = do(t, srv, http.MethodPost, "/v1/auth/login", "", `{"email":"neo@zion.io","password":"redpill1"}`)
83
+ if rec.Code != http.StatusOK {
84
+ t.Fatalf("login status %d", rec.Code)
85
+ }
86
+
87
+ // logout invalidates the session
88
+ rec, _ = do(t, srv, http.MethodPost, "/v1/auth/logout", token, "")
89
+ if rec.Code != http.StatusOK {
90
+ t.Fatalf("logout status %d", rec.Code)
91
+ }
92
+ rec, _ = do(t, srv, http.MethodGet, "/v1/auth/me", token, "")
93
+ if rec.Code != http.StatusUnauthorized {
94
+ t.Fatalf("me after logout status %d, want 401", rec.Code)
95
+ }
96
+ }
97
+
98
+ func TestAuthUnavailableWithoutStore(t *testing.T) {
99
+ cfg := config.Defaults(config.ModeLocalDev)
100
+ cfg.DataDir = t.TempDir()
101
+ srv := NewServer(cfg, jobs.NewManager(cfg), nil)
102
+ rec, _ := do(t, srv, http.MethodPost, "/v1/auth/login", "", `{"email":"a@b.io","password":"x"}`)
103
+ if rec.Code != http.StatusServiceUnavailable {
104
+ t.Fatalf("expected 503 without store, got %d", rec.Code)
105
+ }
106
+ }
api/cloud_test.go ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "net/http"
5
+ "testing"
6
+ )
7
+
8
+ func TestCloudRuntimeAndProviderFlow(t *testing.T) {
9
+ srv := authServer(t)
10
+
11
+ // Create a workspace owner and grab a session token.
12
+ rec, body := do(t, srv, http.MethodPost, "/v1/auth/signup", "", `{"name":"Maya","email":"maya@acme.io","password":"hunter22"}`)
13
+ if rec.Code != http.StatusCreated {
14
+ t.Fatalf("signup %d: %v", rec.Code, body)
15
+ }
16
+ token, _ := body["token"].(string)
17
+
18
+ // Mint a join token.
19
+ rec, body = do(t, srv, http.MethodPost, "/v1/cloud/join-tokens", token, `{"label":"my space","max_uses":1,"ttl_minutes":60}`)
20
+ if rec.Code != http.StatusCreated {
21
+ t.Fatalf("mint join token %d: %v", rec.Code, body)
22
+ }
23
+ secret, _ := body["secret"].(string)
24
+ if secret == "" {
25
+ t.Fatal("expected a join-token secret")
26
+ }
27
+
28
+ // Register a runtime using the join token (no session — simulates a remote Space).
29
+ rec, body = do(t, srv, http.MethodPost, "/v1/cloud/runtimes/register", "",
30
+ `{"join_token":"`+secret+`","name":"maya-space","kind":"hf-space","hf_space":"maya/matrixcloud","caps":["mcp.test"]}`)
31
+ if rec.Code != http.StatusCreated {
32
+ t.Fatalf("register runtime %d: %v", rec.Code, body)
33
+ }
34
+ runtimeToken, _ := body["runtime_token"].(string)
35
+ if runtimeToken == "" {
36
+ t.Fatal("expected a runtime token")
37
+ }
38
+
39
+ // The join token is single-use now.
40
+ rec, _ = do(t, srv, http.MethodPost, "/v1/cloud/runtimes/register", "",
41
+ `{"join_token":"`+secret+`","name":"again"}`)
42
+ if rec.Code != http.StatusUnauthorized {
43
+ t.Errorf("reused join token should 401, got %d", rec.Code)
44
+ }
45
+
46
+ // Heartbeat with the runtime token.
47
+ rec, _ = do(t, srv, http.MethodPost, "/v1/cloud/runtimes/heartbeat", runtimeToken, `{"status":"online","caps":["mcp.test","model.inspect"]}`)
48
+ if rec.Code != http.StatusOK {
49
+ t.Fatalf("heartbeat %d", rec.Code)
50
+ }
51
+ // Bad runtime token rejected.
52
+ rec, _ = do(t, srv, http.MethodPost, "/v1/cloud/runtimes/heartbeat", "bogus", `{"status":"online"}`)
53
+ if rec.Code != http.StatusUnauthorized {
54
+ t.Errorf("bad heartbeat token should 401, got %d", rec.Code)
55
+ }
56
+
57
+ // The owner sees the runtime online.
58
+ rec, body = do(t, srv, http.MethodGet, "/v1/cloud/runtimes", token, "")
59
+ if rec.Code != http.StatusOK {
60
+ t.Fatalf("list runtimes %d", rec.Code)
61
+ }
62
+ rts, _ := body["runtimes"].([]any)
63
+ if len(rts) != 1 {
64
+ t.Fatalf("expected 1 runtime, got %d", len(rts))
65
+ }
66
+
67
+ // Store a BYO Hugging Face credential; only a hint is ever returned.
68
+ rec, body = do(t, srv, http.MethodPost, "/v1/cloud/providers", token,
69
+ `{"provider":"huggingface","label":"default","secret":"hf_supersecret9","meta":{"default_model":"Qwen/Qwen2.5-7B-Instruct"}}`)
70
+ if rec.Code != http.StatusCreated {
71
+ t.Fatalf("set provider %d: %v", rec.Code, body)
72
+ }
73
+ rec, body = do(t, srv, http.MethodGet, "/v1/cloud/providers", token, "")
74
+ provs, _ := body["providers"].([]any)
75
+ if len(provs) != 1 {
76
+ t.Fatalf("expected 1 provider, got %v", body)
77
+ }
78
+ p0, _ := provs[0].(map[string]any)
79
+ if hint, _ := p0["hint"].(string); hint == "" || hint == "hf_supersecret9" {
80
+ t.Errorf("provider must expose only a hint, got %v", p0["hint"])
81
+ }
82
+
83
+ // Cloud endpoints require auth.
84
+ rec, _ = do(t, srv, http.MethodGet, "/v1/cloud/runtimes", "", "")
85
+ if rec.Code != http.StatusUnauthorized {
86
+ t.Errorf("unauth list should 401, got %d", rec.Code)
87
+ }
88
+ }
89
+
90
+ func TestAuditLogRecordsActions(t *testing.T) {
91
+ srv := authServer(t)
92
+ _, body := do(t, srv, http.MethodPost, "/v1/auth/signup", "", `{"name":"Auditor","email":"audit@acme.io","password":"hunter22"}`)
93
+ token, _ := body["token"].(string)
94
+
95
+ // Generate a couple of auditable actions.
96
+ do(t, srv, http.MethodPost, "/v1/cloud/join-tokens", token, `{"label":"x","max_uses":1}`)
97
+ do(t, srv, http.MethodPost, "/v1/cloud/providers", token, `{"provider":"huggingface","secret":"hf_abc1234"}`)
98
+
99
+ rec, b := do(t, srv, http.MethodGet, "/v1/cloud/audit", token, "")
100
+ if rec.Code != http.StatusOK {
101
+ t.Fatalf("audit list %d: %v", rec.Code, b)
102
+ }
103
+ events, _ := b["events"].([]any)
104
+ actions := map[string]bool{}
105
+ for _, e := range events {
106
+ if m, ok := e.(map[string]any); ok {
107
+ actions[m["action"].(string)] = true
108
+ }
109
+ }
110
+ for _, want := range []string{"user.signup", "runtime.join_token.created", "provider.credential.added"} {
111
+ if !actions[want] {
112
+ t.Errorf("missing audit action %q (got %v)", want, actions)
113
+ }
114
+ }
115
+ // Unauthenticated access is rejected.
116
+ if rec, _ := do(t, srv, http.MethodGet, "/v1/cloud/audit", "", ""); rec.Code != http.StatusUnauthorized {
117
+ t.Errorf("unauth audit = %d, want 401", rec.Code)
118
+ }
119
+ }
api/handlers_auth.go ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "net/http"
8
+ "regexp"
9
+ "strings"
10
+ "time"
11
+
12
+ "github.com/agent-matrix/matrix-runtime/internal/store"
13
+ )
14
+
15
+ var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
16
+
17
+ // userJSON is the public shape of a user returned to the console.
18
+ func userJSON(u *store.User) map[string]any {
19
+ return map[string]any{
20
+ "id": u.ID,
21
+ "name": u.Name,
22
+ "email": u.Email,
23
+ "role": u.Role,
24
+ "workspace": u.WorkspaceName,
25
+ "workspace_slug": u.WorkspaceSlug,
26
+ "workspace_id": u.WorkspaceID,
27
+ }
28
+ }
29
+
30
+ func (s *Server) requireStore(w http.ResponseWriter) bool {
31
+ if s.store == nil {
32
+ writeError(w, http.StatusServiceUnavailable, "user store is not available")
33
+ return false
34
+ }
35
+ return true
36
+ }
37
+
38
+ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
39
+ if !s.requireStore(w) {
40
+ return
41
+ }
42
+ var req struct{ Name, Email, Password, Workspace string }
43
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
44
+ writeError(w, http.StatusBadRequest, "invalid JSON body")
45
+ return
46
+ }
47
+ req.Email = strings.TrimSpace(req.Email)
48
+ if !emailRe.MatchString(req.Email) {
49
+ writeError(w, http.StatusBadRequest, "enter a valid email address")
50
+ return
51
+ }
52
+ if len(req.Password) < 6 {
53
+ writeError(w, http.StatusBadRequest, "password must be at least 6 characters")
54
+ return
55
+ }
56
+ if strings.TrimSpace(req.Name) == "" {
57
+ writeError(w, http.StatusBadRequest, "name is required")
58
+ return
59
+ }
60
+ u, err := s.store.Signup(req.Name, req.Email, req.Password, req.Workspace)
61
+ if err != nil {
62
+ if errors.Is(err, store.ErrEmailTaken) {
63
+ writeError(w, http.StatusConflict, err.Error())
64
+ return
65
+ }
66
+ writeError(w, http.StatusInternalServerError, "could not create account")
67
+ return
68
+ }
69
+ token, err := s.store.CreateSession(u.ID)
70
+ if err != nil {
71
+ writeError(w, http.StatusInternalServerError, "could not start session")
72
+ return
73
+ }
74
+ s.audit(r, u.WorkspaceID, u.ID, "user.signup", u.Email, "success", nil)
75
+ // Best-effort welcome + verification email (never blocks signup success).
76
+ if verifyTok, verr := s.store.CreateEmailVerification(u.ID, 24*time.Hour); verr == nil {
77
+ go func() {
78
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
79
+ defer cancel()
80
+ _ = s.email.SendWelcome(ctx, u.Email, u.Name, verifyTok)
81
+ }()
82
+ }
83
+ writeJSON(w, http.StatusCreated, map[string]any{"token": token, "user": userJSON(u)})
84
+ }
85
+
86
+ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
87
+ if !s.requireStore(w) {
88
+ return
89
+ }
90
+ var req struct{ Email, Password string }
91
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
92
+ writeError(w, http.StatusBadRequest, "invalid JSON body")
93
+ return
94
+ }
95
+ u, err := s.store.Login(req.Email, req.Password)
96
+ if err != nil {
97
+ s.audit(r, "", strings.ToLower(strings.TrimSpace(req.Email)), "user.login", req.Email, "failure", nil)
98
+ writeError(w, http.StatusUnauthorized, "invalid email or password")
99
+ return
100
+ }
101
+ token, err := s.store.CreateSession(u.ID)
102
+ if err != nil {
103
+ writeError(w, http.StatusInternalServerError, "could not start session")
104
+ return
105
+ }
106
+ s.audit(r, u.WorkspaceID, u.ID, "user.login", u.Email, "success", nil)
107
+ writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": userJSON(u)})
108
+ }
109
+
110
+ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
111
+ if !s.requireStore(w) {
112
+ return
113
+ }
114
+ u, err := s.store.UserBySession(bearer(r))
115
+ if err != nil {
116
+ writeError(w, http.StatusUnauthorized, "not authenticated")
117
+ return
118
+ }
119
+ writeJSON(w, http.StatusOK, map[string]any{"user": userJSON(u)})
120
+ }
121
+
122
+ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
123
+ if !s.requireStore(w) {
124
+ return
125
+ }
126
+ token := bearer(r)
127
+ all := r.URL.Query().Get("all") == "true"
128
+ if all {
129
+ if u, err := s.store.UserBySession(token); err == nil {
130
+ _ = s.store.DeleteUserSessions(u.ID)
131
+ }
132
+ }
133
+ _ = s.store.DeleteSession(token)
134
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
135
+ }
136
+
137
+ func bearer(r *http.Request) string {
138
+ return strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
139
+ }
140
+
141
+ // handleForgotPassword always responds 200 (to avoid leaking which emails are
142
+ // registered). When the email matches a user, a reset link is emailed via
143
+ // Resend with a single-use, 1-hour token.
144
+ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
145
+ if !s.requireStore(w) {
146
+ return
147
+ }
148
+ var req struct{ Email string }
149
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
150
+ writeError(w, http.StatusBadRequest, "invalid JSON body")
151
+ return
152
+ }
153
+ const ok = "If an account exists for that email, a reset link is on its way."
154
+ u, err := s.store.UserByEmail(req.Email)
155
+ if err != nil {
156
+ // Don't reveal absence; respond identically.
157
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "message": ok})
158
+ return
159
+ }
160
+ token, err := s.store.CreatePasswordReset(u.ID, time.Hour)
161
+ if err != nil {
162
+ writeError(w, http.StatusInternalServerError, "could not start password reset")
163
+ return
164
+ }
165
+ if err := s.email.SendPasswordReset(r.Context(), u.Email, token); err != nil {
166
+ writeError(w, http.StatusBadGateway, "could not send reset email")
167
+ return
168
+ }
169
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "message": ok})
170
+ }
171
+
172
+ // handleResetPassword consumes a reset token and sets a new password.
173
+ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
174
+ if !s.requireStore(w) {
175
+ return
176
+ }
177
+ var req struct{ Token, Password string }
178
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
179
+ writeError(w, http.StatusBadRequest, "invalid JSON body")
180
+ return
181
+ }
182
+ if err := s.store.ResetPassword(strings.TrimSpace(req.Token), req.Password); err != nil {
183
+ writeError(w, http.StatusBadRequest, err.Error())
184
+ return
185
+ }
186
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "Your password has been updated. Please sign in."})
187
+ }
188
+
189
+ // handleVerifyEmail consumes an email-verification token.
190
+ func (s *Server) handleVerifyEmail(w http.ResponseWriter, r *http.Request) {
191
+ if !s.requireStore(w) {
192
+ return
193
+ }
194
+ var req struct{ Token string }
195
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
196
+ writeError(w, http.StatusBadRequest, "invalid JSON body")
197
+ return
198
+ }
199
+ if _, err := s.store.VerifyEmail(strings.TrimSpace(req.Token)); err != nil {
200
+ writeError(w, http.StatusBadRequest, err.Error())
201
+ return
202
+ }
203
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "Email verified."})
204
+ }
api/handlers_cloud.go ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "encoding/json"
5
+ "errors"
6
+ "net/http"
7
+ "time"
8
+
9
+ "github.com/agent-matrix/matrix-runtime/internal/store"
10
+ )
11
+
12
+ // staleAfter marks a runtime offline if it hasn't sent a heartbeat recently.
13
+ const runtimeStaleAfter = 90 * time.Second
14
+
15
+ // handleCloudListRuntimes returns the calling workspace's registered runtimes.
16
+ func (s *Server) handleCloudListRuntimes(w http.ResponseWriter, r *http.Request) {
17
+ u, ok := s.currentUser(r)
18
+ if !ok {
19
+ writeError(w, http.StatusUnauthorized, "not authenticated")
20
+ return
21
+ }
22
+ list, err := s.store.ListRuntimes(u.WorkspaceID, runtimeStaleAfter)
23
+ if err != nil {
24
+ writeError(w, http.StatusInternalServerError, "could not list runtimes")
25
+ return
26
+ }
27
+ writeJSON(w, http.StatusOK, map[string]any{"runtimes": list})
28
+ }
29
+
30
+ // handleCloudRegisterRuntime is called by a remote sandbox (e.g. a duplicated
31
+ // HF Space) presenting a workspace join token. It registers the runtime and
32
+ // returns a long-lived runtime token used for subsequent heartbeats.
33
+ func (s *Server) handleCloudRegisterRuntime(w http.ResponseWriter, r *http.Request) {
34
+ if !s.requireStore(w) {
35
+ return
36
+ }
37
+ var req struct {
38
+ JoinToken string `json:"join_token"`
39
+ Name string `json:"name"`
40
+ Kind string `json:"kind"`
41
+ Mode string `json:"mode"`
42
+ URL string `json:"url"`
43
+ HFSpace string `json:"hf_space"`
44
+ Region string `json:"region"`
45
+ Version string `json:"version"`
46
+ Caps []string `json:"caps"`
47
+ }
48
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
49
+ writeError(w, http.StatusBadRequest, "invalid JSON body")
50
+ return
51
+ }
52
+ workspaceID, err := s.store.RedeemJoinToken(req.JoinToken)
53
+ if err != nil {
54
+ writeError(w, http.StatusUnauthorized, err.Error())
55
+ return
56
+ }
57
+ if req.Name == "" {
58
+ req.Name = req.HFSpace
59
+ }
60
+ if req.Kind == "" {
61
+ req.Kind = "hf-space"
62
+ }
63
+ rt, token, err := s.store.RegisterRuntime(store.Runtime{
64
+ WorkspaceID: workspaceID,
65
+ Name: req.Name,
66
+ Mode: req.Mode,
67
+ Kind: req.Kind,
68
+ URL: req.URL,
69
+ HFSpace: req.HFSpace,
70
+ Region: req.Region,
71
+ Version: req.Version,
72
+ Caps: req.Caps,
73
+ })
74
+ if err != nil {
75
+ writeError(w, http.StatusInternalServerError, "could not register runtime")
76
+ return
77
+ }
78
+ s.audit(r, workspaceID, rt.ID, "runtime.registered", rt.Name, "success", map[string]any{"kind": rt.Kind, "hf_space": rt.HFSpace})
79
+ writeJSON(w, http.StatusCreated, map[string]any{"runtime": rt, "runtime_token": token})
80
+ }
81
+
82
+ // handleCloudHeartbeat updates a runtime's status using its runtime token
83
+ // (Authorization: Bearer <runtime_token>).
84
+ func (s *Server) handleCloudHeartbeat(w http.ResponseWriter, r *http.Request) {
85
+ if !s.requireStore(w) {
86
+ return
87
+ }
88
+ var req struct {
89
+ Status string `json:"status"`
90
+ Caps []string `json:"caps"`
91
+ }
92
+ _ = json.NewDecoder(r.Body).Decode(&req)
93
+ rt, err := s.store.HeartbeatRuntime(bearer(r), req.Status, req.Caps)
94
+ if err != nil {
95
+ if errors.Is(err, store.ErrNotFound) {
96
+ writeError(w, http.StatusUnauthorized, "invalid runtime token")
97
+ return
98
+ }
99
+ writeError(w, http.StatusInternalServerError, "could not record heartbeat")
100
+ return
101
+ }
102
+ writeJSON(w, http.StatusOK, map[string]any{"runtime": rt})
103
+ }
104
+
105
+ // handleCloudListJoinTokens lists the workspace's active join tokens.
106
+ func (s *Server) handleCloudListJoinTokens(w http.ResponseWriter, r *http.Request) {
107
+ u, ok := s.currentUser(r)
108
+ if !ok {
109
+ writeError(w, http.StatusUnauthorized, "not authenticated")
110
+ return
111
+ }
112
+ list, err := s.store.ListJoinTokens(u.WorkspaceID)
113
+ if err != nil {
114
+ writeError(w, http.StatusInternalServerError, "could not list join tokens")
115
+ return
116
+ }
117
+ writeJSON(w, http.StatusOK, map[string]any{"join_tokens": list})
118
+ }
119
+
120
+ // handleCloudMintJoinToken mints a new join token for the workspace. The secret
121
+ // is returned exactly once.
122
+ func (s *Server) handleCloudMintJoinToken(w http.ResponseWriter, r *http.Request) {
123
+ u, ok := s.currentUser(r)
124
+ if !ok {
125
+ writeError(w, http.StatusUnauthorized, "not authenticated")
126
+ return
127
+ }
128
+ var req struct {
129
+ Label string `json:"label"`
130
+ MaxUses int `json:"max_uses"`
131
+ TTLMinutes int `json:"ttl_minutes"`
132
+ }
133
+ _ = json.NewDecoder(r.Body).Decode(&req)
134
+ ttl := time.Duration(req.TTLMinutes) * time.Minute
135
+ if req.TTLMinutes == 0 {
136
+ ttl = 24 * time.Hour
137
+ }
138
+ jt, secret, err := s.store.MintJoinToken(u.WorkspaceID, u.ID, req.Label, req.MaxUses, ttl)
139
+ if err != nil {
140
+ writeError(w, http.StatusInternalServerError, "could not mint join token")
141
+ return
142
+ }
143
+ s.audit(r, u.WorkspaceID, u.ID, "runtime.join_token.created", jt.ID, "success", map[string]any{"label": jt.Label, "max_uses": jt.MaxUses})
144
+ writeJSON(w, http.StatusCreated, map[string]any{"join_token": jt, "secret": secret})
145
+ }
146
+
147
+ // handleCloudListProviders lists BYO provider credentials (hints only).
148
+ func (s *Server) handleCloudListProviders(w http.ResponseWriter, r *http.Request) {
149
+ u, ok := s.currentUser(r)
150
+ if !ok {
151
+ writeError(w, http.StatusUnauthorized, "not authenticated")
152
+ return
153
+ }
154
+ list, err := s.store.ListProviderCredentials(u.WorkspaceID)
155
+ if err != nil {
156
+ writeError(w, http.StatusInternalServerError, "could not list providers")
157
+ return
158
+ }
159
+ writeJSON(w, http.StatusOK, map[string]any{"providers": list})
160
+ }
161
+
162
+ // handleCloudSetProvider stores (encrypted) a BYO provider token — e.g. a user
163
+ // plugging in their own Hugging Face account to use HF LLMs inside MatrixCloud.
164
+ func (s *Server) handleCloudSetProvider(w http.ResponseWriter, r *http.Request) {
165
+ u, ok := s.currentUser(r)
166
+ if !ok {
167
+ writeError(w, http.StatusUnauthorized, "not authenticated")
168
+ return
169
+ }
170
+ var req struct {
171
+ Provider string `json:"provider"`
172
+ Label string `json:"label"`
173
+ Secret string `json:"secret"`
174
+ Meta map[string]any `json:"meta"`
175
+ }
176
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
177
+ writeError(w, http.StatusBadRequest, "invalid JSON body")
178
+ return
179
+ }
180
+ if req.Provider == "" || req.Secret == "" {
181
+ writeError(w, http.StatusBadRequest, "provider and secret are required")
182
+ return
183
+ }
184
+ pc, err := s.store.SetProviderCredential(u.WorkspaceID, u.ID, req.Provider, req.Label, req.Secret, req.Meta)
185
+ if err != nil {
186
+ writeError(w, http.StatusInternalServerError, "could not save credential")
187
+ return
188
+ }
189
+ s.audit(r, u.WorkspaceID, u.ID, "provider.credential.added", req.Provider, "success", map[string]any{"label": pc.Label, "hint": pc.Hint})
190
+ writeJSON(w, http.StatusCreated, map[string]any{"provider": pc})
191
+ }
192
+
193
+ // handleCloudUsage returns the workspace's usage in the trailing 30 days.
194
+ func (s *Server) handleCloudUsage(w http.ResponseWriter, r *http.Request) {
195
+ u, ok := s.currentUser(r)
196
+ if !ok {
197
+ writeError(w, http.StatusUnauthorized, "not authenticated")
198
+ return
199
+ }
200
+ since := time.Now().Add(-30 * 24 * time.Hour)
201
+ used, err := s.store.UsageSince(u.WorkspaceID, since)
202
+ if err != nil {
203
+ writeError(w, http.StatusInternalServerError, "could not load usage")
204
+ return
205
+ }
206
+ writeJSON(w, http.StatusOK, map[string]any{"since": since.UTC().Format(time.RFC3339), "usage": used})
207
+ }
api/handlers_health.go ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+ "os"
7
+ "path/filepath"
8
+ "time"
9
+
10
+ "github.com/agent-matrix/matrix-runtime/internal/config"
11
+ "github.com/agent-matrix/matrix-runtime/internal/runtime"
12
+ )
13
+
14
+ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
15
+ writeJSON(w, http.StatusOK, map[string]any{
16
+ "status": "ok",
17
+ "runtime_id": s.cfg.EffectiveRuntimeID(),
18
+ "mode": string(s.cfg.Mode),
19
+ "version": config.Version,
20
+ })
21
+ }
22
+
23
+ func (s *Server) handleCapabilities(w http.ResponseWriter, _ *http.Request) {
24
+ writeJSON(w, http.StatusOK, runtime.BuildCapabilities(s.cfg))
25
+ }
26
+
27
+ // handleVersion returns build/version metadata for monitoring and support.
28
+ func (s *Server) handleVersion(w http.ResponseWriter, _ *http.Request) {
29
+ writeJSON(w, http.StatusOK, map[string]any{
30
+ "name": "matrix-runtime",
31
+ "version": config.Version,
32
+ "commit": config.Commit,
33
+ "build_time": config.Date,
34
+ "mode": string(s.cfg.Mode),
35
+ })
36
+ }
37
+
38
+ // handleReady is the readiness probe: it verifies core dependencies (database
39
+ // reachable, data directory writable, job manager running, mode valid) and
40
+ // reports non-fatal misconfiguration as warnings. It returns 200 when ready and
41
+ // 503 when a core check fails, so Kubernetes can gate traffic.
42
+ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
43
+ checks := map[string]bool{}
44
+ warnings := []map[string]string{}
45
+ warn := func(code, msg string) { warnings = append(warnings, map[string]string{"code": code, "message": msg}) }
46
+
47
+ // Mode valid.
48
+ checks["mode_valid"] = s.cfg.Mode.Valid()
49
+
50
+ // Job manager running.
51
+ checks["job_manager"] = s.manager != nil
52
+
53
+ // Data directory writable.
54
+ checks["data_dir_writable"] = dirWritable(s.cfg.DataDir)
55
+
56
+ // Database reachable (only when a store is configured).
57
+ if s.store != nil {
58
+ ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
59
+ defer cancel()
60
+ checks["database"] = s.store.Ping(ctx) == nil
61
+ } else {
62
+ checks["database"] = false
63
+ warn("store_unavailable", "User store is not available; auth endpoints return 503.")
64
+ }
65
+
66
+ // Non-fatal security warnings.
67
+ if s.cfg.IsProduction() && s.cfg.APIToken == "" {
68
+ warn("api_token_missing", "MATRIX_RUNTIME_API_TOKEN is not set; the API is unauthenticated for non-session callers.")
69
+ }
70
+ if s.cfg.MatrixShellEnabled {
71
+ warn("matrixshell_enabled", "MatrixShell is enabled; it executes commands in a local sandbox.")
72
+ }
73
+ if s.cfg.IsProduction() && s.cfg.DatabaseURL == "" {
74
+ warn("sqlite_in_use", "Using SQLite; configure MATRIX_RUNTIME_DATABASE_URL (Postgres) for multi-user/HA deployments.")
75
+ }
76
+ if s.cfg.Mode == config.ModeLocalDev {
77
+ warn("local_dev_mode", "Runtime is in local-dev mode; not hardened for production.")
78
+ }
79
+
80
+ ready := true
81
+ for _, ok := range checks {
82
+ if !ok {
83
+ ready = false
84
+ }
85
+ }
86
+ status := http.StatusOK
87
+ if !ready {
88
+ status = http.StatusServiceUnavailable
89
+ }
90
+ writeJSON(w, status, map[string]any{"ready": ready, "checks": checks, "warnings": warnings})
91
+ }
92
+
93
+ // dirWritable reports whether dir exists (creating it if needed) and accepts a
94
+ // temporary file write.
95
+ func dirWritable(dir string) bool {
96
+ if dir == "" {
97
+ return false
98
+ }
99
+ if err := os.MkdirAll(dir, 0o755); err != nil {
100
+ return false
101
+ }
102
+ f, err := os.CreateTemp(dir, ".ready-*")
103
+ if err != nil {
104
+ return false
105
+ }
106
+ name := f.Name()
107
+ _ = f.Close()
108
+ _ = os.Remove(filepath.Join(dir, filepath.Base(name)))
109
+ return true
110
+ }
api/handlers_jobs.go ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "encoding/json"
5
+ "errors"
6
+ "net/http"
7
+
8
+ "github.com/agent-matrix/matrix-runtime/internal/jobs"
9
+ "github.com/agent-matrix/matrix-runtime/internal/logs"
10
+ )
11
+
12
+ func (s *Server) handleCreateJob(w http.ResponseWriter, r *http.Request) {
13
+ var req jobs.CreateRequest
14
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
15
+ writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
16
+ return
17
+ }
18
+ j, err := s.manager.Create(req)
19
+ if err != nil {
20
+ if errors.Is(err, jobs.ErrUnknownType) {
21
+ writeError(w, http.StatusBadRequest, err.Error())
22
+ return
23
+ }
24
+ writeError(w, http.StatusUnprocessableEntity, err.Error())
25
+ return
26
+ }
27
+ writeJSON(w, http.StatusAccepted, map[string]any{
28
+ "job_id": j.ID,
29
+ "status": j.Status(),
30
+ "events_url": "/v1/jobs/" + j.ID + "/events",
31
+ })
32
+ }
33
+
34
+ func (s *Server) handleListJobs(w http.ResponseWriter, _ *http.Request) {
35
+ writeJSON(w, http.StatusOK, map[string]any{"jobs": s.manager.List()})
36
+ }
37
+
38
+ func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
39
+ j, ok := s.manager.Get(r.PathValue("job_id"))
40
+ if !ok {
41
+ writeError(w, http.StatusNotFound, "job not found")
42
+ return
43
+ }
44
+ writeJSON(w, http.StatusOK, j.Snapshot())
45
+ }
46
+
47
+ func (s *Server) handleDeleteJob(w http.ResponseWriter, r *http.Request) {
48
+ status, ok := s.manager.Cancel(r.PathValue("job_id"))
49
+ if !ok {
50
+ writeError(w, http.StatusNotFound, "job not found")
51
+ return
52
+ }
53
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "status": status})
54
+ }
55
+
56
+ func (s *Server) handleJobEvents(w http.ResponseWriter, r *http.Request) {
57
+ j, ok := s.manager.Get(r.PathValue("job_id"))
58
+ if !ok {
59
+ writeError(w, http.StatusNotFound, "job not found")
60
+ return
61
+ }
62
+ streamEvents(w, r, j.Bus())
63
+ }
64
+
65
+ // streamEvents writes a job's event history and live updates as Server-Sent
66
+ // Events until the bus closes or the client disconnects.
67
+ func streamEvents(w http.ResponseWriter, r *http.Request, bus *logs.Bus) {
68
+ flusher, ok := w.(http.Flusher)
69
+ if !ok {
70
+ writeError(w, http.StatusInternalServerError, "streaming unsupported")
71
+ return
72
+ }
73
+ w.Header().Set("Content-Type", "text/event-stream")
74
+ w.Header().Set("Cache-Control", "no-cache")
75
+ w.Header().Set("Connection", "keep-alive")
76
+ w.WriteHeader(http.StatusOK)
77
+
78
+ history, ch, cancel := bus.Subscribe()
79
+ defer cancel()
80
+
81
+ send := func(e logs.Event) {
82
+ b, _ := json.Marshal(e)
83
+ _, _ = w.Write([]byte("data: "))
84
+ _, _ = w.Write(b)
85
+ _, _ = w.Write([]byte("\n\n"))
86
+ flusher.Flush()
87
+ }
88
+
89
+ for _, e := range history {
90
+ send(e)
91
+ }
92
+ for {
93
+ select {
94
+ case <-r.Context().Done():
95
+ return
96
+ case e, open := <-ch:
97
+ if !open {
98
+ return
99
+ }
100
+ send(e)
101
+ }
102
+ }
103
+ }
api/handlers_matrixshell.go ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "net/http"
8
+ "time"
9
+
10
+ "github.com/agent-matrix/matrix-runtime/internal/jobs"
11
+ "github.com/agent-matrix/matrix-runtime/internal/matrixshell"
12
+ )
13
+
14
+ // requireMatrixShell rejects requests when MatrixShell is disabled. It executes
15
+ // commands in a local sandbox, so it is off by default in production modes.
16
+ func (s *Server) requireMatrixShell(w http.ResponseWriter) bool {
17
+ if !s.cfg.MatrixShellEnabled {
18
+ writeError(w, http.StatusForbidden, "MatrixShell is disabled (set MATRIX_SHELL_ENABLED=true to enable)")
19
+ return false
20
+ }
21
+ return true
22
+ }
23
+
24
+ // handleMatrixShellStatus reports whether MatrixShell is installed in the local
25
+ // Python sandbox, with its version and paths.
26
+ func (s *Server) handleMatrixShellStatus(w http.ResponseWriter, r *http.Request) {
27
+ if !s.requireMatrixShell(w) {
28
+ return
29
+ }
30
+ ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
31
+ defer cancel()
32
+ writeJSON(w, http.StatusOK, matrixshell.GetStatus(ctx, s.cfg.DataDir))
33
+ }
34
+
35
+ // handleMatrixShellInstall starts a job that creates the sandbox venv and
36
+ // installs MatrixShell from git, streaming real output over SSE.
37
+ func (s *Server) handleMatrixShellInstall(w http.ResponseWriter, _ *http.Request) {
38
+ if !s.requireMatrixShell(w) {
39
+ return
40
+ }
41
+ job, err := s.manager.Create(jobs.CreateRequest{Type: jobs.TypeMatrixShellInstall, TTLSeconds: 600})
42
+ if err != nil {
43
+ writeError(w, http.StatusUnprocessableEntity, err.Error())
44
+ return
45
+ }
46
+ writeJSON(w, http.StatusAccepted, map[string]any{
47
+ "job_id": job.ID,
48
+ "events_url": "/v1/jobs/" + job.ID + "/events",
49
+ })
50
+ }
51
+
52
+ // handleMatrixShellExec runs a command inside the local MatrixShell sandbox
53
+ // (real execution with the venv on PATH) and returns stdout/stderr/exit.
54
+ func (s *Server) handleMatrixShellExec(w http.ResponseWriter, r *http.Request) {
55
+ if !s.requireMatrixShell(w) {
56
+ return
57
+ }
58
+ var req struct {
59
+ Command string `json:"command"`
60
+ }
61
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
62
+ writeError(w, http.StatusBadRequest, "invalid JSON body")
63
+ return
64
+ }
65
+ wsID, actor := "", "operator"
66
+ if u, ok := s.currentUser(r); ok {
67
+ wsID, actor = u.WorkspaceID, u.ID
68
+ }
69
+ ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
70
+ defer cancel()
71
+ res, err := matrixshell.Exec(ctx, s.cfg.DataDir, req.Command)
72
+ if err != nil {
73
+ if errors.Is(err, matrixshell.ErrBlocked) {
74
+ s.audit(r, wsID, actor, "matrixshell.exec", truncate(req.Command, 200), "failure", map[string]any{"reason": "denylist"})
75
+ writeError(w, http.StatusForbidden, "refused by safety denylist")
76
+ return
77
+ }
78
+ writeError(w, http.StatusUnprocessableEntity, err.Error())
79
+ return
80
+ }
81
+ s.audit(r, wsID, actor, "matrixshell.exec", truncate(req.Command, 200), "success", map[string]any{"exit_code": res.ExitCode})
82
+ writeJSON(w, http.StatusOK, map[string]any{
83
+ "command": req.Command, "stdout": res.Stdout, "stderr": res.Stderr, "exit_code": res.ExitCode,
84
+ })
85
+ }
api/handlers_models.go ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "strconv"
7
+ "strings"
8
+
9
+ "github.com/agent-matrix/matrix-runtime/internal/hf"
10
+ "github.com/agent-matrix/matrix-runtime/internal/jobs"
11
+ "github.com/agent-matrix/matrix-runtime/internal/models"
12
+ "github.com/agent-matrix/matrix-runtime/internal/store"
13
+ )
14
+
15
+ // currentUser resolves the session bearer token to a user, or returns false.
16
+ func (s *Server) currentUser(r *http.Request) (*store.User, bool) {
17
+ if s.store == nil {
18
+ return nil, false
19
+ }
20
+ u, err := s.store.UserBySession(bearer(r))
21
+ if err != nil {
22
+ return nil, false
23
+ }
24
+ return u, true
25
+ }
26
+
27
+ // handleHFSearch proxies the Hugging Face model search server-side (avoiding
28
+ // browser CORS) for the console's generic Import Model flow. On any failure it
29
+ // returns 200 with live=false and an empty list so the UI can fall back to
30
+ // sample data gracefully.
31
+ func (s *Server) handleHFSearch(w http.ResponseWriter, r *http.Request) {
32
+ q := r.URL.Query().Get("q")
33
+ task := r.URL.Query().Get("task")
34
+ limit := 16
35
+ if v := r.URL.Query().Get("limit"); v != "" {
36
+ if n, err := strconv.Atoi(v); err == nil {
37
+ limit = n
38
+ }
39
+ }
40
+ items, err := hf.NewClient(s.cfg.HFToken).Search(r.Context(), q, task, limit)
41
+ if err != nil {
42
+ writeJSON(w, http.StatusOK, map[string]any{"items": []any{}, "live": false, "error": err.Error()})
43
+ return
44
+ }
45
+ writeJSON(w, http.StatusOK, map[string]any{"items": items, "live": true})
46
+ }
47
+
48
+ // resolveReq describes a generic model source to resolve into a profile preview.
49
+ type resolveReq struct {
50
+ SourceType string `json:"sourceType"`
51
+ SourceURI string `json:"sourceUri"`
52
+ Provider string `json:"provider"`
53
+ ExternalID string `json:"externalId"` // e.g. HF model id
54
+ Model string `json:"model"` // e.g. hf:owner/name
55
+ Path string `json:"path"`
56
+ Branch string `json:"branch"`
57
+ Private bool `json:"private"`
58
+ }
59
+
60
+ // handleResolveSource resolves a source into a model-profile preview. Hugging
61
+ // Face is resolved for real via model.inspect; other sources are constructed
62
+ // from the supplied location.
63
+ func (s *Server) handleResolveSource(w http.ResponseWriter, r *http.Request) {
64
+ var req resolveReq
65
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
66
+ writeError(w, http.StatusBadRequest, "invalid JSON body")
67
+ return
68
+ }
69
+ isHF := req.SourceType == "huggingface" || strings.HasPrefix(req.Model, "hf:") || (req.Provider == "Hugging Face")
70
+ if isHF {
71
+ id := req.ExternalID
72
+ if id == "" {
73
+ id = strings.TrimPrefix(req.Model, "hf:")
74
+ }
75
+ meta, err := models.Inspect(r.Context(), "hf:"+id, "main", s.cfg.HFToken)
76
+ if err != nil {
77
+ writeError(w, http.StatusBadGateway, "could not resolve model: "+err.Error())
78
+ return
79
+ }
80
+ writeJSON(w, http.StatusOK, map[string]any{
81
+ "source_type": "huggingface", "provider": "Hugging Face", "external_id": id,
82
+ "display_name": id, "source_uri": "hf:" + id,
83
+ "task": meta.PipelineTag, "library": meta.LibraryName, "license": meta.License,
84
+ "requires_gpu": meta.RequiresGPU, "recommended_runtime": meta.RecommendedRuntime,
85
+ "estimated_parameters": meta.EstimatedParameters, "tags": meta.Tags, "private": req.Private,
86
+ })
87
+ return
88
+ }
89
+ // Generic (GitHub/GitLab/S3/R2/Ollama/URL): construct a profile from the form.
90
+ uri := req.SourceURI
91
+ if req.Path != "" {
92
+ uri = strings.TrimRight(uri, "/") + "/" + strings.TrimLeft(req.Path, "/")
93
+ }
94
+ name := req.ExternalID
95
+ if name == "" {
96
+ name = uri
97
+ }
98
+ writeJSON(w, http.StatusOK, map[string]any{
99
+ "source_type": req.SourceType, "provider": req.Provider, "external_id": name,
100
+ "display_name": name, "source_uri": uri, "task": "text-generation",
101
+ "library": "custom", "license": "review required", "private": req.Private,
102
+ "recommended_runtime": "vLLM / SGLang",
103
+ })
104
+ }
105
+
106
+ // profileJSON shapes a stored profile for the API.
107
+ func profileJSON(p store.ModelProfile) map[string]any {
108
+ return map[string]any{
109
+ "id": p.ID, "source_type": p.SourceType, "source_uri": p.SourceURI,
110
+ "provider": p.Provider, "external_id": p.ExternalID, "display_name": p.DisplayName,
111
+ "task": p.Task, "library": p.Library, "license": p.License, "tags": p.Tags,
112
+ "status": p.Status, "created_at": p.CreatedAt, "metadata": p.Metadata,
113
+ }
114
+ }
115
+
116
+ // handleListProfiles lists model profiles for the caller's workspace.
117
+ func (s *Server) handleListProfiles(w http.ResponseWriter, r *http.Request) {
118
+ u, ok := s.currentUser(r)
119
+ if !ok {
120
+ writeError(w, http.StatusUnauthorized, "not authenticated")
121
+ return
122
+ }
123
+ list, err := s.store.ListProfiles(u.WorkspaceID)
124
+ if err != nil {
125
+ writeError(w, http.StatusInternalServerError, err.Error())
126
+ return
127
+ }
128
+ out := make([]map[string]any, 0, len(list))
129
+ for _, p := range list {
130
+ out = append(out, profileJSON(p))
131
+ }
132
+ writeJSON(w, http.StatusOK, map[string]any{"profiles": out})
133
+ }
134
+
135
+ type importReq struct {
136
+ SourceType string `json:"source_type"`
137
+ SourceURI string `json:"source_uri"`
138
+ Provider string `json:"provider"`
139
+ ExternalID string `json:"external_id"`
140
+ DisplayName string `json:"display_name"`
141
+ Task string `json:"task"`
142
+ Library string `json:"library"`
143
+ License string `json:"license"`
144
+ Tags []string `json:"tags"`
145
+ Metadata map[string]any `json:"metadata"`
146
+ }
147
+
148
+ // handleImportProfile creates a model profile (status profile_only).
149
+ func (s *Server) handleImportProfile(w http.ResponseWriter, r *http.Request) {
150
+ u, ok := s.currentUser(r)
151
+ if !ok {
152
+ writeError(w, http.StatusUnauthorized, "not authenticated")
153
+ return
154
+ }
155
+ var req importReq
156
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
157
+ writeError(w, http.StatusBadRequest, "invalid JSON body")
158
+ return
159
+ }
160
+ if req.DisplayName == "" {
161
+ req.DisplayName = req.ExternalID
162
+ }
163
+ p, err := s.store.CreateProfile(store.ModelProfile{
164
+ WorkspaceID: u.WorkspaceID, SourceType: req.SourceType, SourceURI: req.SourceURI,
165
+ Provider: req.Provider, ExternalID: req.ExternalID, DisplayName: req.DisplayName,
166
+ Task: req.Task, Library: req.Library, License: req.License, Tags: req.Tags,
167
+ Metadata: req.Metadata, Status: "profile_only",
168
+ })
169
+ if err != nil {
170
+ writeError(w, http.StatusInternalServerError, err.Error())
171
+ return
172
+ }
173
+ s.audit(r, u.WorkspaceID, u.ID, "model.imported", p.DisplayName, "success", map[string]any{"provider": p.Provider, "external_id": p.ExternalID})
174
+ writeJSON(w, http.StatusCreated, map[string]any{"profile": profileJSON(*p)})
175
+ }
176
+
177
+ type attachReq struct {
178
+ RuntimeID string `json:"runtimeId"`
179
+ InstallMode string `json:"installMode"`
180
+ ServingEngine string `json:"servingEngine"`
181
+ }
182
+
183
+ // handleAttachProfile creates an installation row and a model.attach job that
184
+ // streams real progress and persists it.
185
+ func (s *Server) handleAttachProfile(w http.ResponseWriter, r *http.Request) {
186
+ u, ok := s.currentUser(r)
187
+ if !ok {
188
+ writeError(w, http.StatusUnauthorized, "not authenticated")
189
+ return
190
+ }
191
+ pid := r.PathValue("id")
192
+ p, err := s.store.GetProfile(u.WorkspaceID, pid)
193
+ if err != nil {
194
+ writeError(w, http.StatusNotFound, "model profile not found")
195
+ return
196
+ }
197
+ var req attachReq
198
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
199
+ writeError(w, http.StatusBadRequest, "invalid JSON body")
200
+ return
201
+ }
202
+ if req.RuntimeID == "" {
203
+ writeError(w, http.StatusBadRequest, "runtimeId is required")
204
+ return
205
+ }
206
+ if req.InstallMode == "" {
207
+ req.InstallMode = "pull_from_source"
208
+ }
209
+ inst, err := s.store.CreateInstallation(store.ModelInstallation{
210
+ WorkspaceID: u.WorkspaceID, ModelProfileID: p.ID, RuntimeID: req.RuntimeID,
211
+ InstallMode: req.InstallMode, ServingEngine: req.ServingEngine, Status: "queued",
212
+ })
213
+ if err != nil {
214
+ writeError(w, http.StatusInternalServerError, err.Error())
215
+ return
216
+ }
217
+ _ = s.store.SetProfileStatus(p.ID, "queued")
218
+
219
+ model := p.SourceURI
220
+ if p.Provider == "Hugging Face" {
221
+ model = "hf:" + p.ExternalID
222
+ }
223
+ payload, _ := json.Marshal(map[string]any{
224
+ "installation_id": inst.ID, "profile_id": p.ID, "model": model, "provider": p.Provider,
225
+ "runtime_id": req.RuntimeID, "install_mode": req.InstallMode, "serving_engine": req.ServingEngine,
226
+ })
227
+ job, err := s.manager.Create(jobs.CreateRequest{Type: jobs.TypeModelAttach, TTLSeconds: 180, Payload: payload})
228
+ if err != nil {
229
+ writeError(w, http.StatusUnprocessableEntity, err.Error())
230
+ return
231
+ }
232
+ _ = s.store.SetInstallationJob(inst.ID, job.ID)
233
+ s.audit(r, u.WorkspaceID, u.ID, "model.attached", p.DisplayName, "success", map[string]any{"runtime_id": req.RuntimeID, "job_id": job.ID})
234
+
235
+ writeJSON(w, http.StatusAccepted, map[string]any{
236
+ "installation_id": inst.ID,
237
+ "profile_id": p.ID,
238
+ "job_id": job.ID,
239
+ "events_url": "/v1/jobs/" + job.ID + "/events",
240
+ })
241
+ }
242
+
243
+ // installationJSON shapes a stored installation for the API.
244
+ func installationJSON(in store.ModelInstallation) map[string]any {
245
+ return map[string]any{
246
+ "id": in.ID, "model_profile_id": in.ModelProfileID, "runtime_id": in.RuntimeID,
247
+ "install_mode": in.InstallMode, "serving_engine": in.ServingEngine,
248
+ "status": in.Status, "progress": in.Progress, "local_path": in.LocalPath,
249
+ "endpoint_url": in.EndpointURL, "job_id": in.JobID,
250
+ "model_name": in.ModelName, "provider": in.Provider, "updated_at": in.UpdatedAt,
251
+ }
252
+ }
253
+
254
+ // handleListInstallations lists runtime-cache installations for the workspace.
255
+ func (s *Server) handleListInstallations(w http.ResponseWriter, r *http.Request) {
256
+ u, ok := s.currentUser(r)
257
+ if !ok {
258
+ writeError(w, http.StatusUnauthorized, "not authenticated")
259
+ return
260
+ }
261
+ list, err := s.store.ListInstallations(u.WorkspaceID)
262
+ if err != nil {
263
+ writeError(w, http.StatusInternalServerError, err.Error())
264
+ return
265
+ }
266
+ out := make([]map[string]any, 0, len(list))
267
+ for _, in := range list {
268
+ out = append(out, installationJSON(in))
269
+ }
270
+ writeJSON(w, http.StatusOK, map[string]any{"installations": out})
271
+ }
api/handlers_platform.go ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "net/http"
5
+
6
+ "github.com/agent-matrix/matrix-runtime/internal/catalog"
7
+ "github.com/agent-matrix/matrix-runtime/internal/config"
8
+ "github.com/agent-matrix/matrix-runtime/internal/runtime"
9
+ "github.com/agent-matrix/matrix-runtime/internal/security"
10
+ )
11
+
12
+ // handleListRuntimes returns the real runtimes known to this control surface.
13
+ // Today that is this node (derived from live health + capabilities); joined
14
+ // remote runtimes will appear here once the control-channel lands.
15
+ func (s *Server) handleListRuntimes(w http.ResponseWriter, _ *http.Request) {
16
+ caps := runtime.BuildCapabilities(s.cfg)
17
+ running := 0
18
+ for _, j := range s.manager.List() {
19
+ if j.Status == "running" || j.Status == "queued" {
20
+ running++
21
+ }
22
+ }
23
+ self := map[string]any{
24
+ "id": s.cfg.EffectiveRuntimeID(),
25
+ "name": s.cfg.EffectiveRuntimeID(),
26
+ "status": "Online",
27
+ "statusClass": "green",
28
+ "mode": string(s.cfg.Mode),
29
+ "region": "local",
30
+ "caps": caps.Capabilities,
31
+ "runtimes": caps.Runtimes,
32
+ "jobs": running,
33
+ "version": config.Version,
34
+ "heartbeat": "just now",
35
+ "live": true,
36
+ }
37
+ writeJSON(w, http.StatusOK, map[string]any{"runtimes": []any{self}})
38
+ }
39
+
40
+ // handleCatalog returns the curated component catalog (real reference data).
41
+ func (s *Server) handleCatalog(w http.ResponseWriter, _ *http.Request) {
42
+ writeJSON(w, http.StatusOK, map[string]any{"items": catalog.Items})
43
+ }
44
+
45
+ // handlePolicies returns the runtime's actual enforced policy — derived from
46
+ // config limits and the security allow/deny lists (not hand-written demo JSON).
47
+ func (s *Server) handlePolicies(w http.ResponseWriter, _ *http.Request) {
48
+ c := s.cfg
49
+ policies := []map[string]any{
50
+ {
51
+ "name": "Sandbox Policy", "active": true,
52
+ "body": map[string]any{
53
+ "max_ttl_seconds": c.MaxTTLSeconds,
54
+ "max_concurrent_jobs": c.MaxConcurrentJobs,
55
+ "startup_timeout_seconds": c.StartupTimeoutSeconds,
56
+ "rpc_timeout_seconds": c.RPCTimeoutSeconds,
57
+ "max_log_bytes": c.MaxLogBytes,
58
+ "allowed_programs": security.AllowedPrograms(),
59
+ "allowed_transports": []string{"stdio"},
60
+ "reject_raw_secrets": true,
61
+ },
62
+ },
63
+ {
64
+ "name": "Command Policy", "active": true,
65
+ "body": map[string]any{
66
+ "blocked_tokens": security.BlockedTokens(),
67
+ "blocked_shell_operators": []string{"&", "|", ";", "<", ">", "`", "$()", "newline"},
68
+ },
69
+ },
70
+ {
71
+ "name": "Runtime Policy", "active": true,
72
+ "body": map[string]any{
73
+ "mode": string(c.Mode),
74
+ "api_token_set": c.APIToken != "",
75
+ "matrixshell_enabled": c.MatrixShellEnabled,
76
+ "rate_limit_rpm": c.RateLimitRPM,
77
+ "cloud_url": c.CloudURL,
78
+ "outbound_only": true,
79
+ "data_dir": c.DataDir,
80
+ },
81
+ },
82
+ }
83
+ writeJSON(w, http.StatusOK, map[string]any{"policies": policies})
84
+ }
api/handlers_sandbox.go ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net/http"
6
+
7
+ "github.com/agent-matrix/matrix-runtime/internal/jobs"
8
+ )
9
+
10
+ func (s *Server) handleCreateSandbox(w http.ResponseWriter, r *http.Request) {
11
+ var req jobs.SandboxRequest
12
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
13
+ writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
14
+ return
15
+ }
16
+ sessionID, j, err := s.manager.CreateSandbox(req)
17
+ if err != nil {
18
+ writeError(w, http.StatusUnprocessableEntity, err.Error())
19
+ return
20
+ }
21
+ writeJSON(w, http.StatusAccepted, map[string]any{
22
+ "session_id": sessionID,
23
+ "job_id": j.ID,
24
+ "status": "starting",
25
+ "expires_at": j.Snapshot().ExpiresAt,
26
+ "events_url": "/v1/sandbox/sessions/" + sessionID + "/events",
27
+ })
28
+ }
29
+
30
+ func (s *Server) handleGetSandbox(w http.ResponseWriter, r *http.Request) {
31
+ sessionID := r.PathValue("session_id")
32
+ j, ok := s.manager.SandboxJob(sessionID)
33
+ if !ok {
34
+ writeError(w, http.StatusNotFound, "sandbox session not found")
35
+ return
36
+ }
37
+ snap := j.Snapshot()
38
+ writeJSON(w, http.StatusOK, map[string]any{
39
+ "session_id": sessionID,
40
+ "job_id": j.ID,
41
+ "status": snap.Status,
42
+ "expires_at": snap.ExpiresAt,
43
+ "result": snap.Result,
44
+ })
45
+ }
46
+
47
+ func (s *Server) handleSandboxEvents(w http.ResponseWriter, r *http.Request) {
48
+ j, ok := s.manager.SandboxJob(r.PathValue("session_id"))
49
+ if !ok {
50
+ writeError(w, http.StatusNotFound, "sandbox session not found")
51
+ return
52
+ }
53
+ streamEvents(w, r, j.Bus())
54
+ }
55
+
56
+ func (s *Server) handleSandboxTools(w http.ResponseWriter, r *http.Request) {
57
+ tools, err := s.manager.SandboxTools(r.PathValue("session_id"))
58
+ if err != nil {
59
+ writeError(w, http.StatusConflict, err.Error())
60
+ return
61
+ }
62
+ writeJSON(w, http.StatusOK, map[string]any{"tools": tools})
63
+ }
64
+
65
+ func (s *Server) handleSandboxToolCall(w http.ResponseWriter, r *http.Request) {
66
+ var req struct {
67
+ Name string `json:"name"`
68
+ Arguments map[string]any `json:"arguments"`
69
+ }
70
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
71
+ writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
72
+ return
73
+ }
74
+ if req.Name == "" {
75
+ writeError(w, http.StatusBadRequest, "tool name is required")
76
+ return
77
+ }
78
+ result, err := s.manager.CallSandboxTool(r.Context(), r.PathValue("session_id"), req.Name, req.Arguments)
79
+ if err != nil {
80
+ writeError(w, http.StatusConflict, err.Error())
81
+ return
82
+ }
83
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": json.RawMessage(result)})
84
+ }
85
+
86
+ func (s *Server) handleDeleteSandbox(w http.ResponseWriter, r *http.Request) {
87
+ j, ok := s.manager.SandboxJob(r.PathValue("session_id"))
88
+ if !ok {
89
+ writeError(w, http.StatusNotFound, "sandbox session not found")
90
+ return
91
+ }
92
+ status, _ := s.manager.Cancel(j.ID)
93
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "status": status})
94
+ }
api/handlers_system.go ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "io/fs"
5
+ "net/http"
6
+ "os"
7
+ "path/filepath"
8
+ "syscall"
9
+ )
10
+
11
+ // dirSize sums the size of all regular files under dir (0 when missing).
12
+ func dirSize(dir string) int64 {
13
+ var total int64
14
+ _ = filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error {
15
+ if err != nil || d.IsDir() {
16
+ return nil
17
+ }
18
+ if info, err := d.Info(); err == nil {
19
+ total += info.Size()
20
+ }
21
+ return nil
22
+ })
23
+ return total
24
+ }
25
+
26
+ func fileSize(path string) int64 {
27
+ if info, err := os.Stat(path); err == nil {
28
+ return info.Size()
29
+ }
30
+ return 0
31
+ }
32
+
33
+ // handleStorage reports disk usage for the runtime's data directory: per-area
34
+ // sizes, the database file, job count, and free space on the filesystem.
35
+ func (s *Server) handleStorage(w http.ResponseWriter, _ *http.Request) {
36
+ l := s.manager.Layout()
37
+ areas := map[string]int64{
38
+ "models": dirSize(l.HuggingFace()),
39
+ "mcp": dirSize(l.MCP()),
40
+ "agents": dirSize(l.Agents()),
41
+ "jobs": dirSize(l.Jobs()),
42
+ "logs": dirSize(l.Logs()),
43
+ "database": fileSize(s.cfg.DBPath),
44
+ }
45
+ var total int64
46
+ for _, v := range areas {
47
+ total += v
48
+ }
49
+
50
+ jobsCount := 0
51
+ if entries, err := os.ReadDir(l.Jobs()); err == nil {
52
+ jobsCount = len(entries)
53
+ }
54
+
55
+ var freeBytes uint64
56
+ var fsStat syscall.Statfs_t
57
+ if err := syscall.Statfs(s.cfg.DataDir, &fsStat); err == nil {
58
+ freeBytes = fsStat.Bavail * uint64(fsStat.Bsize)
59
+ }
60
+
61
+ writeJSON(w, http.StatusOK, map[string]any{
62
+ "data_dir": s.cfg.DataDir,
63
+ "areas": areas,
64
+ "total_bytes": total,
65
+ "free_bytes": freeBytes,
66
+ "jobs_count": jobsCount,
67
+ })
68
+ }
api/middleware_auth.go ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "net/http"
5
+ "strings"
6
+ )
7
+
8
+ // alwaysOpen is the set of endpoints reachable without any credentials:
9
+ // liveness/readiness/version probes and the capabilities advert.
10
+ func alwaysOpen(path string) bool {
11
+ switch path {
12
+ case "/v1/health", "/v1/ready", "/v1/version", "/v1/capabilities":
13
+ return true
14
+ }
15
+ return false
16
+ }
17
+
18
+ // withAuth authenticates API requests. Two credential types are accepted and
19
+ // either is sufficient:
20
+ //
21
+ // - a valid user session bearer token (multitenant console), or
22
+ // - the operator API token (self-hosted single-tenant runtime), when one is
23
+ // configured via MATRIX_RUNTIME_API_TOKEN.
24
+ //
25
+ // Public probes, the user-auth endpoints, runtime onboarding (which carries its
26
+ // own join/runtime-token auth), and static console assets are always allowed.
27
+ // In production modes a request with no valid credential is rejected
28
+ // (fail-closed); in local-dev it is permitted for convenience.
29
+ func (s *Server) withAuth(next http.Handler) http.Handler {
30
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
31
+ path := r.URL.Path
32
+
33
+ // Public probes + non-API (console assets).
34
+ if alwaysOpen(path) || !strings.HasPrefix(path, "/v1/") {
35
+ next.ServeHTTP(w, r)
36
+ return
37
+ }
38
+ // User auth endpoints (login/signup/forgot/reset/verify) and runtime
39
+ // onboarding (own-token auth) are always reachable.
40
+ if strings.HasPrefix(path, "/v1/auth/") ||
41
+ path == "/v1/cloud/runtimes/register" || path == "/v1/cloud/runtimes/heartbeat" {
42
+ next.ServeHTTP(w, r)
43
+ return
44
+ }
45
+
46
+ token := bearer(r)
47
+
48
+ // 1) Valid user session → allow (multitenant console).
49
+ if s.store != nil && token != "" {
50
+ if _, err := s.store.UserBySession(token); err == nil {
51
+ next.ServeHTTP(w, r)
52
+ return
53
+ }
54
+ }
55
+ // 2) Operator API token configured → require an exact match.
56
+ if s.cfg.APIToken != "" {
57
+ if token == s.cfg.APIToken {
58
+ next.ServeHTTP(w, r)
59
+ return
60
+ }
61
+ writeError(w, http.StatusUnauthorized, "missing or invalid bearer token")
62
+ return
63
+ }
64
+ // 3) No operator token configured: fail-closed in production, allow in dev.
65
+ if s.cfg.IsProduction() {
66
+ writeError(w, http.StatusUnauthorized, "authentication required")
67
+ return
68
+ }
69
+ next.ServeHTTP(w, r)
70
+ })
71
+ }
api/middleware_ratelimit.go ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "net/http"
5
+ "strconv"
6
+ "sync"
7
+ "time"
8
+ )
9
+
10
+ // rateLimiter is a tiny per-key fixed-window counter. It is intentionally
11
+ // simple (no external deps): each key gets `limit` requests per 60s window.
12
+ type rateLimiter struct {
13
+ mu sync.Mutex
14
+ limit int
15
+ window time.Duration
16
+ hits map[string]*window
17
+ }
18
+
19
+ type window struct {
20
+ count int
21
+ reset time.Time
22
+ }
23
+
24
+ func newRateLimiter(rpm int) *rateLimiter {
25
+ return &rateLimiter{limit: rpm, window: time.Minute, hits: make(map[string]*window)}
26
+ }
27
+
28
+ // allow reports whether a request for key is permitted and, if not, how long
29
+ // until the window resets.
30
+ func (rl *rateLimiter) allow(key string) (bool, time.Duration) {
31
+ now := time.Now()
32
+ rl.mu.Lock()
33
+ defer rl.mu.Unlock()
34
+ w := rl.hits[key]
35
+ if w == nil || now.After(w.reset) {
36
+ rl.hits[key] = &window{count: 1, reset: now.Add(rl.window)}
37
+ // Opportunistically evict stale entries to bound memory.
38
+ if len(rl.hits) > 4096 {
39
+ for k, v := range rl.hits {
40
+ if now.After(v.reset) {
41
+ delete(rl.hits, k)
42
+ }
43
+ }
44
+ }
45
+ return true, 0
46
+ }
47
+ if w.count >= rl.limit {
48
+ return false, time.Until(w.reset)
49
+ }
50
+ w.count++
51
+ return true, 0
52
+ }
53
+
54
+ // rateLimited reports whether a path should be rate limited: state-changing
55
+ // methods on /v1, plus auth endpoints (to slow brute force). Read-only probes
56
+ // and asset fetches are never limited.
57
+ func rateLimited(r *http.Request) bool {
58
+ if !pathHasPrefix(r.URL.Path, "/v1/") {
59
+ return false
60
+ }
61
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
62
+ return true
63
+ }
64
+ return false
65
+ }
66
+
67
+ func pathHasPrefix(p, prefix string) bool {
68
+ return len(p) >= len(prefix) && p[:len(prefix)] == prefix
69
+ }
70
+
71
+ // withRateLimit wraps next with per-IP rate limiting on write/auth endpoints.
72
+ func (s *Server) withRateLimit(next http.Handler) http.Handler {
73
+ if s.cfg.RateLimitRPM <= 0 || s.limiter == nil {
74
+ return next
75
+ }
76
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
77
+ if rateLimited(r) {
78
+ if ok, retry := s.limiter.allow(clientIP(r)); !ok {
79
+ w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1))
80
+ writeError(w, http.StatusTooManyRequests, "rate limit exceeded — slow down and retry")
81
+ return
82
+ }
83
+ }
84
+ next.ServeHTTP(w, r)
85
+ })
86
+ }
api/models_test.go ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "net/http"
5
+ "testing"
6
+ )
7
+
8
+ // authedToken signs up a user on the given server and returns its session token.
9
+ func authedToken(t *testing.T, srv *Server) string {
10
+ t.Helper()
11
+ rec, body := do(t, srv, http.MethodPost, "/v1/auth/signup", "", `{"name":"Mo","email":"mo@acme.io","password":"hunter2x"}`)
12
+ if rec.Code != http.StatusCreated {
13
+ t.Fatalf("signup %d", rec.Code)
14
+ }
15
+ return body["token"].(string)
16
+ }
17
+
18
+ func TestModelProfilesRequireAuth(t *testing.T) {
19
+ srv := authServer(t)
20
+ rec, _ := do(t, srv, http.MethodGet, "/v1/model-profiles", "", "")
21
+ if rec.Code != http.StatusUnauthorized {
22
+ t.Fatalf("expected 401 without auth, got %d", rec.Code)
23
+ }
24
+ }
25
+
26
+ func TestImportListAttachProfile(t *testing.T) {
27
+ srv := authServer(t)
28
+ tok := authedToken(t, srv)
29
+
30
+ // import a profile
31
+ rec, body := do(t, srv, http.MethodPost, "/v1/model-profiles", tok,
32
+ `{"source_type":"huggingface","provider":"Hugging Face","external_id":"deepseek-ai/DeepSeek-V3","display_name":"deepseek-ai/DeepSeek-V3","task":"text-generation"}`)
33
+ if rec.Code != http.StatusCreated {
34
+ t.Fatalf("import status %d: %v", rec.Code, body)
35
+ }
36
+ prof := body["profile"].(map[string]any)
37
+ if prof["status"] != "profile_only" {
38
+ t.Errorf("status = %v", prof["status"])
39
+ }
40
+ pid := prof["id"].(string)
41
+
42
+ // list profiles
43
+ rec, body = do(t, srv, http.MethodGet, "/v1/model-profiles", tok, "")
44
+ if rec.Code != http.StatusOK {
45
+ t.Fatalf("list status %d", rec.Code)
46
+ }
47
+ if len(body["profiles"].([]any)) != 1 {
48
+ t.Fatalf("expected 1 profile, got %v", body["profiles"])
49
+ }
50
+
51
+ // attach -> creates installation + a job
52
+ rec, body = do(t, srv, http.MethodPost, "/v1/model-profiles/"+pid+"/attach", tok,
53
+ `{"runtimeId":"acme-prod-runtime","installMode":"pull_from_source","servingEngine":"vLLM"}`)
54
+ if rec.Code != http.StatusAccepted {
55
+ t.Fatalf("attach status %d: %v", rec.Code, body)
56
+ }
57
+ if body["job_id"] == nil || body["installation_id"] == nil {
58
+ t.Fatalf("attach missing ids: %v", body)
59
+ }
60
+
61
+ // installation should now appear in the runtime cache
62
+ rec, body = do(t, srv, http.MethodGet, "/v1/model-installations", tok, "")
63
+ if rec.Code != http.StatusOK {
64
+ t.Fatalf("installations status %d", rec.Code)
65
+ }
66
+ ins := body["installations"].([]any)
67
+ if len(ins) != 1 {
68
+ t.Fatalf("expected 1 installation, got %d", len(ins))
69
+ }
70
+ got := ins[0].(map[string]any)
71
+ if got["runtime_id"] != "acme-prod-runtime" || got["serving_engine"] != "vLLM" {
72
+ t.Errorf("installation fields: %v", got)
73
+ }
74
+
75
+ // cancel the background job so it doesn't linger past the test
76
+ if id, _ := got["job_id"].(string); id != "" {
77
+ _, _ = do(t, srv, http.MethodDelete, "/v1/jobs/"+id, tok, "")
78
+ }
79
+ }
api/openapi.go ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ _ "embed"
5
+ "net/http"
6
+ )
7
+
8
+ //go:embed openapi.yaml
9
+ var openAPISpec []byte
10
+
11
+ // handleOpenAPISpec serves the raw OpenAPI document.
12
+ func (s *Server) handleOpenAPISpec(w http.ResponseWriter, _ *http.Request) {
13
+ w.Header().Set("Content-Type", "application/yaml")
14
+ _, _ = w.Write(openAPISpec)
15
+ }
16
+
17
+ // docsHTML is a self-contained API docs viewer. It loads Redoc from a CDN when
18
+ // online, but falls back to a readable, dependency-free rendering of
19
+ // /openapi.yaml so the page works air-gapped too.
20
+ const docsHTML = `<!DOCTYPE html>
21
+ <html lang="en">
22
+ <head>
23
+ <meta charset="utf-8"/>
24
+ <meta name="viewport" content="width=device-width,initial-scale=1"/>
25
+ <title>Matrix Runtime API — Docs</title>
26
+ <style>
27
+ body{margin:0;font-family:ui-sans-serif,system-ui,Segoe UI,Roboto,Arial;background:#0b0f14;color:#e6edf3}
28
+ header{padding:18px 24px;border-bottom:1px solid #1b2430;display:flex;align-items:center;gap:12px}
29
+ header h1{font-size:16px;margin:0;font-weight:700}
30
+ header .v{font-family:ui-monospace,monospace;font-size:11px;color:#7d8da1;border:1px solid #1b2430;border-radius:6px;padding:2px 8px}
31
+ a{color:#5ad19a;text-decoration:none}
32
+ #fallback{max-width:900px;margin:0 auto;padding:24px}
33
+ .op{border:1px solid #1b2430;border-radius:10px;margin:10px 0;overflow:hidden}
34
+ .op .h{display:flex;align-items:center;gap:10px;padding:11px 14px;background:#0f1620}
35
+ .m{font-family:ui-monospace,monospace;font-size:11px;font-weight:700;padding:2px 8px;border-radius:5px;text-transform:uppercase}
36
+ .get{background:#10331f;color:#5ad19a}.post{background:#102a44;color:#6db3f2}
37
+ .put{background:#3a2f10;color:#e8c468}.delete{background:#3a1414;color:#f2796d}
38
+ .path{font-family:ui-monospace,monospace;font-size:13px}
39
+ .sum{color:#9fb0c3;font-size:12.5px;padding:8px 14px}
40
+ </style>
41
+ </head>
42
+ <body>
43
+ <header>
44
+ <h1>Matrix Runtime API</h1><span class="v" id="ver">openapi</span>
45
+ <span style="flex:1"></span><a href="/openapi.yaml">openapi.yaml ↗</a>
46
+ </header>
47
+ <redoc spec-url="/openapi.yaml"></redoc>
48
+ <div id="fallback"><p style="color:#7d8da1">Rendering API reference…</p></div>
49
+ <script src="https://cdn.redocly.com/redoc/latest/bundles/redoc.standalone.js"
50
+ onload="document.getElementById('fallback').style.display='none'"
51
+ onerror="renderFallback()"></script>
52
+ <script>
53
+ function renderFallback(){
54
+ var rd=document.querySelector('redoc'); if(rd) rd.remove();
55
+ fetch('/openapi.yaml').then(function(r){return r.text()}).then(function(t){
56
+ // Minimal YAML-ish scan: list " /path:" then " get:" with summary.
57
+ var lines=t.split('\n'), out=[], path=null, method=null, mLine=-1;
58
+ var box=document.getElementById('fallback'); box.innerHTML='';
59
+ var verEl=document.getElementById('ver');
60
+ for(var i=0;i<lines.length;i++){
61
+ var ln=lines[i];
62
+ var pm=ln.match(/^ (\/[^:]+):\s*$/); if(pm){path=pm[1];continue;}
63
+ var mm=ln.match(/^ (get|post|put|delete|patch):\s*$/);
64
+ if(mm&&path){method=mm[1];
65
+ var sum=''; for(var j=i+1;j<Math.min(i+6,lines.length);j++){var sm=lines[j].match(/^ summary:\s*(.+)$/); if(sm){sum=sm[1];break;}}
66
+ var div=document.createElement('div'); div.className='op';
67
+ div.innerHTML='<div class="h"><span class="m '+method+'">'+method+'</span><span class="path">'+path+'</span></div>'+(sum?'<div class="sum">'+sum+'</div>':'');
68
+ box.appendChild(div);
69
+ }
70
+ var vm=ln.match(/^ version:\s*(.+)$/); if(vm&&verEl) verEl.textContent='v'+vm[1].trim();
71
+ }
72
+ if(!box.children.length) box.innerHTML='<p>See <a href="/openapi.yaml">/openapi.yaml</a>.</p>';
73
+ });
74
+ }
75
+ </script>
76
+ </body>
77
+ </html>`
78
+
79
+ // handleDocs serves the API docs viewer.
80
+ func (s *Server) handleDocs(w http.ResponseWriter, _ *http.Request) {
81
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
82
+ _, _ = w.Write([]byte(docsHTML))
83
+ }
api/openapi.yaml ADDED
@@ -0,0 +1,651 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ openapi: 3.0.3
2
+ info:
3
+ title: Matrix Runtime API
4
+ description: |
5
+ Execution plane for Matrix Cloud. Runs MCP sandboxes, inspects Hugging Face
6
+ models and (in future) agents/tools. Sandboxes are a thin alias over jobs of
7
+ type mcp.test.
8
+ version: 0.2.0
9
+ servers:
10
+ - url: http://localhost:8080
11
+ description: Local
12
+ security:
13
+ - bearerAuth: []
14
+ - {}
15
+ paths:
16
+ /v1/health:
17
+ get:
18
+ summary: Liveness and identity
19
+ security: []
20
+ responses:
21
+ "200":
22
+ description: OK
23
+ content:
24
+ application/json:
25
+ schema:
26
+ type: object
27
+ properties:
28
+ status: { type: string, example: ok }
29
+ runtime_id: { type: string, example: rt_local }
30
+ mode: { type: string, example: local-dev }
31
+ version: { type: string, example: 0.1.0 }
32
+ /v1/capabilities:
33
+ get:
34
+ summary: Runtime capabilities and limits
35
+ responses:
36
+ "200":
37
+ description: OK
38
+ content:
39
+ application/json:
40
+ schema:
41
+ $ref: "#/components/schemas/Capabilities"
42
+ /v1/ready:
43
+ get:
44
+ summary: Readiness probe (checks + warnings)
45
+ security: []
46
+ responses:
47
+ "200": { description: Ready }
48
+ "503": { description: Not ready (a core check failed) }
49
+ /v1/version:
50
+ get:
51
+ summary: Build and version metadata
52
+ security: []
53
+ responses:
54
+ "200": { description: OK }
55
+ /v1/system/storage:
56
+ get:
57
+ summary: Data-directory storage usage and free disk
58
+ responses:
59
+ "200": { description: OK }
60
+ /v1/policies:
61
+ get:
62
+ summary: Enforced guardrails (limits + command allow/deny lists)
63
+ responses:
64
+ "200": { description: OK }
65
+ /v1/runtimes:
66
+ get:
67
+ summary: Runtimes connected to this control surface
68
+ responses:
69
+ "200": { description: OK }
70
+ /v1/catalog:
71
+ get:
72
+ summary: Curated catalog of MCP servers and models
73
+ responses:
74
+ "200": { description: OK }
75
+ /v1/auth/forgot:
76
+ post:
77
+ summary: Request a password-reset email (always 200)
78
+ security: []
79
+ responses:
80
+ "200": { description: OK }
81
+ /v1/auth/reset:
82
+ post:
83
+ summary: Set a new password from a reset token
84
+ security: []
85
+ responses:
86
+ "200": { description: OK }
87
+ "400": { description: Invalid or expired token }
88
+ /v1/auth/verify:
89
+ post:
90
+ summary: Confirm an email-verification token
91
+ security: []
92
+ responses:
93
+ "200": { description: OK }
94
+ /v1/matrixshell/status:
95
+ get:
96
+ summary: MatrixShell install status (403 when disabled)
97
+ responses:
98
+ "200": { description: OK }
99
+ "403": { description: MatrixShell disabled }
100
+ /v1/matrixshell/install:
101
+ post:
102
+ summary: Install MatrixShell into the local sandbox (job)
103
+ responses:
104
+ "202": { description: Job accepted }
105
+ "403": { description: MatrixShell disabled }
106
+ /v1/matrixshell/exec:
107
+ post:
108
+ summary: Run a command in the MatrixShell sandbox (denylisted)
109
+ responses:
110
+ "200": { description: OK }
111
+ "403": { description: Disabled or blocked by denylist }
112
+ /v1/cloud/runtimes:
113
+ get:
114
+ summary: List the workspace's registered runtimes
115
+ responses:
116
+ "200": { description: OK }
117
+ /v1/cloud/runtimes/register:
118
+ post:
119
+ summary: Register a runtime using a workspace join token
120
+ security: []
121
+ responses:
122
+ "201": { description: Registered (returns a runtime token) }
123
+ "401": { description: Invalid join token }
124
+ /v1/cloud/runtimes/heartbeat:
125
+ post:
126
+ summary: Runtime liveness heartbeat (runtime-token auth)
127
+ security: []
128
+ responses:
129
+ "200": { description: OK }
130
+ "401": { description: Invalid runtime token }
131
+ /v1/cloud/join-tokens:
132
+ get:
133
+ summary: List active join tokens
134
+ responses:
135
+ "200": { description: OK }
136
+ post:
137
+ summary: Mint a single/limited-use join token
138
+ responses:
139
+ "201": { description: Created (secret shown once) }
140
+ /v1/cloud/providers:
141
+ get:
142
+ summary: List BYO provider credentials (hints only)
143
+ responses:
144
+ "200": { description: OK }
145
+ post:
146
+ summary: Store a BYO provider token (encrypted at rest)
147
+ responses:
148
+ "201": { description: Created }
149
+ /v1/cloud/usage:
150
+ get:
151
+ summary: 30-day usage metering by kind
152
+ responses:
153
+ "200": { description: OK }
154
+ /v1/cloud/audit:
155
+ get:
156
+ summary: Recent audit events for the workspace
157
+ responses:
158
+ "200": { description: OK }
159
+ /v1/auth/signup:
160
+ post:
161
+ summary: Create a workspace + owner account (multitenant)
162
+ security: []
163
+ requestBody:
164
+ required: true
165
+ content:
166
+ application/json:
167
+ schema:
168
+ type: object
169
+ required: [name, email, password]
170
+ properties:
171
+ name: { type: string }
172
+ email: { type: string, format: email }
173
+ password: { type: string, minLength: 6 }
174
+ workspace: { type: string }
175
+ responses:
176
+ "201":
177
+ description: Created
178
+ content:
179
+ application/json:
180
+ schema: { $ref: "#/components/schemas/AuthResult" }
181
+ "409": { $ref: "#/components/responses/Error" }
182
+ /v1/auth/login:
183
+ post:
184
+ summary: Authenticate and start a session
185
+ security: []
186
+ requestBody:
187
+ required: true
188
+ content:
189
+ application/json:
190
+ schema:
191
+ type: object
192
+ required: [email, password]
193
+ properties:
194
+ email: { type: string, format: email }
195
+ password: { type: string }
196
+ responses:
197
+ "200":
198
+ description: OK
199
+ content:
200
+ application/json:
201
+ schema: { $ref: "#/components/schemas/AuthResult" }
202
+ "401": { $ref: "#/components/responses/Error" }
203
+ /v1/auth/me:
204
+ get:
205
+ summary: Current authenticated user
206
+ responses:
207
+ "200":
208
+ description: OK
209
+ content:
210
+ application/json:
211
+ schema:
212
+ type: object
213
+ properties:
214
+ user: { $ref: "#/components/schemas/AuthUser" }
215
+ "401": { $ref: "#/components/responses/Error" }
216
+ /v1/auth/logout:
217
+ post:
218
+ summary: End the current session (?all=true ends every session)
219
+ responses:
220
+ "200": { description: OK }
221
+ /v1/model-sources/huggingface/search:
222
+ get:
223
+ summary: Search Hugging Face models (server-side proxy, sorted by downloads)
224
+ security: []
225
+ parameters:
226
+ - { name: q, in: query, schema: { type: string } }
227
+ - { name: task, in: query, schema: { type: string } }
228
+ - { name: limit, in: query, schema: { type: integer } }
229
+ responses:
230
+ "200":
231
+ description: OK
232
+ content:
233
+ application/json:
234
+ schema:
235
+ type: object
236
+ properties:
237
+ live: { type: boolean }
238
+ items: { type: array, items: { type: object } }
239
+ /v1/model-sources/resolve:
240
+ post:
241
+ summary: Resolve a source into a model-profile preview (HF resolved live)
242
+ requestBody:
243
+ required: true
244
+ content:
245
+ application/json:
246
+ schema:
247
+ type: object
248
+ properties:
249
+ sourceType: { type: string, example: huggingface }
250
+ externalId: { type: string }
251
+ model: { type: string }
252
+ sourceUri: { type: string }
253
+ provider: { type: string }
254
+ path: { type: string }
255
+ branch: { type: string }
256
+ private: { type: boolean }
257
+ responses:
258
+ "200": { description: Profile preview }
259
+ "502": { $ref: "#/components/responses/Error" }
260
+ /v1/model-profiles:
261
+ get:
262
+ summary: List model profiles for the workspace
263
+ responses:
264
+ "200":
265
+ description: OK
266
+ content:
267
+ application/json:
268
+ schema:
269
+ type: object
270
+ properties:
271
+ profiles: { type: array, items: { $ref: "#/components/schemas/ModelProfile" } }
272
+ "401": { $ref: "#/components/responses/Error" }
273
+ post:
274
+ summary: Import (create) a model profile
275
+ requestBody:
276
+ required: true
277
+ content:
278
+ application/json:
279
+ schema: { $ref: "#/components/schemas/ModelProfileImport" }
280
+ responses:
281
+ "201":
282
+ description: Created
283
+ content:
284
+ application/json:
285
+ schema:
286
+ type: object
287
+ properties:
288
+ profile: { $ref: "#/components/schemas/ModelProfile" }
289
+ "401": { $ref: "#/components/responses/Error" }
290
+ /v1/model-profiles/{id}/attach:
291
+ post:
292
+ summary: Attach/install a profile onto a runtime (creates a model.attach job)
293
+ parameters:
294
+ - { name: id, in: path, required: true, schema: { type: string } }
295
+ requestBody:
296
+ required: true
297
+ content:
298
+ application/json:
299
+ schema:
300
+ type: object
301
+ required: [runtimeId]
302
+ properties:
303
+ runtimeId: { type: string }
304
+ installMode: { type: string, enum: [pull_from_source, mount_volume, external_endpoint] }
305
+ servingEngine: { type: string, example: vLLM }
306
+ responses:
307
+ "202":
308
+ description: Accepted
309
+ content:
310
+ application/json:
311
+ schema:
312
+ type: object
313
+ properties:
314
+ installation_id: { type: string }
315
+ profile_id: { type: string }
316
+ job_id: { type: string }
317
+ events_url: { type: string }
318
+ "404": { $ref: "#/components/responses/Error" }
319
+ /v1/model-installations:
320
+ get:
321
+ summary: List runtime-cache installations for the workspace (with progress)
322
+ responses:
323
+ "200":
324
+ description: OK
325
+ content:
326
+ application/json:
327
+ schema:
328
+ type: object
329
+ properties:
330
+ installations: { type: array, items: { $ref: "#/components/schemas/ModelInstallation" } }
331
+ "401": { $ref: "#/components/responses/Error" }
332
+ /v1/jobs:
333
+ get:
334
+ summary: List jobs (newest first)
335
+ responses:
336
+ "200":
337
+ description: OK
338
+ content:
339
+ application/json:
340
+ schema:
341
+ type: object
342
+ properties:
343
+ jobs:
344
+ type: array
345
+ items: { $ref: "#/components/schemas/Job" }
346
+ post:
347
+ summary: Create a job
348
+ requestBody:
349
+ required: true
350
+ content:
351
+ application/json:
352
+ schema:
353
+ $ref: "#/components/schemas/CreateJobRequest"
354
+ responses:
355
+ "202":
356
+ description: Accepted
357
+ content:
358
+ application/json:
359
+ schema:
360
+ $ref: "#/components/schemas/CreateJobResponse"
361
+ "400": { $ref: "#/components/responses/Error" }
362
+ "422": { $ref: "#/components/responses/Error" }
363
+ /v1/jobs/{job_id}:
364
+ parameters:
365
+ - $ref: "#/components/parameters/JobID"
366
+ get:
367
+ summary: Read a job
368
+ responses:
369
+ "200":
370
+ description: OK
371
+ content:
372
+ application/json:
373
+ schema:
374
+ $ref: "#/components/schemas/Job"
375
+ "404": { $ref: "#/components/responses/Error" }
376
+ delete:
377
+ summary: Cancel/delete a job
378
+ responses:
379
+ "200":
380
+ description: OK
381
+ content:
382
+ application/json:
383
+ schema:
384
+ type: object
385
+ properties:
386
+ ok: { type: boolean }
387
+ status: { type: string }
388
+ "404": { $ref: "#/components/responses/Error" }
389
+ /v1/jobs/{job_id}/events:
390
+ parameters:
391
+ - $ref: "#/components/parameters/JobID"
392
+ get:
393
+ summary: Stream job events (SSE)
394
+ responses:
395
+ "200":
396
+ description: text/event-stream of Event objects
397
+ content:
398
+ text/event-stream:
399
+ schema:
400
+ $ref: "#/components/schemas/Event"
401
+ /v1/sandbox/sessions:
402
+ post:
403
+ summary: Create a sandbox session (alias over mcp.test)
404
+ requestBody:
405
+ required: true
406
+ content:
407
+ application/json:
408
+ schema:
409
+ $ref: "#/components/schemas/SandboxRequest"
410
+ responses:
411
+ "202":
412
+ description: Accepted
413
+ content:
414
+ application/json:
415
+ schema:
416
+ $ref: "#/components/schemas/SandboxResponse"
417
+ /v1/sandbox/sessions/{session_id}:
418
+ parameters:
419
+ - $ref: "#/components/parameters/SessionID"
420
+ get:
421
+ summary: Read a sandbox session
422
+ responses:
423
+ "200": { description: OK }
424
+ "404": { $ref: "#/components/responses/Error" }
425
+ delete:
426
+ summary: Delete a sandbox session
427
+ responses:
428
+ "200": { description: OK }
429
+ "404": { $ref: "#/components/responses/Error" }
430
+ /v1/sandbox/sessions/{session_id}/events:
431
+ parameters:
432
+ - $ref: "#/components/parameters/SessionID"
433
+ get:
434
+ summary: Stream sandbox events (SSE)
435
+ responses:
436
+ "200":
437
+ description: text/event-stream of Event objects
438
+ /v1/sandbox/sessions/{session_id}/tools:
439
+ parameters:
440
+ - $ref: "#/components/parameters/SessionID"
441
+ get:
442
+ summary: List tools advertised by the sandbox's MCP server
443
+ responses:
444
+ "200":
445
+ description: OK
446
+ content:
447
+ application/json:
448
+ schema:
449
+ type: object
450
+ properties:
451
+ tools:
452
+ type: array
453
+ items: { $ref: "#/components/schemas/Tool" }
454
+ "409": { $ref: "#/components/responses/Error" }
455
+ /v1/sandbox/sessions/{session_id}/tools/call:
456
+ parameters:
457
+ - $ref: "#/components/parameters/SessionID"
458
+ post:
459
+ summary: Call a tool in the sandbox
460
+ requestBody:
461
+ required: true
462
+ content:
463
+ application/json:
464
+ schema:
465
+ type: object
466
+ required: [name]
467
+ properties:
468
+ name: { type: string }
469
+ arguments: { type: object, additionalProperties: true }
470
+ responses:
471
+ "200":
472
+ description: OK
473
+ content:
474
+ application/json:
475
+ schema:
476
+ type: object
477
+ properties:
478
+ ok: { type: boolean }
479
+ result: { type: object, additionalProperties: true }
480
+ "409": { $ref: "#/components/responses/Error" }
481
+ components:
482
+ securitySchemes:
483
+ bearerAuth:
484
+ type: http
485
+ scheme: bearer
486
+ parameters:
487
+ JobID:
488
+ name: job_id
489
+ in: path
490
+ required: true
491
+ schema: { type: string }
492
+ SessionID:
493
+ name: session_id
494
+ in: path
495
+ required: true
496
+ schema: { type: string }
497
+ responses:
498
+ Error:
499
+ description: Error
500
+ content:
501
+ application/json:
502
+ schema:
503
+ type: object
504
+ properties:
505
+ error: { type: string }
506
+ status: { type: integer }
507
+ schemas:
508
+ ModelProfile:
509
+ type: object
510
+ properties:
511
+ id: { type: string }
512
+ source_type: { type: string }
513
+ source_uri: { type: string }
514
+ provider: { type: string }
515
+ external_id: { type: string }
516
+ display_name: { type: string }
517
+ task: { type: string }
518
+ library: { type: string }
519
+ license: { type: string }
520
+ tags: { type: array, items: { type: string } }
521
+ status:
522
+ type: string
523
+ enum: [profile_only, queued, downloading, installed, attached, ready, failed, gated, incompatible]
524
+ ModelProfileImport:
525
+ type: object
526
+ properties:
527
+ source_type: { type: string }
528
+ source_uri: { type: string }
529
+ provider: { type: string }
530
+ external_id: { type: string }
531
+ display_name: { type: string }
532
+ task: { type: string }
533
+ library: { type: string }
534
+ license: { type: string }
535
+ tags: { type: array, items: { type: string } }
536
+ metadata: { type: object, additionalProperties: true }
537
+ ModelInstallation:
538
+ type: object
539
+ properties:
540
+ id: { type: string }
541
+ model_profile_id: { type: string }
542
+ runtime_id: { type: string }
543
+ install_mode: { type: string }
544
+ serving_engine: { type: string }
545
+ status:
546
+ type: string
547
+ enum: [queued, checking, downloading, verifying, attached, ready, failed]
548
+ progress: { type: integer }
549
+ local_path: { type: string }
550
+ model_name: { type: string }
551
+ provider: { type: string }
552
+ job_id: { type: string }
553
+ AuthUser:
554
+ type: object
555
+ properties:
556
+ id: { type: string }
557
+ name: { type: string }
558
+ email: { type: string }
559
+ role: { type: string, example: Owner }
560
+ workspace: { type: string }
561
+ workspace_slug: { type: string }
562
+ workspace_id: { type: string }
563
+ AuthResult:
564
+ type: object
565
+ properties:
566
+ token: { type: string, description: Session bearer token }
567
+ user: { $ref: "#/components/schemas/AuthUser" }
568
+ Capabilities:
569
+ type: object
570
+ properties:
571
+ capabilities:
572
+ type: array
573
+ items: { type: string }
574
+ example: [mcp.test, mcp.run, model.inspect, model.pull, agent.run, tool.run]
575
+ runtimes:
576
+ type: object
577
+ properties:
578
+ node: { type: boolean }
579
+ python: { type: boolean }
580
+ ollama: { type: boolean }
581
+ vllm: { type: boolean }
582
+ sglang: { type: boolean }
583
+ limits:
584
+ type: object
585
+ properties:
586
+ max_ttl_seconds: { type: integer }
587
+ max_concurrent_jobs: { type: integer }
588
+ CreateJobRequest:
589
+ type: object
590
+ required: [type]
591
+ properties:
592
+ type:
593
+ type: string
594
+ enum: [mcp.test, mcp.run, model.inspect, model.pull, model.preload, agent.run, tool.run]
595
+ ttl_seconds: { type: integer, example: 600 }
596
+ payload:
597
+ type: object
598
+ additionalProperties: true
599
+ CreateJobResponse:
600
+ type: object
601
+ properties:
602
+ job_id: { type: string }
603
+ status: { type: string }
604
+ events_url: { type: string }
605
+ Job:
606
+ type: object
607
+ properties:
608
+ job_id: { type: string }
609
+ type: { type: string }
610
+ status:
611
+ type: string
612
+ enum: [queued, running, complete, error, expired, cancelled]
613
+ created_at: { type: string, format: date-time }
614
+ expires_at: { type: string, format: date-time }
615
+ result: { nullable: true }
616
+ error: { type: string }
617
+ Event:
618
+ type: object
619
+ properties:
620
+ step: { type: string }
621
+ status:
622
+ type: string
623
+ enum: [queued, start, ok, error, running, expired, cancelled, complete]
624
+ message: { type: string }
625
+ data: { type: object, additionalProperties: true }
626
+ SandboxRequest:
627
+ type: object
628
+ required: [start_command]
629
+ properties:
630
+ entity_id: { type: string }
631
+ ttl_seconds: { type: integer }
632
+ runtime: { type: string, example: node }
633
+ transport: { type: string, example: stdio }
634
+ start_command: { type: string }
635
+ env:
636
+ type: object
637
+ additionalProperties: { type: string }
638
+ SandboxResponse:
639
+ type: object
640
+ properties:
641
+ session_id: { type: string }
642
+ job_id: { type: string }
643
+ status: { type: string }
644
+ expires_at: { type: string, format: date-time }
645
+ events_url: { type: string }
646
+ Tool:
647
+ type: object
648
+ properties:
649
+ name: { type: string }
650
+ description: { type: string }
651
+ input_schema: { type: object, additionalProperties: true }
api/production_test.go ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "testing"
8
+
9
+ "github.com/agent-matrix/matrix-runtime/internal/config"
10
+ "github.com/agent-matrix/matrix-runtime/internal/jobs"
11
+ )
12
+
13
+ func get(t *testing.T, srv *Server, path, token string) (*httptest.ResponseRecorder, map[string]any) {
14
+ t.Helper()
15
+ req := httptest.NewRequest(http.MethodGet, path, nil)
16
+ if token != "" {
17
+ req.Header.Set("Authorization", "Bearer "+token)
18
+ }
19
+ rec := httptest.NewRecorder()
20
+ srv.Handler().ServeHTTP(rec, req)
21
+ var out map[string]any
22
+ _ = json.Unmarshal(rec.Body.Bytes(), &out)
23
+ return rec, out
24
+ }
25
+
26
+ func TestVersionAndReady(t *testing.T) {
27
+ srv := testServer(t)
28
+ rec, body := get(t, srv, "/v1/version", "")
29
+ if rec.Code != http.StatusOK || body["name"] != "matrix-runtime" {
30
+ t.Fatalf("version = %d %v", rec.Code, body)
31
+ }
32
+ rec, body = get(t, srv, "/v1/ready", "")
33
+ // No store in testServer → not ready, with a store_unavailable warning.
34
+ if rec.Code != http.StatusServiceUnavailable {
35
+ t.Fatalf("ready status = %d, want 503 (no store)", rec.Code)
36
+ }
37
+ if _, ok := body["checks"].(map[string]any); !ok {
38
+ t.Fatalf("ready missing checks: %v", body)
39
+ }
40
+ if body["ready"] != false {
41
+ t.Errorf("ready = %v, want false", body["ready"])
42
+ }
43
+ }
44
+
45
+ func TestReadyHealthyWithStore(t *testing.T) {
46
+ srv := authServer(t) // has a store + temp data dir, local-dev
47
+ rec, body := get(t, srv, "/v1/ready", "")
48
+ if rec.Code != http.StatusOK || body["ready"] != true {
49
+ t.Fatalf("ready = %d %v", rec.Code, body)
50
+ }
51
+ // local-dev should surface a local_dev_mode warning.
52
+ ws, _ := body["warnings"].([]any)
53
+ found := false
54
+ for _, w := range ws {
55
+ if m, ok := w.(map[string]any); ok && m["code"] == "local_dev_mode" {
56
+ found = true
57
+ }
58
+ }
59
+ if !found {
60
+ t.Errorf("expected local_dev_mode warning, got %v", ws)
61
+ }
62
+ }
63
+
64
+ func TestProductionFailsClosedWithoutCreds(t *testing.T) {
65
+ cfg := config.Defaults(config.ModeCustomerAgent) // production mode
66
+ cfg.DataDir = t.TempDir()
67
+ srv := NewServer(cfg, jobs.NewManager(cfg), nil)
68
+
69
+ // Probes stay public.
70
+ if rec, _ := get(t, srv, "/v1/health", ""); rec.Code != http.StatusOK {
71
+ t.Fatalf("health should be public, got %d", rec.Code)
72
+ }
73
+ if rec, _ := get(t, srv, "/v1/version", ""); rec.Code != http.StatusOK {
74
+ t.Fatalf("version should be public, got %d", rec.Code)
75
+ }
76
+ // A protected endpoint with no creds is rejected (fail-closed).
77
+ if rec, _ := get(t, srv, "/v1/jobs", ""); rec.Code != http.StatusUnauthorized {
78
+ t.Fatalf("protected endpoint = %d, want 401", rec.Code)
79
+ }
80
+ }
81
+
82
+ func TestOperatorAPITokenGate(t *testing.T) {
83
+ cfg := config.Defaults(config.ModeCustomerAgent)
84
+ cfg.DataDir = t.TempDir()
85
+ cfg.APIToken = "s3cret-operator-token"
86
+ srv := NewServer(cfg, jobs.NewManager(cfg), nil)
87
+
88
+ if rec, _ := get(t, srv, "/v1/jobs", ""); rec.Code != http.StatusUnauthorized {
89
+ t.Fatalf("no token = %d, want 401", rec.Code)
90
+ }
91
+ if rec, _ := get(t, srv, "/v1/jobs", "wrong"); rec.Code != http.StatusUnauthorized {
92
+ t.Fatalf("wrong token = %d, want 401", rec.Code)
93
+ }
94
+ if rec, _ := get(t, srv, "/v1/jobs", "s3cret-operator-token"); rec.Code == http.StatusUnauthorized {
95
+ t.Fatalf("correct operator token should pass the auth gate, got 401")
96
+ }
97
+ }
98
+
99
+ func TestMatrixShellGate(t *testing.T) {
100
+ // Production mode → MatrixShell disabled by default → 403.
101
+ cfg := config.Defaults(config.ModeCustomerAgent)
102
+ cfg.DataDir = t.TempDir()
103
+ cfg.APIToken = "tok"
104
+ srv := NewServer(cfg, jobs.NewManager(cfg), nil)
105
+ if rec, _ := get(t, srv, "/v1/matrixshell/status", "tok"); rec.Code != http.StatusForbidden {
106
+ t.Fatalf("matrixshell (prod, disabled) = %d, want 403", rec.Code)
107
+ }
108
+
109
+ // Explicitly enabled → reachable (200).
110
+ cfg2 := config.Defaults(config.ModeCustomerAgent)
111
+ cfg2.DataDir = t.TempDir()
112
+ cfg2.APIToken = "tok"
113
+ cfg2.MatrixShellEnabled = true
114
+ srv2 := NewServer(cfg2, jobs.NewManager(cfg2), nil)
115
+ if rec, _ := get(t, srv2, "/v1/matrixshell/status", "tok"); rec.Code == http.StatusForbidden {
116
+ t.Fatalf("matrixshell (enabled) should not be 403")
117
+ }
118
+ }
119
+
120
+ func TestStorageEndpoint(t *testing.T) {
121
+ srv := authServer(t) // has store + temp data dir, local-dev (no auth required)
122
+ rec, body := get(t, srv, "/v1/system/storage", "")
123
+ if rec.Code != http.StatusOK {
124
+ t.Fatalf("storage = %d", rec.Code)
125
+ }
126
+ if _, ok := body["areas"].(map[string]any); !ok {
127
+ t.Fatalf("missing areas: %v", body)
128
+ }
129
+ if _, ok := body["total_bytes"]; !ok {
130
+ t.Errorf("missing total_bytes")
131
+ }
132
+ }
133
+
134
+ func TestRateLimit(t *testing.T) {
135
+ cfg := config.Defaults(config.ModeLocalDev)
136
+ cfg.DataDir = t.TempDir()
137
+ cfg.RateLimitRPM = 3
138
+ srv := NewServer(cfg, jobs.NewManager(cfg), nil)
139
+
140
+ // GET probes are never limited.
141
+ for i := 0; i < 10; i++ {
142
+ if rec, _ := get(t, srv, "/v1/health", ""); rec.Code == http.StatusTooManyRequests {
143
+ t.Fatalf("GET health should never be rate limited")
144
+ }
145
+ }
146
+ // POSTs are limited after RateLimitRPM within the window.
147
+ limited := false
148
+ for i := 0; i < 6; i++ {
149
+ req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", jsonBody(`{"email":"a@b.io","password":"x"}`))
150
+ rec := httptest.NewRecorder()
151
+ srv.Handler().ServeHTTP(rec, req)
152
+ if rec.Code == http.StatusTooManyRequests {
153
+ limited = true
154
+ break
155
+ }
156
+ }
157
+ if !limited {
158
+ t.Errorf("expected a 429 after exceeding the rate limit")
159
+ }
160
+ }
api/server.go ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package api exposes the matrix-runtime HTTP API: health, capabilities, jobs
2
+ // and the sandbox compatibility aliases used by MatrixHub.
3
+ package api
4
+
5
+ import (
6
+ "context"
7
+ "encoding/json"
8
+ "net"
9
+ "net/http"
10
+ "strings"
11
+ "time"
12
+
13
+ "github.com/agent-matrix/matrix-runtime/internal/config"
14
+ "github.com/agent-matrix/matrix-runtime/internal/email"
15
+ "github.com/agent-matrix/matrix-runtime/internal/jobs"
16
+ "github.com/agent-matrix/matrix-runtime/internal/store"
17
+ "github.com/agent-matrix/matrix-runtime/web"
18
+ )
19
+
20
+ // Server wires the job manager, user store, email sender and config to routes.
21
+ type Server struct {
22
+ cfg *config.Config
23
+ manager *jobs.Manager
24
+ store *store.Store
25
+ email *email.Sender
26
+ limiter *rateLimiter
27
+ }
28
+
29
+ // NewServer builds a Server. store may be nil if the user database could not be
30
+ // opened; auth endpoints then return 503.
31
+ func NewServer(cfg *config.Config, mgr *jobs.Manager, st *store.Store) *Server {
32
+ s := &Server{cfg: cfg, manager: mgr, store: st, email: email.NewFromEnv()}
33
+ if cfg.RateLimitRPM > 0 {
34
+ s.limiter = newRateLimiter(cfg.RateLimitRPM)
35
+ }
36
+ return s
37
+ }
38
+
39
+ // Handler returns the configured HTTP handler with all routes registered.
40
+ func (s *Server) Handler() http.Handler {
41
+ mux := http.NewServeMux()
42
+
43
+ mux.HandleFunc("GET /v1/health", s.handleHealth)
44
+ mux.HandleFunc("GET /v1/ready", s.handleReady)
45
+ mux.HandleFunc("GET /v1/version", s.handleVersion)
46
+ mux.HandleFunc("GET /v1/capabilities", s.handleCapabilities)
47
+ mux.HandleFunc("GET /v1/runtimes", s.handleListRuntimes)
48
+ mux.HandleFunc("GET /v1/catalog", s.handleCatalog)
49
+ mux.HandleFunc("GET /v1/policies", s.handlePolicies)
50
+
51
+ // Multitenant auth (users + sessions, SQLite or Postgres/Neon).
52
+ mux.HandleFunc("POST /v1/auth/signup", s.handleSignup)
53
+ mux.HandleFunc("POST /v1/auth/login", s.handleLogin)
54
+ mux.HandleFunc("GET /v1/auth/me", s.handleMe)
55
+ mux.HandleFunc("POST /v1/auth/logout", s.handleLogout)
56
+ // Password recovery + email verification (delivered via Resend).
57
+ mux.HandleFunc("POST /v1/auth/forgot", s.handleForgotPassword)
58
+ mux.HandleFunc("POST /v1/auth/reset", s.handleResetPassword)
59
+ mux.HandleFunc("POST /v1/auth/verify", s.handleVerifyEmail)
60
+
61
+ // Hosted control plane: runtimes, join tokens, BYO provider creds, usage.
62
+ mux.HandleFunc("GET /v1/cloud/runtimes", s.handleCloudListRuntimes)
63
+ mux.HandleFunc("POST /v1/cloud/runtimes/register", s.handleCloudRegisterRuntime)
64
+ mux.HandleFunc("POST /v1/cloud/runtimes/heartbeat", s.handleCloudHeartbeat)
65
+ mux.HandleFunc("GET /v1/cloud/join-tokens", s.handleCloudListJoinTokens)
66
+ mux.HandleFunc("POST /v1/cloud/join-tokens", s.handleCloudMintJoinToken)
67
+ mux.HandleFunc("GET /v1/cloud/providers", s.handleCloudListProviders)
68
+ mux.HandleFunc("POST /v1/cloud/providers", s.handleCloudSetProvider)
69
+ mux.HandleFunc("GET /v1/cloud/usage", s.handleCloudUsage)
70
+ mux.HandleFunc("GET /v1/cloud/audit", s.handleCloudAudit)
71
+
72
+ mux.HandleFunc("GET /v1/model-sources/huggingface/search", s.handleHFSearch)
73
+ mux.HandleFunc("POST /v1/model-sources/resolve", s.handleResolveSource)
74
+ mux.HandleFunc("GET /v1/model-profiles", s.handleListProfiles)
75
+ mux.HandleFunc("POST /v1/model-profiles", s.handleImportProfile)
76
+ mux.HandleFunc("POST /v1/model-profiles/{id}/attach", s.handleAttachProfile)
77
+ mux.HandleFunc("GET /v1/model-installations", s.handleListInstallations)
78
+
79
+ // MatrixShell — real local Python sandbox (install / status / exec).
80
+ mux.HandleFunc("GET /v1/matrixshell/status", s.handleMatrixShellStatus)
81
+ mux.HandleFunc("POST /v1/matrixshell/install", s.handleMatrixShellInstall)
82
+ mux.HandleFunc("POST /v1/matrixshell/exec", s.handleMatrixShellExec)
83
+
84
+ mux.HandleFunc("POST /v1/jobs", s.handleCreateJob)
85
+ mux.HandleFunc("GET /v1/jobs", s.handleListJobs)
86
+ mux.HandleFunc("GET /v1/jobs/{job_id}", s.handleGetJob)
87
+ mux.HandleFunc("GET /v1/jobs/{job_id}/events", s.handleJobEvents)
88
+ mux.HandleFunc("DELETE /v1/jobs/{job_id}", s.handleDeleteJob)
89
+
90
+ mux.HandleFunc("POST /v1/sandbox/sessions", s.handleCreateSandbox)
91
+ mux.HandleFunc("GET /v1/sandbox/sessions/{session_id}", s.handleGetSandbox)
92
+ mux.HandleFunc("GET /v1/sandbox/sessions/{session_id}/events", s.handleSandboxEvents)
93
+ mux.HandleFunc("GET /v1/sandbox/sessions/{session_id}/tools", s.handleSandboxTools)
94
+ mux.HandleFunc("POST /v1/sandbox/sessions/{session_id}/tools/call", s.handleSandboxToolCall)
95
+ mux.HandleFunc("DELETE /v1/sandbox/sessions/{session_id}", s.handleDeleteSandbox)
96
+
97
+ // API docs (OpenAPI spec + a self-contained viewer), public.
98
+ mux.HandleFunc("GET /openapi.yaml", s.handleOpenAPISpec)
99
+ mux.HandleFunc("GET /docs", s.handleDocs)
100
+
101
+ // System: storage usage.
102
+ mux.HandleFunc("GET /v1/system/storage", s.handleStorage)
103
+
104
+ // Enterprise console (static SPA) served from the embedded web assets.
105
+ mux.Handle("/", s.consoleHandler())
106
+
107
+ // Outermost: rate limiting (protects auth + writes); then auth.
108
+ return s.withRateLimit(s.withAuth(mux))
109
+ }
110
+
111
+ // consoleHandler serves the embedded console, falling back to index.html for
112
+ // client-side routes (single-page app).
113
+ func (s *Server) consoleHandler() http.Handler {
114
+ assets := web.Static()
115
+ fileServer := http.FileServer(http.FS(assets))
116
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
117
+ // Never let the SPA shadow the API namespace.
118
+ if strings.HasPrefix(r.URL.Path, "/v1/") {
119
+ http.NotFound(w, r)
120
+ return
121
+ }
122
+ // Serve the asset when it exists; otherwise hand back the app shell.
123
+ p := strings.TrimPrefix(r.URL.Path, "/")
124
+ if p == "" {
125
+ p = "index.html"
126
+ }
127
+ if f, err := assets.Open(p); err == nil {
128
+ _ = f.Close()
129
+ fileServer.ServeHTTP(w, r)
130
+ return
131
+ }
132
+ r2 := r.Clone(r.Context())
133
+ r2.URL.Path = "/"
134
+ fileServer.ServeHTTP(w, r2)
135
+ })
136
+ }
137
+
138
+ // Run starts the HTTP server and blocks until ctx is cancelled, then performs
139
+ // a graceful shutdown.
140
+ // Run binds addr and serves until ctx is cancelled. Kept for compatibility;
141
+ // prefer Serve with a pre-bound listener (e.g. from a port-fallback search).
142
+ func (s *Server) Run(ctx context.Context, addr string) error {
143
+ ln, err := net.Listen("tcp", addr)
144
+ if err != nil {
145
+ return err
146
+ }
147
+ return s.Serve(ctx, ln)
148
+ }
149
+
150
+ // Serve serves HTTP on the given listener until ctx is cancelled, then performs
151
+ // a graceful shutdown.
152
+ func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
153
+ srv := &http.Server{
154
+ Handler: s.Handler(),
155
+ ReadHeaderTimeout: 10 * time.Second,
156
+ }
157
+ errCh := make(chan error, 1)
158
+ go func() { errCh <- srv.Serve(ln) }()
159
+
160
+ select {
161
+ case err := <-errCh:
162
+ return err
163
+ case <-ctx.Done():
164
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
165
+ defer cancel()
166
+ return srv.Shutdown(shutdownCtx)
167
+ }
168
+ }
169
+
170
+ // writeJSON serialises v as JSON with the given status code.
171
+ func writeJSON(w http.ResponseWriter, status int, v any) {
172
+ w.Header().Set("Content-Type", "application/json")
173
+ w.WriteHeader(status)
174
+ _ = json.NewEncoder(w).Encode(v)
175
+ }
176
+
177
+ // writeError writes a structured JSON error.
178
+ func writeError(w http.ResponseWriter, status int, msg string) {
179
+ writeJSON(w, status, map[string]any{"error": msg, "status": status})
180
+ }
api/server_test.go ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "encoding/json"
5
+ "io"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "strings"
9
+ "testing"
10
+
11
+ "github.com/agent-matrix/matrix-runtime/internal/config"
12
+ "github.com/agent-matrix/matrix-runtime/internal/jobs"
13
+ )
14
+
15
+ func jsonBody(s string) io.Reader { return strings.NewReader(s) }
16
+
17
+ func testServer(t *testing.T) *Server {
18
+ t.Helper()
19
+ cfg := config.Defaults(config.ModeLocalDev)
20
+ cfg.DataDir = t.TempDir()
21
+ return NewServer(cfg, jobs.NewManager(cfg), nil)
22
+ }
23
+
24
+ func TestHealth(t *testing.T) {
25
+ srv := testServer(t)
26
+ req := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
27
+ rec := httptest.NewRecorder()
28
+ srv.Handler().ServeHTTP(rec, req)
29
+
30
+ if rec.Code != http.StatusOK {
31
+ t.Fatalf("status %d", rec.Code)
32
+ }
33
+ var body map[string]any
34
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ if body["status"] != "ok" {
38
+ t.Errorf("status = %v", body["status"])
39
+ }
40
+ }
41
+
42
+ func TestCapabilities(t *testing.T) {
43
+ srv := testServer(t)
44
+ req := httptest.NewRequest(http.MethodGet, "/v1/capabilities", nil)
45
+ rec := httptest.NewRecorder()
46
+ srv.Handler().ServeHTTP(rec, req)
47
+
48
+ if rec.Code != http.StatusOK {
49
+ t.Fatalf("status %d", rec.Code)
50
+ }
51
+ var body struct {
52
+ Capabilities []string `json:"capabilities"`
53
+ }
54
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
55
+ t.Fatal(err)
56
+ }
57
+ want := map[string]bool{"mcp.test": false, "model.inspect": false}
58
+ for _, c := range body.Capabilities {
59
+ if _, ok := want[c]; ok {
60
+ want[c] = true
61
+ }
62
+ }
63
+ for c, seen := range want {
64
+ if !seen {
65
+ t.Errorf("capability %q missing", c)
66
+ }
67
+ }
68
+ }
69
+
70
+ func TestCreateJob_BadType(t *testing.T) {
71
+ srv := testServer(t)
72
+ req := httptest.NewRequest(http.MethodPost, "/v1/jobs", jsonBody(`{"type":"nope"}`))
73
+ rec := httptest.NewRecorder()
74
+ srv.Handler().ServeHTTP(rec, req)
75
+ if rec.Code != http.StatusBadRequest {
76
+ t.Fatalf("status %d, want 400", rec.Code)
77
+ }
78
+ }
79
+
80
+ func TestGetJob_NotFound(t *testing.T) {
81
+ srv := testServer(t)
82
+ req := httptest.NewRequest(http.MethodGet, "/v1/jobs/job_missing", nil)
83
+ rec := httptest.NewRecorder()
84
+ srv.Handler().ServeHTTP(rec, req)
85
+ if rec.Code != http.StatusNotFound {
86
+ t.Fatalf("status %d, want 404", rec.Code)
87
+ }
88
+ }
app/main.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Launcher for the MatrixCloud Hugging Face Space.
3
+
4
+ HF runs this as the container entrypoint. It normalises the environment for a
5
+ Space (writable data dir, listening port that matches `app_port`) and then
6
+ hands off to the real `matrix-runtime` binary via exec, so the Go process
7
+ becomes PID 1's child with no extra layer.
8
+ """
9
+ import os
10
+ import sys
11
+
12
+ # HF maps external traffic to the container's app_port (7860 by default). Honor
13
+ # $PORT if the platform provides one, else fall back to 7860.
14
+ port = os.environ.get("PORT") or os.environ.get("MATRIX_RUNTIME_PORT") or "7860"
15
+ os.environ["MATRIX_RUNTIME_PORT"] = port
16
+
17
+ # The Space filesystem is largely read-only; /tmp is writable. Durable data
18
+ # (accounts, sessions) should be in Postgres via MATRIXCLOUD_DATABASE_URL.
19
+ data_dir = os.environ.setdefault("MATRIX_RUNTIME_DATA_DIR", "/tmp/matrixcloud")
20
+ try:
21
+ os.makedirs(data_dir, exist_ok=True)
22
+ except OSError as exc: # pragma: no cover - defensive
23
+ print(f"warning: could not create data dir {data_dir}: {exc}", file=sys.stderr)
24
+
25
+ mode = os.environ.get("MATRIX_RUNTIME_MODE", "cloud-worker")
26
+ print(f"MatrixCloud Space launching: mode={mode} port={port} data_dir={data_dir}", flush=True)
27
+
28
+ # Replace this process with the runtime binary.
29
+ os.execvp("matrix-runtime", ["matrix-runtime", "--mode", mode])
assets/banner.svg ADDED
assets/logo.svg ADDED
clients/python/README.md ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # matrixcloud — Python client &amp; CLI
2
+
3
+ Official Python SDK and command-line interface for **Matrix Cloud / Matrix
4
+ Runtime**. Talk to the `/v1` API: authenticate, run jobs, inspect models, and
5
+ drive MCP sandboxes.
6
+
7
+ ## Install (with [uv](https://docs.astral.sh/uv/) — fast)
8
+
9
+ ```bash
10
+ cd clients/python
11
+ uv sync --extra dev # creates .venv and installs the package + dev tools
12
+ uv run mxc status # or: source .venv/bin/activate && mxc status
13
+ ```
14
+
15
+ From the repo root you can also run `make venv` (uv) and `make py-test`.
16
+
17
+ Plain pip works too:
18
+
19
+ ```bash
20
+ python -m venv .venv && . .venv/bin/activate
21
+ pip install -e '.[dev]'
22
+ ```
23
+
24
+ ## CLI
25
+
26
+ ```bash
27
+ mxc signup # create a workspace + owner
28
+ mxc login # sign in (token stored in ~/.config/matrixcloud)
29
+ mxc status # runtime health + capabilities
30
+ mxc jobs # list jobs
31
+ mxc inspect hf:Qwen/Qwen2.5-7B-Instruct
32
+ mxc sandbox start mcp_server:filesystem \
33
+ --cmd 'npx -y @modelcontextprotocol/server-filesystem /tmp'
34
+ mxc sandbox tools <session_id>
35
+ mxc sandbox call <session_id> list_directory --args '{"path":"/tmp"}'
36
+ mxc sandbox stop <session_id>
37
+ ```
38
+
39
+ Point at a remote runtime with `--url` or `MATRIXCLOUD_URL`; use
40
+ `MATRIXCLOUD_TOKEN` to pass a session token in CI.
41
+
42
+ ## Library
43
+
44
+ ```python
45
+ from matrixcloud import MatrixCloud
46
+
47
+ with MatrixCloud("http://localhost:8080") as mc:
48
+ mc.login("you@acme.io", "secret123")
49
+ print(mc.capabilities()["capabilities"])
50
+ meta = mc.inspect_model("hf:Qwen/Qwen2.5-7B-Instruct")
51
+ print(meta["recommended_runtime"], meta["estimated_parameters"])
52
+
53
+ s = mc.create_sandbox("mcp_server:filesystem",
54
+ "npx -y @modelcontextprotocol/server-filesystem /tmp")
55
+ for ev in mc.stream_sandbox_events(s["session_id"]):
56
+ print(ev["step"], ev["message"])
57
+ if ev["step"] == "ready":
58
+ break
59
+ print([t["name"] for t in mc.sandbox_tools(s["session_id"])])
60
+ mc.delete_sandbox(s["session_id"])
61
+ ```
62
+
63
+ Licensed under Apache-2.0.
clients/python/pyproject.toml ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "matrixcloud"
7
+ version = "0.1.0"
8
+ description = "Official Python client and CLI for Matrix Cloud / Matrix Runtime."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "Apache-2.0"
12
+ authors = [{ name = "agent-matrix" }]
13
+ keywords = ["matrix", "matrixcloud", "mcp", "ai", "agents", "runtime", "cli"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Topic :: Software Development :: Libraries",
20
+ ]
21
+ dependencies = [
22
+ "httpx>=0.27",
23
+ "typer>=0.12",
24
+ "rich>=13.7",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "pytest>=8.0",
30
+ "ruff>=0.6",
31
+ "mypy>=1.11",
32
+ ]
33
+
34
+ [project.scripts]
35
+ matrixcloud = "matrixcloud.cli:app"
36
+ mxc = "matrixcloud.cli:app"
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/agent-matrix/matrix-runtime"
40
+ Repository = "https://github.com/agent-matrix/matrix-runtime"
41
+ Documentation = "https://github.com/agent-matrix/matrix-runtime/blob/main/docs/console.md"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/matrixcloud"]
45
+
46
+ [tool.ruff]
47
+ line-length = 100
48
+ target-version = "py39"
49
+ src = ["src", "tests"]
50
+
51
+ [tool.ruff.lint]
52
+ select = ["E", "F", "I", "UP", "B", "W"]
53
+ # Keep typing compatible with Python 3.9 + Typer's runtime hint resolution
54
+ # (X | None / list[...] break Typer on 3.9), so skip the pyupgrade typing rules.
55
+ ignore = ["E501", "UP006", "UP007", "UP035", "UP037", "UP045"]
56
+
57
+ [tool.mypy]
58
+ python_version = "3.9"
59
+ strict = false
60
+ ignore_missing_imports = true
61
+ files = ["src"]
62
+
63
+ [tool.pytest.ini_options]
64
+ testpaths = ["tests"]
65
+ addopts = "-q"
clients/python/src/matrixcloud/__init__.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Matrix Cloud — official Python client and CLI.
2
+
3
+ Talk to a Matrix Runtime control surface (the /v1 API): authenticate, run jobs,
4
+ inspect models, and drive MCP sandboxes.
5
+
6
+ from matrixcloud import MatrixCloud
7
+
8
+ with MatrixCloud("http://localhost:8080") as mc:
9
+ mc.login("you@acme.io", "secret123")
10
+ print(mc.capabilities()["capabilities"])
11
+ print(mc.inspect_model("hf:Qwen/Qwen2.5-7B-Instruct")["recommended_runtime"])
12
+ """
13
+
14
+ from .client import MatrixCloud
15
+ from .errors import AuthError, MatrixCloudError
16
+
17
+ __all__ = ["MatrixCloud", "MatrixCloudError", "AuthError", "__version__"]
18
+ __version__ = "0.1.0"
clients/python/src/matrixcloud/cli.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Matrix Cloud command-line interface (``matrixcloud`` / ``mxc``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from typing import Optional
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from . import config as cfg
14
+ from .client import DEFAULT_BASE_URL, MatrixCloud
15
+ from .errors import MatrixCloudError
16
+
17
+ app = typer.Typer(no_args_is_help=True, add_completion=False, help="Matrix Cloud — control your runtime from the terminal.")
18
+ sandbox_app = typer.Typer(no_args_is_help=True, help="Drive MCP sandboxes.")
19
+ app.add_typer(sandbox_app, name="sandbox")
20
+ console = Console()
21
+
22
+
23
+ def _base(url: Optional[str]) -> str:
24
+ return url or os.environ.get("MATRIXCLOUD_URL") or cfg.load().get("base_url") or DEFAULT_BASE_URL
25
+
26
+
27
+ def _client(url: Optional[str] = None) -> MatrixCloud:
28
+ token = os.environ.get("MATRIXCLOUD_TOKEN") or cfg.load().get("token")
29
+ return MatrixCloud(base_url=_base(url), token=token)
30
+
31
+
32
+ def _fail(msg: str) -> None:
33
+ console.print(f"[bold red]✗[/] {msg}")
34
+ raise typer.Exit(1)
35
+
36
+
37
+ @app.command()
38
+ def login(
39
+ email: str = typer.Option(..., prompt=True),
40
+ password: str = typer.Option(..., prompt=True, hide_input=True),
41
+ url: Optional[str] = typer.Option(None, help="Runtime base URL"),
42
+ ) -> None:
43
+ """Sign in and store a session token."""
44
+ c = _client(url)
45
+ try:
46
+ out = c.login(email, password)
47
+ except MatrixCloudError as e:
48
+ _fail(f"login failed: {e}")
49
+ cfg.save(c.base_url, c.token, email)
50
+ u = out["user"]
51
+ console.print(f"[bold green]✓[/] signed in as [b]{u['email']}[/] · {u['role']} · {u['workspace']}")
52
+
53
+
54
+ @app.command()
55
+ def signup(
56
+ name: str = typer.Option(..., prompt=True),
57
+ email: str = typer.Option(..., prompt=True),
58
+ password: str = typer.Option(..., prompt=True, hide_input=True, confirmation_prompt=True),
59
+ workspace: Optional[str] = typer.Option(None, help="Workspace (tenant) name"),
60
+ url: Optional[str] = typer.Option(None),
61
+ ) -> None:
62
+ """Create a workspace + owner account."""
63
+ c = _client(url)
64
+ try:
65
+ out = c.signup(name, email, password, workspace)
66
+ except MatrixCloudError as e:
67
+ _fail(f"signup failed: {e}")
68
+ cfg.save(c.base_url, c.token, email)
69
+ u = out["user"]
70
+ console.print(f"[bold green]✓[/] workspace [b]{u['workspace']}[/] created — signed in as {u['email']}")
71
+
72
+
73
+ @app.command()
74
+ def logout(all_sessions: bool = typer.Option(False, "--all", help="Sign out of every session")) -> None:
75
+ """Sign out and clear stored credentials."""
76
+ try:
77
+ _client().logout(all_sessions)
78
+ except MatrixCloudError:
79
+ pass
80
+ cfg.clear()
81
+ console.print("[green]✓[/] signed out")
82
+
83
+
84
+ @app.command()
85
+ def me() -> None:
86
+ """Show the authenticated user."""
87
+ try:
88
+ u = _client().me()
89
+ except MatrixCloudError as e:
90
+ _fail(str(e))
91
+ console.print(f"[b]{u['name']}[/] <{u['email']}> · {u['role']} · workspace [b]{u['workspace']}[/]")
92
+
93
+
94
+ @app.command()
95
+ def status(url: Optional[str] = typer.Option(None)) -> None:
96
+ """Runtime health + capabilities (live)."""
97
+ c = _client(url)
98
+ try:
99
+ h = c.health()
100
+ caps = c.capabilities()
101
+ except MatrixCloudError as e:
102
+ _fail(f"runtime unreachable at {c.base_url}: {e}")
103
+ console.print(f"[bold green]●[/] [b]{h['runtime_id']}[/] · mode [b]{h['mode']}[/] · v{h['version']} ([dim]{c.base_url}[/])")
104
+ console.print(" capabilities: " + ", ".join(caps.get("capabilities", [])))
105
+ limits = caps.get("limits", {})
106
+ console.print(f" limits: max_ttl={limits.get('max_ttl_seconds')}s · max_jobs={limits.get('max_concurrent_jobs')}")
107
+
108
+
109
+ @app.command()
110
+ def jobs(url: Optional[str] = typer.Option(None)) -> None:
111
+ """List jobs (newest first)."""
112
+ try:
113
+ rows = _client(url).list_jobs()
114
+ except MatrixCloudError as e:
115
+ _fail(str(e))
116
+ if not rows:
117
+ console.print("[dim]no jobs yet[/]")
118
+ return
119
+ t = Table("Job", "Type", "Status", "Created", box=None, header_style="bold")
120
+ colour = {"complete": "green", "running": "cyan", "error": "red", "expired": "yellow", "queued": "dim", "cancelled": "yellow"}
121
+ for j in rows:
122
+ st = j.get("status", "")
123
+ t.add_row(j.get("job_id", ""), j.get("type", ""), f"[{colour.get(st, 'white')}]{st}[/]", (j.get("created_at") or "").replace("T", " ").replace("Z", ""))
124
+ console.print(t)
125
+
126
+
127
+ @app.command()
128
+ def inspect(model: str, revision: str = typer.Option("main"), url: Optional[str] = typer.Option(None)) -> None:
129
+ """Resolve a model's metadata via model.inspect (live)."""
130
+ try:
131
+ meta = _client(url).inspect_model(model, revision)
132
+ except MatrixCloudError as e:
133
+ _fail(str(e))
134
+ t = Table(show_header=False, box=None)
135
+ for k in ("model", "pipeline_tag", "library_name", "model_type", "license", "estimated_parameters", "recommended_runtime", "requires_gpu"):
136
+ if k in meta:
137
+ t.add_row(f"[dim]{k}[/]", str(meta[k]))
138
+ console.print(t)
139
+
140
+
141
+ @sandbox_app.command("start")
142
+ def sandbox_start(
143
+ entity_id: str = typer.Argument(..., help="e.g. mcp_server:filesystem"),
144
+ command: str = typer.Option(..., "--cmd", help="start command, e.g. 'npx -y @modelcontextprotocol/server-filesystem /tmp'"),
145
+ ttl: int = typer.Option(600),
146
+ url: Optional[str] = typer.Option(None),
147
+ ) -> None:
148
+ """Start a sandbox session and stream its lifecycle until ready."""
149
+ c = _client(url)
150
+ try:
151
+ s = c.create_sandbox(entity_id, command, ttl_seconds=ttl)
152
+ except MatrixCloudError as e:
153
+ _fail(str(e))
154
+ sid = s["session_id"]
155
+ console.print(f"[green]✓[/] sandbox [b]{sid}[/] starting (job {s.get('job_id')})")
156
+ try:
157
+ for ev in c.stream_sandbox_events(sid):
158
+ console.print(f" [dim]{ev.get('step','')}[/] {ev.get('message','')}")
159
+ if ev.get("step") == "ready":
160
+ break
161
+ if ev.get("status") in ("error", "expired"):
162
+ break
163
+ except MatrixCloudError:
164
+ pass
165
+ console.print(f"tools: mxc sandbox tools {sid}")
166
+
167
+
168
+ @sandbox_app.command("tools")
169
+ def sandbox_tools(session_id: str, url: Optional[str] = typer.Option(None)) -> None:
170
+ """List the tools exposed by a sandbox."""
171
+ try:
172
+ tools = _client(url).sandbox_tools(session_id)
173
+ except MatrixCloudError as e:
174
+ _fail(str(e))
175
+ t = Table("Tool", "Description", box=None, header_style="bold")
176
+ for tool in tools:
177
+ t.add_row(tool.get("name", ""), (tool.get("description", "") or "")[:80])
178
+ console.print(t)
179
+
180
+
181
+ @sandbox_app.command("call")
182
+ def sandbox_call(
183
+ session_id: str,
184
+ name: str,
185
+ args: str = typer.Option("{}", "--args", help="JSON arguments"),
186
+ url: Optional[str] = typer.Option(None),
187
+ ) -> None:
188
+ """Call a tool in a sandbox."""
189
+ try:
190
+ arguments = json.loads(args)
191
+ except ValueError:
192
+ _fail("--args must be valid JSON")
193
+ try:
194
+ out = _client(url).call_sandbox_tool(session_id, name, arguments)
195
+ except MatrixCloudError as e:
196
+ _fail(str(e))
197
+ console.print_json(data=out)
198
+
199
+
200
+ @sandbox_app.command("stop")
201
+ def sandbox_stop(session_id: str, url: Optional[str] = typer.Option(None)) -> None:
202
+ """Delete a sandbox session."""
203
+ try:
204
+ _client(url).delete_sandbox(session_id)
205
+ except MatrixCloudError as e:
206
+ _fail(str(e))
207
+ console.print(f"[green]✓[/] stopped {session_id}")
208
+
209
+
210
+ if __name__ == "__main__":
211
+ app()
clients/python/src/matrixcloud/client.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Synchronous HTTP client for the Matrix Runtime /v1 API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from typing import Any, Dict, Iterator, List, Optional
8
+
9
+ import httpx
10
+
11
+ from .errors import AuthError, MatrixCloudError
12
+
13
+ DEFAULT_BASE_URL = "http://localhost:8080"
14
+ _TERMINAL = {"complete", "error", "expired", "cancelled"}
15
+
16
+
17
+ class MatrixCloud:
18
+ """A thin, typed client over the runtime's REST API.
19
+
20
+ Pass ``transport`` (an ``httpx.BaseTransport``) to inject a mock in tests.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ base_url: str = DEFAULT_BASE_URL,
26
+ token: Optional[str] = None,
27
+ timeout: float = 30.0,
28
+ transport: Optional[httpx.BaseTransport] = None,
29
+ ) -> None:
30
+ self.base_url = base_url.rstrip("/")
31
+ self.token = token
32
+ self._http = httpx.Client(base_url=self.base_url, timeout=timeout, transport=transport)
33
+
34
+ # -- lifecycle -------------------------------------------------------
35
+ def close(self) -> None:
36
+ self._http.close()
37
+
38
+ def __enter__(self) -> "MatrixCloud":
39
+ return self
40
+
41
+ def __exit__(self, *_exc: object) -> None:
42
+ self.close()
43
+
44
+ def _headers(self) -> Dict[str, str]:
45
+ h = {"Accept": "application/json"}
46
+ if self.token:
47
+ h["Authorization"] = "Bearer " + self.token
48
+ return h
49
+
50
+ def _request(self, method: str, path: str, body: Any = None) -> Any:
51
+ try:
52
+ r = self._http.request(method, path, headers=self._headers(), json=body)
53
+ except httpx.HTTPError as e: # network / connection error
54
+ raise MatrixCloudError(f"request to {path} failed: {e}") from e
55
+ data: Any = None
56
+ if r.content:
57
+ try:
58
+ data = r.json()
59
+ except ValueError:
60
+ data = r.text
61
+ if r.status_code >= 400:
62
+ msg = data.get("error") if isinstance(data, dict) else (data or f"HTTP {r.status_code}")
63
+ if r.status_code in (401, 403):
64
+ raise AuthError(str(msg), status=r.status_code, data=data)
65
+ raise MatrixCloudError(str(msg), status=r.status_code, data=data)
66
+ return data
67
+
68
+ # -- auth ------------------------------------------------------------
69
+ def signup(self, name: str, email: str, password: str, workspace: Optional[str] = None) -> Dict[str, Any]:
70
+ out = self._request("POST", "/v1/auth/signup", {"name": name, "email": email, "password": password, "workspace": workspace})
71
+ self.token = out.get("token")
72
+ return out
73
+
74
+ def login(self, email: str, password: str) -> Dict[str, Any]:
75
+ out = self._request("POST", "/v1/auth/login", {"email": email, "password": password})
76
+ self.token = out.get("token")
77
+ return out
78
+
79
+ def me(self) -> Dict[str, Any]:
80
+ return self._request("GET", "/v1/auth/me")["user"]
81
+
82
+ def logout(self, all_sessions: bool = False) -> None:
83
+ self._request("POST", "/v1/auth/logout" + ("?all=true" if all_sessions else ""), {})
84
+ self.token = None
85
+
86
+ # -- platform --------------------------------------------------------
87
+ def health(self) -> Dict[str, Any]:
88
+ return self._request("GET", "/v1/health")
89
+
90
+ def capabilities(self) -> Dict[str, Any]:
91
+ return self._request("GET", "/v1/capabilities")
92
+
93
+ # -- jobs ------------------------------------------------------------
94
+ def create_job(self, type: str, payload: Optional[Dict[str, Any]] = None, ttl_seconds: Optional[int] = None) -> Dict[str, Any]:
95
+ body: Dict[str, Any] = {"type": type}
96
+ if payload is not None:
97
+ body["payload"] = payload
98
+ if ttl_seconds is not None:
99
+ body["ttl_seconds"] = ttl_seconds
100
+ return self._request("POST", "/v1/jobs", body)
101
+
102
+ def get_job(self, job_id: str) -> Dict[str, Any]:
103
+ return self._request("GET", "/v1/jobs/" + job_id)
104
+
105
+ def list_jobs(self) -> List[Dict[str, Any]]:
106
+ return self._request("GET", "/v1/jobs").get("jobs", [])
107
+
108
+ def cancel_job(self, job_id: str) -> Dict[str, Any]:
109
+ return self._request("DELETE", "/v1/jobs/" + job_id)
110
+
111
+ def run_job(
112
+ self,
113
+ type: str,
114
+ payload: Optional[Dict[str, Any]] = None,
115
+ ttl_seconds: Optional[int] = None,
116
+ timeout: float = 60.0,
117
+ poll: float = 0.5,
118
+ ) -> Any:
119
+ """Create a job and block until it reaches a terminal state."""
120
+ job = self.create_job(type, payload, ttl_seconds)
121
+ job_id = job["job_id"]
122
+ deadline = time.time() + timeout
123
+ while time.time() < deadline:
124
+ snap = self.get_job(job_id)
125
+ if snap["status"] in _TERMINAL:
126
+ if snap["status"] != "complete":
127
+ raise MatrixCloudError(snap.get("error") or snap["status"], data=snap)
128
+ return snap.get("result")
129
+ time.sleep(poll)
130
+ raise MatrixCloudError(f"job {job_id} timed out after {timeout}s")
131
+
132
+ def inspect_model(self, model: str, revision: str = "main") -> Dict[str, Any]:
133
+ return self.run_job("model.inspect", {"model": model, "revision": revision})
134
+
135
+ def stream_job_events(self, job_id: str) -> Iterator[Dict[str, Any]]:
136
+ yield from self._sse("/v1/jobs/" + job_id + "/events")
137
+
138
+ # -- sandboxes -------------------------------------------------------
139
+ def create_sandbox(
140
+ self,
141
+ entity_id: str,
142
+ start_command: str,
143
+ runtime: str = "node",
144
+ transport: str = "stdio",
145
+ ttl_seconds: int = 600,
146
+ ) -> Dict[str, Any]:
147
+ return self._request("POST", "/v1/sandbox/sessions", {
148
+ "entity_id": entity_id,
149
+ "start_command": start_command,
150
+ "runtime": runtime,
151
+ "transport": transport,
152
+ "ttl_seconds": ttl_seconds,
153
+ })
154
+
155
+ def get_sandbox(self, session_id: str) -> Dict[str, Any]:
156
+ return self._request("GET", "/v1/sandbox/sessions/" + session_id)
157
+
158
+ def sandbox_tools(self, session_id: str) -> List[Dict[str, Any]]:
159
+ return self._request("GET", "/v1/sandbox/sessions/" + session_id + "/tools").get("tools", [])
160
+
161
+ def call_sandbox_tool(self, session_id: str, name: str, arguments: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
162
+ return self._request("POST", "/v1/sandbox/sessions/" + session_id + "/tools/call", {"name": name, "arguments": arguments or {}})
163
+
164
+ def delete_sandbox(self, session_id: str) -> Dict[str, Any]:
165
+ return self._request("DELETE", "/v1/sandbox/sessions/" + session_id)
166
+
167
+ def stream_sandbox_events(self, session_id: str) -> Iterator[Dict[str, Any]]:
168
+ yield from self._sse("/v1/sandbox/sessions/" + session_id + "/events")
169
+
170
+ # -- internals -------------------------------------------------------
171
+ def _sse(self, path: str) -> Iterator[Dict[str, Any]]:
172
+ with self._http.stream("GET", path, headers=self._headers()) as r:
173
+ if r.status_code >= 400:
174
+ raise MatrixCloudError(f"stream {path}: HTTP {r.status_code}", status=r.status_code)
175
+ for line in r.iter_lines():
176
+ if line.startswith("data: "):
177
+ try:
178
+ yield json.loads(line[6:])
179
+ except ValueError:
180
+ continue
clients/python/src/matrixcloud/config.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tiny credential store for the CLI (~/.config/matrixcloud/credentials.json)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Optional
9
+
10
+
11
+ def _config_dir() -> Path:
12
+ override = os.environ.get("MATRIXCLOUD_CONFIG_DIR")
13
+ if override:
14
+ return Path(override)
15
+ return Path.home() / ".config" / "matrixcloud"
16
+
17
+
18
+ def _cred_path() -> Path:
19
+ return _config_dir() / "credentials.json"
20
+
21
+
22
+ def save(base_url: str, token: Optional[str], email: Optional[str] = None) -> None:
23
+ d = _config_dir()
24
+ d.mkdir(parents=True, exist_ok=True)
25
+ path = _cred_path()
26
+ path.write_text(json.dumps({"base_url": base_url, "token": token, "email": email}, indent=2))
27
+ try:
28
+ path.chmod(0o600)
29
+ except OSError:
30
+ pass
31
+
32
+
33
+ def load() -> Dict[str, Any]:
34
+ try:
35
+ return json.loads(_cred_path().read_text())
36
+ except (OSError, ValueError):
37
+ return {}
38
+
39
+
40
+ def clear() -> None:
41
+ try:
42
+ _cred_path().unlink()
43
+ except OSError:
44
+ pass
clients/python/src/matrixcloud/errors.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Exception types for the Matrix Cloud client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+
8
+ class MatrixCloudError(Exception):
9
+ """Base error for all client failures."""
10
+
11
+ def __init__(self, message: str, status: Optional[int] = None, data: Any = None) -> None:
12
+ super().__init__(message)
13
+ self.status = status
14
+ self.data = data
15
+
16
+
17
+ class AuthError(MatrixCloudError):
18
+ """Raised on 401/403 — missing or invalid credentials."""
clients/python/tests/test_client.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for the Matrix Cloud client using an in-memory httpx transport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import httpx
8
+ import pytest
9
+
10
+ from matrixcloud import AuthError, MatrixCloud, MatrixCloudError
11
+
12
+
13
+ def make_handler():
14
+ """A fake runtime: routes a handful of /v1 endpoints."""
15
+ jobs: dict = {}
16
+
17
+ def handler(request: httpx.Request) -> httpx.Response:
18
+ path = request.url.path
19
+ method = request.method
20
+ body = json.loads(request.content) if request.content else {}
21
+
22
+ if path == "/v1/health":
23
+ return httpx.Response(200, json={"status": "ok", "runtime_id": "rt_test", "mode": "local-dev", "version": "0.1.0"})
24
+ if path == "/v1/capabilities":
25
+ return httpx.Response(200, json={"capabilities": ["mcp.test", "model.inspect"], "limits": {"max_ttl_seconds": 600, "max_concurrent_jobs": 2}})
26
+ if path == "/v1/auth/login" and method == "POST":
27
+ if body.get("password") == "secret123":
28
+ return httpx.Response(200, json={"token": "tok_abc", "user": {"email": body["email"], "role": "Owner", "workspace": "Acme", "name": "Neo"}})
29
+ return httpx.Response(401, json={"error": "invalid email or password"})
30
+ if path == "/v1/auth/me":
31
+ if request.headers.get("Authorization") == "Bearer tok_abc":
32
+ return httpx.Response(200, json={"user": {"email": "neo@acme.io", "role": "Owner", "workspace": "Acme", "name": "Neo"}})
33
+ return httpx.Response(401, json={"error": "not authenticated"})
34
+ if path == "/v1/jobs" and method == "POST":
35
+ jid = "job_" + str(len(jobs) + 1)
36
+ jobs[jid] = {"job_id": jid, "type": body["type"], "status": "complete",
37
+ "result": {"model": body.get("payload", {}).get("model"), "recommended_runtime": "vllm"}}
38
+ return httpx.Response(202, json={"job_id": jid, "status": "queued"})
39
+ if path == "/v1/jobs" and method == "GET":
40
+ return httpx.Response(200, json={"jobs": list(jobs.values())})
41
+ if path.startswith("/v1/jobs/") and method == "GET":
42
+ jid = path.rsplit("/", 1)[-1]
43
+ if jid in jobs:
44
+ return httpx.Response(200, json=jobs[jid])
45
+ return httpx.Response(404, json={"error": "job not found"})
46
+
47
+ return httpx.Response(404, json={"error": "not found: " + path})
48
+
49
+ return handler
50
+
51
+
52
+ def client() -> MatrixCloud:
53
+ return MatrixCloud(base_url="http://rt.test", transport=httpx.MockTransport(make_handler()))
54
+
55
+
56
+ def test_health_and_capabilities():
57
+ with client() as c:
58
+ assert c.health()["status"] == "ok"
59
+ assert "model.inspect" in c.capabilities()["capabilities"]
60
+
61
+
62
+ def test_login_sets_token_and_me():
63
+ with client() as c:
64
+ out = c.login("neo@acme.io", "secret123")
65
+ assert out["token"] == "tok_abc"
66
+ assert c.token == "tok_abc"
67
+ assert c.me()["workspace"] == "Acme"
68
+
69
+
70
+ def test_login_wrong_password_raises_auth_error():
71
+ with client() as c:
72
+ with pytest.raises(AuthError):
73
+ c.login("neo@acme.io", "nope")
74
+
75
+
76
+ def test_me_without_token_raises():
77
+ with client() as c:
78
+ with pytest.raises(AuthError):
79
+ c.me()
80
+
81
+
82
+ def test_run_job_and_inspect_model():
83
+ with client() as c:
84
+ meta = c.inspect_model("hf:Qwen/Qwen2.5-7B-Instruct")
85
+ assert meta["recommended_runtime"] == "vllm"
86
+ assert len(c.list_jobs()) == 1
87
+
88
+
89
+ def test_unknown_path_raises():
90
+ with client() as c:
91
+ with pytest.raises(MatrixCloudError):
92
+ c.get_job("missing")
clients/python/uv.lock ADDED
The diff for this file is too large to render. See raw diff
 
cmd/matrix-runtime/main.go ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Command matrix-runtime is the execution plane for Matrix Cloud. It serves an
2
+ // HTTP API for running MCP sandboxes, inspecting models and (in future)
3
+ // agents/tools, and can join a MatrixHub Cloud control plane.
4
+ package main
5
+
6
+ import (
7
+ "context"
8
+ "errors"
9
+ "flag"
10
+ "fmt"
11
+ "log"
12
+ "net"
13
+ "os"
14
+ "os/signal"
15
+ "strconv"
16
+ "syscall"
17
+
18
+ "github.com/agent-matrix/matrix-runtime/api"
19
+ "github.com/agent-matrix/matrix-runtime/internal/config"
20
+ "github.com/agent-matrix/matrix-runtime/internal/controlplane"
21
+ "github.com/agent-matrix/matrix-runtime/internal/jobs"
22
+ "github.com/agent-matrix/matrix-runtime/internal/store"
23
+ )
24
+
25
+ func main() {
26
+ if len(os.Args) > 1 && os.Args[1] == "join" {
27
+ if err := runJoin(os.Args[2:]); err != nil {
28
+ log.Fatalf("join: %v", err)
29
+ }
30
+ return
31
+ }
32
+ if err := runServe(os.Args[1:]); err != nil {
33
+ log.Fatalf("matrix-runtime: %v", err)
34
+ }
35
+ }
36
+
37
+ func runServe(args []string) error {
38
+ fs := flag.NewFlagSet("matrix-runtime", flag.ExitOnError)
39
+ mode := fs.String("mode", "", "runtime mode: cloud-worker|customer-agent|hf-space|local-dev")
40
+ port := fs.Int("port", 0, "HTTP port (overrides MATRIX_RUNTIME_PORT)")
41
+ if err := fs.Parse(args); err != nil {
42
+ return err
43
+ }
44
+
45
+ cfg := config.FromEnv(*mode)
46
+ if *port != 0 {
47
+ cfg.Port = *port
48
+ }
49
+ if err := cfg.Validate(); err != nil {
50
+ return err
51
+ }
52
+
53
+ // Loud, actionable warnings for unsafe production configuration. These do not
54
+ // abort startup (the readiness probe also reports them) but they should be
55
+ // impossible to miss in logs.
56
+ if cfg.IsProduction() {
57
+ if cfg.APIToken == "" {
58
+ log.Printf("WARNING: MATRIX_RUNTIME_API_TOKEN is not set in %s mode — the API is unauthenticated for non-session callers", cfg.Mode)
59
+ }
60
+ if cfg.DatabaseURL == "" {
61
+ log.Printf("WARNING: using SQLite in %s mode — set MATRIX_RUNTIME_DATABASE_URL (Postgres) for multi-user/HA deployments", cfg.Mode)
62
+ }
63
+ }
64
+ if cfg.MatrixShellEnabled {
65
+ log.Printf("WARNING: MatrixShell is ENABLED — it executes commands in a local sandbox (disable with MATRIX_SHELL_ENABLED=false)")
66
+ }
67
+
68
+ mgr := jobs.NewManager(cfg)
69
+ defer mgr.Shutdown()
70
+
71
+ if err := mgr.Layout().EnsureDirs(); err != nil {
72
+ log.Printf("warning: could not create data dirs under %s: %v", cfg.DataDir, err)
73
+ }
74
+
75
+ // Open the multitenant user store. When a PostgreSQL/Neon URL is configured
76
+ // (hosted control plane, e.g. cloud.matrixhub.io) use it and isolate all
77
+ // objects in cfg.DBSchema; otherwise fall back to a local SQLite file. A
78
+ // failure here is non-fatal: the console loads but auth endpoints return 503.
79
+ var (
80
+ st *store.Store
81
+ err error
82
+ dbDesc string
83
+ )
84
+ if cfg.DatabaseURL != "" {
85
+ st, err = store.OpenPostgres(cfg.DatabaseURL, cfg.DBSchema, cfg.DataDir)
86
+ dbDesc = fmt.Sprintf("postgres (schema %q)", cfg.DBSchema)
87
+ } else {
88
+ st, err = store.Open(cfg.DBPath)
89
+ dbDesc = cfg.DBPath
90
+ }
91
+ if err != nil {
92
+ log.Printf("warning: could not open user store (%s): %v", dbDesc, err)
93
+ } else {
94
+ defer func() { _ = st.Close() }()
95
+ mgr.SetInstallStore(st)
96
+ log.Printf("user store ready: %s", dbDesc)
97
+ }
98
+
99
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
100
+ defer stop()
101
+
102
+ // Background retention: purge old terminal jobs, scratch dirs and logs.
103
+ mgr.StartJanitor(ctx)
104
+
105
+ // Bind the configured port; if it's busy, walk forward to the next free one.
106
+ ln, boundPort, err := listenWithFallback(cfg.Port, 50)
107
+ if err != nil {
108
+ return err
109
+ }
110
+ if boundPort != cfg.Port {
111
+ log.Printf("port %d is in use — using %d instead", cfg.Port, boundPort)
112
+ }
113
+ cfg.Port = boundPort
114
+
115
+ log.Printf("matrix-runtime %s (commit %s, built %s) starting: mode=%s runtime_id=%s addr=:%d data_dir=%s",
116
+ config.Version, config.Commit, config.Date, cfg.Mode, cfg.EffectiveRuntimeID(), boundPort, cfg.DataDir)
117
+ log.Printf("console + API ready: http://localhost:%d", boundPort)
118
+
119
+ srv := api.NewServer(cfg, mgr, st)
120
+ if err := srv.Serve(ctx, ln); err != nil && err.Error() != "http: Server closed" {
121
+ return err
122
+ }
123
+ log.Printf("matrix-runtime stopped")
124
+ return nil
125
+ }
126
+
127
+ // listenWithFallback binds the first free TCP port in [start, start+maxTries),
128
+ // returning the listener and the port it bound. A port already in use is
129
+ // skipped; other errors (e.g. permission) are returned.
130
+ func listenWithFallback(start, maxTries int) (net.Listener, int, error) {
131
+ var lastErr error
132
+ for p := start; p < start+maxTries && p <= 65535; p++ {
133
+ ln, err := net.Listen("tcp", ":"+strconv.Itoa(p))
134
+ if err == nil {
135
+ return ln, p, nil
136
+ }
137
+ lastErr = err
138
+ if errors.Is(err, syscall.EADDRINUSE) {
139
+ continue
140
+ }
141
+ // Non "in use" error on the very first try is fatal; otherwise keep trying.
142
+ if p == start {
143
+ return nil, 0, err
144
+ }
145
+ }
146
+ return nil, 0, fmt.Errorf("no free port in range %d-%d: %w", start, start+maxTries-1, lastErr)
147
+ }
148
+
149
+ func runJoin(args []string) error {
150
+ fs := flag.NewFlagSet("join", flag.ExitOnError)
151
+ cloudURL := fs.String("cloud-url", "", "MatrixHub Cloud URL")
152
+ token := fs.String("token", "", "runtime join token")
153
+ runtimeID := fs.String("runtime-id", "", "optional runtime id")
154
+ workspace := fs.String("workspace", "", "optional workspace")
155
+ if err := fs.Parse(args); err != nil {
156
+ return err
157
+ }
158
+ path, err := controlplane.WriteJoinConfig(controlplane.JoinConfig{
159
+ CloudURL: *cloudURL,
160
+ JoinToken: *token,
161
+ RuntimeID: *runtimeID,
162
+ Workspace: *workspace,
163
+ })
164
+ if err != nil {
165
+ return err
166
+ }
167
+ fmt.Printf("Wrote join configuration to %s\n", path)
168
+ return nil
169
+ }
cmd/matrix-runtime/main_test.go ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "net"
5
+ "testing"
6
+ )
7
+
8
+ func TestListenWithFallback_SkipsBusyPort(t *testing.T) {
9
+ // Occupy a port, then ask listenWithFallback to start there.
10
+ occ, err := net.Listen("tcp", "127.0.0.1:0")
11
+ if err != nil {
12
+ t.Fatal(err)
13
+ }
14
+ defer func() { _ = occ.Close() }()
15
+ busy := occ.Addr().(*net.TCPAddr).Port
16
+
17
+ ln, got, err := listenWithFallback(busy, 20)
18
+ if err != nil {
19
+ t.Fatalf("listenWithFallback: %v", err)
20
+ }
21
+ defer func() { _ = ln.Close() }()
22
+
23
+ if got == busy {
24
+ t.Fatalf("expected fallback to a different port, got the busy one %d", busy)
25
+ }
26
+ if got <= busy {
27
+ t.Errorf("expected a higher port than %d, got %d", busy, got)
28
+ }
29
+ if ln.Addr().(*net.TCPAddr).Port != got {
30
+ t.Errorf("listener port %d != reported %d", ln.Addr().(*net.TCPAddr).Port, got)
31
+ }
32
+ }
33
+
34
+ func TestListenWithFallback_FreePort(t *testing.T) {
35
+ // Find a free port, release it, then bind it via the helper.
36
+ probe, err := net.Listen("tcp", "127.0.0.1:0")
37
+ if err != nil {
38
+ t.Fatal(err)
39
+ }
40
+ free := probe.Addr().(*net.TCPAddr).Port
41
+ _ = probe.Close()
42
+
43
+ ln, got, err := listenWithFallback(free, 20)
44
+ if err != nil {
45
+ t.Fatalf("listenWithFallback: %v", err)
46
+ }
47
+ defer func() { _ = ln.Close() }()
48
+ if got != free {
49
+ t.Errorf("expected to bind the free port %d, got %d", free, got)
50
+ }
51
+ }
deploy/docker-compose/.env.example ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copy to .env and adjust. All values are optional; defaults are shown.
2
+ MATRIX_RUNTIME_MODE=local-dev
3
+ MATRIX_RUNTIME_MAX_TTL_SECONDS=600
4
+ MATRIX_RUNTIME_MAX_CONCURRENT_JOBS=2
5
+
6
+ # Hybrid cloud (customer-agent mode)
7
+ MATRIX_CLOUD_URL=https://cloud.matrixhub.io
8
+ MATRIX_RUNTIME_JOIN_TOKEN=
9
+
10
+ # Hugging Face token for gated/private models
11
+ HF_TOKEN=
deploy/docker-compose/docker-compose.yml ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ matrix-runtime:
3
+ build:
4
+ context: ../..
5
+ dockerfile: Dockerfile
6
+ image: matrix-runtime:local
7
+ ports:
8
+ - "8080:8080"
9
+ environment:
10
+ MATRIX_RUNTIME_MODE: ${MATRIX_RUNTIME_MODE:-local-dev}
11
+ MATRIX_RUNTIME_DATA_DIR: /var/lib/matrix-runtime
12
+ MATRIX_RUNTIME_MAX_TTL_SECONDS: ${MATRIX_RUNTIME_MAX_TTL_SECONDS:-600}
13
+ MATRIX_RUNTIME_MAX_CONCURRENT_JOBS: ${MATRIX_RUNTIME_MAX_CONCURRENT_JOBS:-2}
14
+ MATRIX_CLOUD_URL: ${MATRIX_CLOUD_URL:-https://cloud.matrixhub.io}
15
+ MATRIX_RUNTIME_JOIN_TOKEN: ${MATRIX_RUNTIME_JOIN_TOKEN:-}
16
+ HF_TOKEN: ${HF_TOKEN:-}
17
+ volumes:
18
+ - matrix-runtime-data:/var/lib/matrix-runtime
19
+ healthcheck:
20
+ test: ["CMD", "curl", "-fsS", "http://localhost:8080/v1/health"]
21
+ interval: 15s
22
+ timeout: 5s
23
+ retries: 5
24
+
25
+ volumes:
26
+ matrix-runtime-data:
deploy/helm/matrix-runtime/Chart.yaml ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ apiVersion: v2
2
+ name: matrix-runtime
3
+ description: Matrix Runtime — the execution plane for Matrix Cloud (MCP sandboxes, agents, tools, model jobs).
4
+ type: application
5
+ version: 0.1.0
6
+ appVersion: "0.1.0"
7
+ keywords:
8
+ - matrix
9
+ - mcp
10
+ - runtime
11
+ - execution-plane
12
+ home: https://github.com/agent-matrix/matrix-runtime
13
+ sources:
14
+ - https://github.com/agent-matrix/matrix-runtime
15
+ maintainers:
16
+ - name: agent-matrix
deploy/helm/matrix-runtime/templates/_helpers.tpl ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {{- define "matrix-runtime.name" -}}
2
+ {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
3
+ {{- end -}}
4
+
5
+ {{- define "matrix-runtime.fullname" -}}
6
+ {{- if .Values.fullnameOverride -}}
7
+ {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
8
+ {{- else -}}
9
+ {{- printf "%s-%s" .Release.Name (include "matrix-runtime.name" .) | trunc 63 | trimSuffix "-" -}}
10
+ {{- end -}}
11
+ {{- end -}}
12
+
13
+ {{- define "matrix-runtime.labels" -}}
14
+ app.kubernetes.io/name: {{ include "matrix-runtime.name" . }}
15
+ app.kubernetes.io/instance: {{ .Release.Name }}
16
+ app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
17
+ app.kubernetes.io/managed-by: {{ .Release.Service }}
18
+ helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }}
19
+ {{- end -}}
20
+
21
+ {{- define "matrix-runtime.selectorLabels" -}}
22
+ app.kubernetes.io/name: {{ include "matrix-runtime.name" . }}
23
+ app.kubernetes.io/instance: {{ .Release.Name }}
24
+ {{- end -}}
25
+
26
+ {{- define "matrix-runtime.serviceAccountName" -}}
27
+ {{- if .Values.serviceAccount.create -}}
28
+ {{- default (include "matrix-runtime.fullname" .) .Values.serviceAccount.name -}}
29
+ {{- else -}}
30
+ {{- default "default" .Values.serviceAccount.name -}}
31
+ {{- end -}}
32
+ {{- end -}}
deploy/helm/matrix-runtime/templates/configmap.yaml ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ apiVersion: v1
2
+ kind: ConfigMap
3
+ metadata:
4
+ name: {{ include "matrix-runtime.fullname" . }}
5
+ labels:
6
+ {{- include "matrix-runtime.labels" . | nindent 4 }}
7
+ data:
8
+ MATRIX_RUNTIME_MODE: {{ .Values.runtime.mode | quote }}
9
+ MATRIX_RUNTIME_PORT: {{ .Values.service.port | quote }}
10
+ MATRIX_RUNTIME_DATA_DIR: {{ .Values.runtime.dataDir | quote }}
11
+ MATRIX_RUNTIME_MAX_TTL_SECONDS: {{ .Values.runtime.maxTTLSeconds | quote }}
12
+ MATRIX_RUNTIME_MAX_CONCURRENT_JOBS: {{ .Values.runtime.maxConcurrentJobs | quote }}
13
+ MATRIX_CLOUD_URL: {{ .Values.cloud.url | quote }}
14
+ {{- with .Values.runtime.id }}
15
+ MATRIX_RUNTIME_ID: {{ . | quote }}
16
+ {{- end }}
17
+ {{- with .Values.runtime.workspace }}
18
+ MATRIX_RUNTIME_WORKSPACE: {{ . | quote }}
19
+ {{- end }}
deploy/helm/matrix-runtime/templates/deployment.yaml ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ apiVersion: apps/v1
2
+ kind: Deployment
3
+ metadata:
4
+ name: {{ include "matrix-runtime.fullname" . }}
5
+ labels:
6
+ {{- include "matrix-runtime.labels" . | nindent 4 }}
7
+ spec:
8
+ replicas: {{ .Values.replicaCount }}
9
+ selector:
10
+ matchLabels:
11
+ {{- include "matrix-runtime.selectorLabels" . | nindent 6 }}
12
+ template:
13
+ metadata:
14
+ labels:
15
+ {{- include "matrix-runtime.selectorLabels" . | nindent 8 }}
16
+ spec:
17
+ serviceAccountName: {{ include "matrix-runtime.serviceAccountName" . }}
18
+ securityContext:
19
+ {{- toYaml .Values.podSecurityContext | nindent 8 }}
20
+ containers:
21
+ - name: matrix-runtime
22
+ image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
23
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
24
+ args: ["--mode", "{{ .Values.runtime.mode }}"]
25
+ securityContext:
26
+ {{- toYaml .Values.securityContext | nindent 12 }}
27
+ ports:
28
+ - name: http
29
+ containerPort: {{ .Values.service.port }}
30
+ envFrom:
31
+ - configMapRef:
32
+ name: {{ include "matrix-runtime.fullname" . }}
33
+ {{- if or .Values.runtime.joinToken .Values.runtime.apiToken .Values.runtime.hfToken }}
34
+ - secretRef:
35
+ name: {{ include "matrix-runtime.fullname" . }}
36
+ {{- end }}
37
+ livenessProbe:
38
+ httpGet:
39
+ path: /v1/health
40
+ port: http
41
+ initialDelaySeconds: 10
42
+ periodSeconds: 20
43
+ readinessProbe:
44
+ httpGet:
45
+ path: /v1/ready
46
+ port: http
47
+ initialDelaySeconds: 5
48
+ periodSeconds: 10
49
+ resources:
50
+ {{- toYaml .Values.resources | nindent 12 }}
51
+ volumeMounts:
52
+ - name: data
53
+ mountPath: {{ .Values.runtime.dataDir }}
54
+ volumes:
55
+ - name: data
56
+ {{- if .Values.persistence.enabled }}
57
+ persistentVolumeClaim:
58
+ claimName: {{ include "matrix-runtime.fullname" . }}-data
59
+ {{- else }}
60
+ emptyDir: {}
61
+ {{- end }}
62
+ {{- with .Values.nodeSelector }}
63
+ nodeSelector:
64
+ {{- toYaml . | nindent 8 }}
65
+ {{- end }}
66
+ {{- with .Values.affinity }}
67
+ affinity:
68
+ {{- toYaml . | nindent 8 }}
69
+ {{- end }}
70
+ {{- with .Values.tolerations }}
71
+ tolerations:
72
+ {{- toYaml . | nindent 8 }}
73
+ {{- end }}
deploy/helm/matrix-runtime/templates/pvc.yaml ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {{- if .Values.persistence.enabled }}
2
+ apiVersion: v1
3
+ kind: PersistentVolumeClaim
4
+ metadata:
5
+ name: {{ include "matrix-runtime.fullname" . }}-data
6
+ labels:
7
+ {{- include "matrix-runtime.labels" . | nindent 4 }}
8
+ spec:
9
+ accessModes:
10
+ {{- toYaml .Values.persistence.accessModes | nindent 4 }}
11
+ resources:
12
+ requests:
13
+ storage: {{ .Values.persistence.size | quote }}
14
+ {{- with .Values.persistence.storageClass }}
15
+ storageClassName: {{ . | quote }}
16
+ {{- end }}
17
+ {{- end }}
deploy/helm/matrix-runtime/templates/secret.yaml ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {{- if or .Values.runtime.joinToken .Values.runtime.apiToken .Values.runtime.hfToken }}
2
+ apiVersion: v1
3
+ kind: Secret
4
+ metadata:
5
+ name: {{ include "matrix-runtime.fullname" . }}
6
+ labels:
7
+ {{- include "matrix-runtime.labels" . | nindent 4 }}
8
+ type: Opaque
9
+ stringData:
10
+ {{- with .Values.runtime.joinToken }}
11
+ MATRIX_RUNTIME_JOIN_TOKEN: {{ . | quote }}
12
+ {{- end }}
13
+ {{- with .Values.runtime.apiToken }}
14
+ MATRIX_RUNTIME_API_TOKEN: {{ . | quote }}
15
+ {{- end }}
16
+ {{- with .Values.runtime.hfToken }}
17
+ HF_TOKEN: {{ . | quote }}
18
+ {{- end }}
19
+ {{- end }}
deploy/helm/matrix-runtime/templates/service.yaml ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ apiVersion: v1
2
+ kind: Service
3
+ metadata:
4
+ name: {{ include "matrix-runtime.fullname" . }}
5
+ labels:
6
+ {{- include "matrix-runtime.labels" . | nindent 4 }}
7
+ spec:
8
+ type: {{ .Values.service.type }}
9
+ selector:
10
+ {{- include "matrix-runtime.selectorLabels" . | nindent 4 }}
11
+ ports:
12
+ - name: http
13
+ port: {{ .Values.service.port }}
14
+ targetPort: http
15
+ protocol: TCP