Spaces:
Runtime error
Runtime error
github-actions[bot] commited on
Commit ·
857a91b
0
Parent(s):
Deploy from 4dfd54f2
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +73 -0
- .gitattributes +34 -0
- .gitignore +31 -0
- Dockerfile +33 -0
- LICENSE +201 -0
- Makefile +282 -0
- README.md +36 -0
- api/audit.go +67 -0
- api/auth_test.go +106 -0
- api/cloud_test.go +119 -0
- api/handlers_auth.go +204 -0
- api/handlers_cloud.go +207 -0
- api/handlers_health.go +110 -0
- api/handlers_jobs.go +103 -0
- api/handlers_matrixshell.go +85 -0
- api/handlers_models.go +271 -0
- api/handlers_platform.go +84 -0
- api/handlers_sandbox.go +94 -0
- api/handlers_system.go +68 -0
- api/middleware_auth.go +71 -0
- api/middleware_ratelimit.go +86 -0
- api/models_test.go +79 -0
- api/openapi.go +83 -0
- api/openapi.yaml +651 -0
- api/production_test.go +160 -0
- api/server.go +180 -0
- api/server_test.go +88 -0
- app/main.py +29 -0
- assets/banner.svg +70 -0
- assets/logo.svg +25 -0
- clients/python/README.md +63 -0
- clients/python/pyproject.toml +65 -0
- clients/python/src/matrixcloud/__init__.py +18 -0
- clients/python/src/matrixcloud/cli.py +211 -0
- clients/python/src/matrixcloud/client.py +180 -0
- clients/python/src/matrixcloud/config.py +44 -0
- clients/python/src/matrixcloud/errors.py +18 -0
- clients/python/tests/test_client.py +92 -0
- clients/python/uv.lock +0 -0
- cmd/matrix-runtime/main.go +169 -0
- cmd/matrix-runtime/main_test.go +51 -0
- deploy/docker-compose/.env.example +11 -0
- deploy/docker-compose/docker-compose.yml +26 -0
- deploy/helm/matrix-runtime/Chart.yaml +16 -0
- deploy/helm/matrix-runtime/templates/_helpers.tpl +32 -0
- deploy/helm/matrix-runtime/templates/configmap.yaml +19 -0
- deploy/helm/matrix-runtime/templates/deployment.yaml +73 -0
- deploy/helm/matrix-runtime/templates/pvc.yaml +17 -0
- deploy/helm/matrix-runtime/templates/secret.yaml +19 -0
- 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 & 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
|