chore: import upstream snapshot with attribution
Build / Frontend Build (push) Has been cancelled
Build / Backend Build (push) Has been cancelled
Build / Docker Build (push) Has been cancelled
Build / Kubernetes Deploy Smoke Test (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:35:24 +08:00
commit 6d978fe483
567 changed files with 180611 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
.git
.gitignore
.DS_Store
node_modules
frontend/node_modules
frontend/dist
backend/bin
backend/.cache
dev_docs
docs
scripts
*.log
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Compute a stable fingerprint for the runtime contents of a local Docker image."""
from __future__ import annotations
import hashlib
import json
import subprocess
import sys
import tarfile
import tempfile
from pathlib import Path
def run(*args: str) -> str:
completed = subprocess.run(
list(args),
check=True,
capture_output=True,
text=True,
)
return completed.stdout
def hash_stream(fileobj) -> str:
digest = hashlib.sha256()
while True:
chunk = fileobj.read(1024 * 1024)
if not chunk:
break
digest.update(chunk)
return digest.hexdigest()
def normalized_config(image_ref: str) -> bytes:
inspect_payload = json.loads(run("docker", "image", "inspect", image_ref))[0]
config_payload = {
"architecture": inspect_payload.get("Architecture"),
"os": inspect_payload.get("Os"),
"variant": inspect_payload.get("Variant"),
"config": inspect_payload.get("Config", {}),
}
return json.dumps(
config_payload,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
def normalized_tar_entries(tar_path: Path) -> list[str]:
records: list[str] = []
with tarfile.open(tar_path, mode="r") as archive:
for member in archive.getmembers():
path = member.name.rstrip("/") or "."
record: dict[str, object] = {
"path": path,
"mode": member.mode,
}
if member.isdir():
record["type"] = "dir"
elif member.isfile():
extracted = archive.extractfile(member)
if extracted is None:
raise RuntimeError(f"Unable to read file contents for {path}")
with extracted:
record["type"] = "file"
record["sha256"] = hash_stream(extracted)
elif member.issym():
record["type"] = "symlink"
record["target"] = member.linkname
elif member.islnk():
record["type"] = "hardlink"
record["target"] = member.linkname
elif member.ischr():
record["type"] = "char"
record["device"] = [member.devmajor, member.devminor]
elif member.isblk():
record["type"] = "block"
record["device"] = [member.devmajor, member.devminor]
elif member.isfifo():
record["type"] = "fifo"
else:
record["type"] = f"other:{member.type!r}"
if member.linkname:
record["target"] = member.linkname
records.append(
json.dumps(record, sort_keys=True, separators=(",", ":"))
)
records.sort()
return records
def main() -> int:
if len(sys.argv) != 2:
print("usage: compute_image_fingerprint.py <local-image-ref>", file=sys.stderr)
return 2
image_ref = sys.argv[1]
container_id = run("docker", "create", image_ref).strip()
try:
with tempfile.TemporaryDirectory(prefix="clawmanager-image-export-") as temp_dir:
tar_path = Path(temp_dir) / "filesystem.tar"
subprocess.run(
["docker", "export", "-o", str(tar_path), container_id],
check=True,
)
digest = hashlib.sha256()
digest.update(normalized_config(image_ref))
digest.update(b"\n")
for record in normalized_tar_entries(tar_path):
digest.update(record.encode("utf-8"))
digest.update(b"\n")
print(digest.hexdigest())
finally:
subprocess.run(
["docker", "rm", "-f", container_id],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+213
View File
@@ -0,0 +1,213 @@
name: Build
on:
push:
branches:
- main
- 'codex/**'
- 'feature/**'
- 'fix/**'
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
frontend:
name: Frontend Build
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build frontend
run: npm run build
backend:
name: Backend Build
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: backend/go.mod
cache-dependency-path: backend/go.sum
- name: Download dependencies
run: go mod download
- name: Build backend
run: go build -o bin/server ./cmd/server
- name: Run backend tests
run: go test ./...
docker:
name: Docker Build
runs-on: ubuntu-latest
needs:
- frontend
- backend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build multi-platform application image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: false
platforms: linux/amd64,linux/arm64
tags: clawmanager:ci
k8s-smoke:
name: Kubernetes Deploy Smoke Test
runs-on: ubuntu-latest
needs:
- frontend
- backend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build local application image
run: docker build -t clawmanager:ci .
- name: Create kind cluster
uses: helm/kind-action@v1.12.0
with:
cluster_name: clawmanager-ci
ignore_failed_clean: true
- name: Load image into kind
run: kind load docker-image clawmanager:ci --name clawmanager-ci
- name: Deploy manifest to cluster
shell: bash
run: |
kubectl label node clawmanager-ci-control-plane clawmanager.io/storage-node=true --overwrite
cp deployments/k8s/single-node/clawmanager.yaml /tmp/clawmanager-ci.yaml
python - <<'PY'
from pathlib import Path
import re
path = Path("/tmp/clawmanager-ci.yaml")
text = path.read_text(encoding="utf-8")
text = text.replace(
"image: ghcr.io/yuan-lab-llm/clawmanager:latest",
"image: clawmanager:ci",
)
text = re.sub(
r"(kind: Deployment\s+metadata:\s+name: mysql\s+namespace: clawmanager-system\s+spec:\s+replicas: 1\s+selector:\s+matchLabels:\s+app: mysql\s+template:\s+metadata:\s+labels:\s+app: mysql\s+spec:\s+containers:\s+- name: mysql.*?volumeMounts:\s+- name: mysql-data\s+mountPath: /var/lib/mysql\s+- name: mysql-init\s+mountPath: /docker-entrypoint-initdb.d\s+readinessProbe:.*?periodSeconds: 10\s+)volumes:\s+- name: mysql-data\s+persistentVolumeClaim:\s+claimName: mysql-data\s+- name: mysql-init\s+configMap:\s+name: clawmanager-mysql-init",
r"\1volumes:\n - name: mysql-data\n emptyDir: {}\n - name: mysql-init\n configMap:\n name: clawmanager-mysql-init",
text,
flags=re.S,
)
text = re.sub(
r"(kind: Deployment\s+metadata:\s+name: clawmanager-app\s+namespace: clawmanager-system\s+spec:\s+)replicas: 1",
r"\1replicas: 0",
text,
count=1,
flags=re.S,
)
text = re.sub(
r"(kind: Service\s+metadata:\s+name: clawmanager-frontend\s+namespace: clawmanager-system\s+spec:\s+)type: NodePort",
r"\1type: ClusterIP",
text,
count=1,
flags=re.S,
)
text = re.sub(
r"\n\s+nodePort:\s+\d+",
"",
text,
count=1,
)
text = re.sub(
r"(\n\s+- name: workspaces\s+)nfs:\s+server: workspace-store\.clawmanager-system\.svc\.cluster\.local\s+path: /exports/workspaces",
r"\1emptyDir: {}",
text,
)
path.write_text(text, encoding="utf-8")
PY
kubectl apply -f /tmp/clawmanager-ci.yaml
- name: Wait for MySQL deployment
run: kubectl -n clawmanager-system rollout status deployment/mysql --timeout=300s
- name: Start ClawManager after MySQL is ready
run: kubectl -n clawmanager-system scale deployment/clawmanager-app --replicas=1
- name: Wait for ClawManager deployment
run: kubectl -n clawmanager-system rollout status deployment/clawmanager-app --timeout=300s
- name: Show cluster status on failure
if: failure()
run: |
kubectl get pods -A || true
kubectl get pvc -A || true
kubectl get events -A --sort-by=.lastTimestamp || true
kubectl -n clawmanager-system describe deployment mysql || true
kubectl -n clawmanager-system describe deployment clawmanager-app || true
kubectl -n clawmanager-system describe pod -l app=mysql || true
kubectl -n clawmanager-system describe pod -l app=clawmanager-app || true
kubectl -n clawmanager-system logs deployment/mysql --tail=200 || true
kubectl -n clawmanager-system logs deployment/clawmanager-app --tail=200 || true
- name: Start port-forward
run: |
kubectl -n clawmanager-system port-forward svc/clawmanager-frontend 8443:443 > /tmp/clawmanager-port-forward.log 2>&1 &
echo $! > /tmp/clawmanager-port-forward.pid
sleep 10
- name: Verify health endpoint
run: curl --fail --insecure https://127.0.0.1:8443/healthz
- name: Verify web page is reachable
run: curl --fail --insecure https://127.0.0.1:8443/ | grep -i "<!doctype html>"
- name: Stop port-forward
if: always()
run: |
if [ -f /tmp/clawmanager-port-forward.pid ]; then
kill "$(cat /tmp/clawmanager-port-forward.pid)" || true
fi
+55
View File
@@ -0,0 +1,55 @@
name: Manual LGTM
on:
issue_comment:
types:
- created
permissions:
contents: read
issues: read
pull-requests: read
statuses: write
jobs:
lgtm:
name: Apply Manual LGTM
if: github.event.issue.pull_request != null && startsWith(github.event.comment.body, '/lgtm')
runs-on: ubuntu-latest
steps:
- name: Validate commenter permission and set status
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const issueNumber = context.issue.number;
const commenter = context.payload.comment.user.login;
const permission = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: commenter,
});
const allowed = new Set(['admin', 'maintain', 'write']);
if (!allowed.has(permission.data.permission)) {
core.notice(`Skipping /lgtm from ${commenter}: permission=${permission.data.permission}`);
return;
}
const pull = await github.rest.pulls.get({
owner,
repo,
pull_number: issueNumber,
});
await github.rest.repos.createCommitStatus({
owner,
repo,
sha: pull.data.head.sha,
state: 'success',
context: 'Manual LGTM',
description: `Approved by /lgtm from ${commenter}`,
target_url: context.payload.comment.html_url,
});
+243
View File
@@ -0,0 +1,243 @@
name: Release
on:
schedule:
# 09:00 Asia/Shanghai = 01:00 UTC
- cron: '0 1 * * *'
workflow_dispatch:
inputs:
force_release:
description: Force create a release even if the image fingerprint is unchanged
required: false
default: false
type: boolean
push:
tags:
- 'v*'
permissions:
contents: write
packages: write
jobs:
prepare-scheduled-release:
name: Prepare Scheduled Release
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
outputs:
should_release: ${{ steps.decision.outputs.should_release }}
tag_name: ${{ steps.decision.outputs.tag_name }}
image_fingerprint: ${{ steps.compute-fingerprint.outputs.image_fingerprint }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build amd64 candidate image
shell: bash
run: |
docker buildx build --pull --platform linux/amd64 --load -t clawmanager:release-candidate-amd64 .
- name: Build arm64 candidate image
shell: bash
run: |
docker buildx build --pull --platform linux/arm64 --load -t clawmanager:release-candidate-arm64 .
- name: Compute release fingerprint
id: compute-fingerprint
shell: bash
run: |
AMD64_FINGERPRINT="$(python3 .github/scripts/compute_image_fingerprint.py clawmanager:release-candidate-amd64)"
ARM64_FINGERPRINT="$(python3 .github/scripts/compute_image_fingerprint.py clawmanager:release-candidate-arm64)"
IMAGE_FINGERPRINT="$(printf '%s\n%s\n' "${AMD64_FINGERPRINT}" "${ARM64_FINGERPRINT}" | sha256sum | cut -d' ' -f1)"
echo "IMAGE_FINGERPRINT=${IMAGE_FINGERPRINT}" >> "$GITHUB_ENV"
echo "image_fingerprint=${IMAGE_FINGERPRINT}" >> "$GITHUB_OUTPUT"
- name: Read latest release fingerprint
id: latest-release
uses: actions/github-script@v7
with:
script: |
try {
const release = await github.rest.repos.getLatestRelease({
owner: context.repo.owner,
repo: context.repo.repo,
});
const body = release.data.body || "";
const match = body.match(/Build-Fingerprint:\s*`?([^\s`]+)`?/);
core.setOutput("tag", release.data.tag_name || "");
core.setOutput("fingerprint", match ? match[1] : "");
} catch (error) {
if (error.status === 404) {
core.setOutput("tag", "");
core.setOutput("fingerprint", "");
return;
}
throw error;
}
- name: Decide whether to create a new release tag
id: decision
shell: bash
env:
FORCE_RELEASE: ${{ github.event.inputs.force_release || 'false' }}
LAST_FINGERPRINT: ${{ steps.latest-release.outputs.fingerprint }}
run: |
CURRENT_FINGERPRINT="${IMAGE_FINGERPRINT}"
TAG_NAME="v$(date -u +'%Y.%-m.%-d')"
LAST_FINGERPRINT="${LAST_FINGERPRINT//\`/}"
CURRENT_FINGERPRINT="${CURRENT_FINGERPRINT//\`/}"
if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
echo "should_release=false" >> "$GITHUB_OUTPUT"
echo "tag_name=" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$FORCE_RELEASE" = "true" ]; then
echo "should_release=true" >> "$GITHUB_OUTPUT"
echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ -n "$LAST_FINGERPRINT" ] && [ "$LAST_FINGERPRINT" = "$CURRENT_FINGERPRINT" ]; then
echo "should_release=false" >> "$GITHUB_OUTPUT"
echo "tag_name=" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "should_release=true" >> "$GITHUB_OUTPUT"
echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT"
- name: Create and push release tag
if: steps.decision.outputs.should_release == 'true'
shell: bash
run: |
TAG_NAME="${{ steps.decision.outputs.tag_name }}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG_NAME" -m "Release $TAG_NAME"
git push origin "$TAG_NAME"
release:
name: Publish Release
needs:
- prepare-scheduled-release
if: |
always() && (
github.event_name == 'push' ||
needs.prepare-scheduled-release.outputs.should_release == 'true'
)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set release metadata
shell: bash
run: |
REPOSITORY_OWNER_LOWER="$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')"
if [ "${{ github.event_name }}" = "push" ]; then
echo "TAG_NAME=${GITHUB_REF_NAME}" >> "$GITHUB_ENV"
else
echo "TAG_NAME=${{ needs.prepare-scheduled-release.outputs.tag_name }}" >> "$GITHUB_ENV"
fi
echo "IMAGE_URI=ghcr.io/${REPOSITORY_OWNER_LOWER}/clawmanager" >> "$GITHUB_ENV"
- name: Checkout release tag
if: github.event_name != 'push'
run: git checkout "${TAG_NAME}"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build amd64 release candidate
shell: bash
run: |
docker buildx build --pull --platform linux/amd64 --load -t clawmanager:release-candidate-amd64 .
- name: Build arm64 release candidate
shell: bash
run: |
docker buildx build --pull --platform linux/arm64 --load -t clawmanager:release-candidate-arm64 .
- name: Compute release fingerprint
shell: bash
run: |
if [ -n "${{ needs.prepare-scheduled-release.outputs.image_fingerprint }}" ]; then
echo "IMAGE_FINGERPRINT=${{ needs.prepare-scheduled-release.outputs.image_fingerprint }}" >> "$GITHUB_ENV"
exit 0
fi
AMD64_FINGERPRINT="$(python3 .github/scripts/compute_image_fingerprint.py clawmanager:release-candidate-amd64)"
ARM64_FINGERPRINT="$(python3 .github/scripts/compute_image_fingerprint.py clawmanager:release-candidate-arm64)"
IMAGE_FINGERPRINT="$(printf '%s\n%s\n' "${AMD64_FINGERPRINT}" "${ARM64_FINGERPRINT}" | sha256sum | cut -d' ' -f1)"
echo "IMAGE_FINGERPRINT=${IMAGE_FINGERPRINT}" >> "$GITHUB_ENV"
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push multi-platform release image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ env.IMAGE_URI }}:${{ env.TAG_NAME }}
${{ env.IMAGE_URI }}:latest
- name: Read pushed image digest
shell: bash
run: |
IMAGE_DIGEST="$(docker buildx imagetools inspect "${IMAGE_URI}:${TAG_NAME}" | sed -n 's/^Digest:[[:space:]]*//p' | head -n1)"
if [ -z "${IMAGE_DIGEST}" ]; then
echo "Failed to resolve image digest for ${IMAGE_URI}:${TAG_NAME}" >&2
exit 1
fi
echo "IMAGE_DIGEST=${IMAGE_DIGEST}" >> "$GITHUB_ENV"
- name: Build release notes
shell: bash
run: |
cat <<EOF > /tmp/release-highlights.md
## Highlights
- Added automated release flow for scheduled checks and \`v*\` tag publishing.
- Added in-app delete confirmation dialogs instead of browser-native prompts.
- Fixed WebSocket authentication so real-time status updates can connect in browser environments.
- Fixed Linux container startup script handling and enforced LF line endings for shell scripts.
## Release Assets
Image: \`${IMAGE_URI}:${TAG_NAME}\`
Latest: \`${IMAGE_URI}:latest\`
Platforms: \`linux/amd64\`, \`linux/arm64\`
Image-Digest: \`${IMAGE_DIGEST}\`
Build-Fingerprint: \`${IMAGE_FINGERPRINT}\`
EOF
- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.TAG_NAME }}
generate_release_notes: true
body_path: /tmp/release-highlights.md
+85
View File
@@ -0,0 +1,85 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
/backend/bin/
/backend/tmp/
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
# Go vendor directory
/backend/vendor/
# Node modules
/frontend/node_modules/
/frontend/dist/
/frontend/dist-ssr/
/outputs/
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment files
.env
.env.local
.env.*.local
# Database
*.db
*.sqlite
# OS files
Thumbs.db
# Temporary files
*.tmp
*.temp
/test_*
/deploy/
/test.tar
/fix-install-plan.md
/docs/deployment-storage-remediation-plan.zh-CN.md
# Team runtime config snapshots
/team-*.json
/agency-agents-main/
.codex-logs/
.codex-temp/
.cache/
.VSCodeCounter/
.superpowers/
dev_docs/
devplan.md
AGENTS.md
TASK_BREAKDOWN.md
dev_progress.md
CLAUDE.md
TEAM_IMAGE_DEPLOY_STEPS.md
TEAM_PROFILES_LOCAL_TESTING.md
/team-optimization-docs/
/clawmanager-apply.sh
/clawmanager-team-profiles-test.yaml
/clawmanager-tenant.yaml
+51
View File
@@ -0,0 +1,51 @@
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM --platform=$BUILDPLATFORM golang:1.26.1-alpine AS backend-builder
WORKDIR /app/backend
ARG TARGETOS
ARG TARGETARCH
ARG GOPROXY
ARG GOSUMDB
ARG GOFLAGS
ENV GOFLAGS=${GOFLAGS}
RUN apk add --no-cache git
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ ./
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -trimpath -buildvcs=false -ldflags="-s -w -buildid=" -o /out/clawreef-server ./cmd/server
FROM nginx:1.27-alpine
# nginx-module-njs provides ngx_http_js_module.so, loaded by nginx.conf to run
# the desktop access-token verification (deployments/nginx/njs/desktop_auth.js).
RUN apk add --no-cache dumb-init openssl nginx-module-njs
WORKDIR /app
COPY --from=backend-builder /out/clawreef-server /usr/local/bin/clawreef-server
COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html
COPY deployments/nginx/nginx.conf /etc/nginx/nginx.conf
COPY deployments/nginx/njs/desktop_auth.js /etc/nginx/njs/desktop_auth.js
COPY deployments/container/start.sh /app/start.sh
RUN chmod +x /app/start.sh \
&& mkdir -p /etc/nginx/tls /var/log/clawreef
EXPOSE 8443
ENTRYPOINT ["dumb-init", "--"]
CMD ["/app/start.sh"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ClawManager contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+293
View File
@@ -0,0 +1,293 @@
# ClawManager
<p align="center">
<img src="frontend/public/openclaw_github_logo.png" alt="ClawManager" width="100%" />
</p>
<p align="center">
ClawManager ist eine Kubernetes-native Control Plane fuer die Verwaltung von AI-Agent-Instanzen mit kontrolliertem AI-Zugriff, Runtime-Orchestrierung und wiederverwendbaren Ressourcen ueber mehrere Agent-Runtimes hinweg.
</p>
<p align="center">
<strong>Sprachen:</strong>
<a href="./README.md">English</a> |
<a href="./README.zh-CN.md">简体中文</a> |
<a href="./README.ja.md">日本語</a> |
<a href="./README.ko.md">한국어</a> |
Deutsch
</p>
<p align="center">
<img src="https://img.shields.io/badge/ClawManager-Control%20Plane-e25544?style=for-the-badge" alt="ClawManager Control Plane" />
<img src="https://img.shields.io/badge/Go-1.21%2B-00ADD8?style=for-the-badge&logo=go&logoColor=white" alt="Go 1.21+" />
<img src="https://img.shields.io/badge/React-19-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" alt="React 19" />
<img src="https://img.shields.io/badge/Kubernetes-Native-326CE5?style=for-the-badge&logo=kubernetes&logoColor=white" alt="Kubernetes Native" />
<img src="https://img.shields.io/badge/License-MIT-2ea44f?style=for-the-badge" alt="MIT License" />
<a href="https://discord.gg/9RwgbGJD5R">
<img src="https://img.shields.io/badge/Discord-Join%20Us-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="ClawManager Discord-Community beitreten" />
</a>
</p>
<p align="center">
<a href="#product-tour">Produktueberblick</a> |
<a href="#team-workspaces">Team Workspaces</a> |
<a href="#ai-gateway">AI Gateway</a> |
<a href="#agent-control-plane">Agent Control Plane</a> |
<a href="#runtime-integrations">Runtime-Integrationen</a> |
<a href="#resource-management">Ressourcenverwaltung</a> |
<a href="#get-started">Erste Schritte</a>
</p>
<p align="center">
<a href="https://github.com/Yuan-lab-LLM/ClawManager/stargazers">
<img src="https://img.shields.io/github/stars/Yuan-lab-LLM/ClawManager?style=for-the-badge&logo=github&label=Star%20ClawManager" alt="Star ClawManager on GitHub" />
</a>
</p>
<h2 align="center">ClawManager in 60 Sekunden</h2>
<p align="center">
<img src="https://raw.githubusercontent.com/Yuan-lab-LLM/ClawManager-Assets/main/gif/clawmanager-launch-60s-hd.gif" alt="ClawManager Produktdemo" width="100%" />
</p>
<p align="center">
Ein schneller Blick auf Agent-Provisionierung, Skill-Verwaltung und -Scanning sowie AI-Gateway-Governance.
</p>
## Neuigkeiten
Wichtige aktuelle Produkt- und Dokumentations-Updates.
- [2026-07-07] Security Protection Platform (secplane) Frontend-Konsole hinzugefuegt — umfassende Sicherheitskonsole mit Runtime-Abwehr (Eingabe-/Zustands-/Entscheidungs-/Ausgabeoberflaeche, Asset-Schutz, menschliche Freigabe), Host-Haertung und Container-Isolierung, Outbound-Vertrauens-Governance, Richtlinien-Governance, Kill-Switch/Circuit-Breaker, Full-Chain-Audit, SecureClaw-Daten- und Komponentenvertrauens-Audit, Kollaborations-Governance und Eingabeerkennung. 4 Verteidigungsschichten in einer einheitlichen Admin-UI mit vollstaendiger i18n fuer 5 Sprachen.
- [2026-06-14] Lite-/Pro-Runtime-Modi und Rollout-Support hinzugefuegt: Lite-Instanzen laufen ueber gemeinsame Gateway-Runtime-Pools, waehrend Pro-Instanzen dedizierte Desktop-Deployments fuer staerkere Isolation behalten.
- [2026-05-18] Team-Workspace-MVP mit Einfuehrung und Vorschau hinzugefuegt, inklusive One-Click-Team-Erstellung, OpenClaw-Member-Orchestrierung, Redis-Team-Bus-Injection, Shared Storage, Member-Status, Task-Dispatch sowie Event- und Ergebnisansichten.
- [2026-04-29] Hermes-Runtime-Integration hinzugefuegt, inklusive Webtop-basierter Instanzbereitstellung, Agent-Control-Plane-Registrierung, AI-Gateway-Injection, channel- und skill-Bootstrap sowie `.hermes` Import/Export. Siehe [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md).
- [2026-04-08] Skill-Verwaltung und Skill-Scanning wurden der Plattform hinzugefuegt. Details siehe [Merged PR #52](https://github.com/Yuan-lab-LLM/ClawManager/pull/52).
- [2026-03-26] Die AI-Gateway-Dokumentation wurde erweitert und deckt nun Modell-Governance, Audit und Trace, Kostenrechnung sowie Risikokontrolle genauer ab. Siehe [AI Gateway Guide](./docs/aigateway.md).
- [2026-03-20] ClawManager hat sich zu einer breiteren Control Plane fuer AI-Agent-Workspaces entwickelt, mit staerkerer Runtime-Steuerung, wiederverwendbaren Ressourcen und Security-Scanning-Workflows.
> Wenn ClawManager fuer dein Team nuetzlich ist, gib dem Projekt gerne einen Star, damit mehr Nutzer und Entwickler es entdecken.
<p align="center">
<a href="https://github.com/Yuan-lab-LLM/ClawManager/stargazers">
<img src="https://raw.githubusercontent.com/Yuan-lab-LLM/ClawManager-Assets/main/gif/clawmanager-star.gif" alt="Star ClawManager on GitHub" width="100%" />
</a>
</p>
## Community
Tritt der ClawManager Open-Source-Community auf WeChat oder Discord bei, um Produkt-Updates zu verfolgen, Nutzungserfahrungen auszutauschen und mit Mitwirkenden ins Gespraech zu kommen.
<table align="center">
<tr>
<td align="center" width="320" valign="top">
<img src="./docs/main/clawmanager_group_chat.jpg" alt="QR-Code zur ClawManager WeChat-Gruppe" height="300" />
<br /><br />
<strong>WeChat</strong>
<br />
QR-Code scannen, um der WeChat-Gruppe beizutreten
</td>
<td align="center" width="320" valign="top">
<img src="./docs/main/clawmanager_discord.jpg" alt="QR-Code zur ClawManager Discord-Einladung" height="300" />
<br /><br />
<strong>Discord</strong>
<br />
<a href="https://discord.gg/9RwgbGJD5R">QR-Code scannen, um unserem Discord-Server beizutreten</a>
</td>
</tr>
</table>
<a id="product-tour"></a>
## Produktueberblick
ClawManager bringt den Betrieb von AI-Agent-Instanzen auf Kubernetes und legt darauf drei hoeherwertige Control Planes. Teams koennen damit AI-Zugriff steuern, Runtime-Verhalten ueber Agents orchestrieren und Workspace-Faehigkeiten ueber scanbare und wiederverwendbare channel- und skill-Ressourcen bereitstellen.
Es eignet sich besonders fuer:
- Plattformteams, die AI-Agent-Instanzen fuer mehrere Nutzer betreiben
- Betriebsteams, die Runtime-Sichtbarkeit, Command-Dispatch und Desired-State-Kontrolle benoetigen
- Entwicklungsteams, die Agent-Workspaces ueber wiederverwendbare Ressourcen statt ueber manuelle Konfiguration bereitstellen wollen
<a id="team-workspaces"></a>
## Team Workspaces
Team Workspaces bieten einen vereinfachten OpenClaw-Lite-Kollaborationsablauf: Rollenvorlage auswaehlen, Team erstellen und das Ziel im Team-Chat beschreiben. Der Leader plant, koordiniert die Mitglieder, sammelt Ergebnisse und liefert die finale Zusammenfassung.
- feste Leader-vermittelte Zusammenarbeit ohne Runtime- oder Ressourcenprofil je Mitglied
- integrierte Vorlagen fuer Auslieferung, Produkterkundung und Softwareentwicklung
- Team-Chat fuer Plan, Zuweisung, Fortschritt, Review, Lieferung und Zusammenfassung
- Execution Kanban fuer Gesamtaufgabe und aktuelle Mitgliederlieferungen
Siehe [Team Workspace Quick Guide](./docs/team-workspaces-guide_de.md) fuer Erstellung, Kollaborationsphasen und Ergebnisansicht.
<a id="runtime-integrations"></a>
## Runtime-Integrationen
ClawManager unterstuetzt derzeit die folgenden verwalteten Runtimes:
- <img src="frontend/public/openclaw.png" alt="OpenClaw icon" width="18" /> `OpenClaw`: die standardmaessige OpenClaw-artige Workspace-Runtime fuer von ClawManager verwaltete Desktop-Instanzen
- <img src="frontend/public/hermes.png" alt="Hermes icon" width="18" /> `Hermes`: eine Webtop-basierte Runtime-Integration mit persistentem `.hermes`-Workspace und eingebettetem Hermes agent
Runtime-Vorschau:
**<img src="frontend/public/openclaw.png" alt="OpenClaw icon" width="18" /> OpenClaw**
![openclaw](./docs/images/openclaw.png)
**<img src="frontend/public/hermes.png" alt="Hermes icon" width="18" /> Hermes**
![hermes](./docs/images/hermes.png)
Runtime-Autoren koennen dem [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md), dem [Generic Runtime Agent Integration Guide](./docs/runtime-agent-integration-guide.md) und der [Skill Content MD5 Spec](./docs/skill-content-md5-spec.md) folgen, um kompatible Agents zu bauen.
<a id="get-started"></a>
## Erste Schritte
ClawManager bietet jetzt klarere Einstiegspfade sowohl fuer Standard-Kubernetes als auch fuer leichtere Cluster-Setups. Zum Evaluieren der Plattform ist es am sinnvollsten, zuerst den passenden Deployment-Pfad fuer die eigene Umgebung zu waehlen und danach dem First-Use-Flow zu folgen.
- Standard-Kubernetes-Deployment: [deployments/k8s/clawmanager.yaml](./deployments/k8s/clawmanager.yaml)
- K3s / leichtgewichtiges Deployment: [deployments/k3s/clawmanager.yaml](./deployments/k3s/clawmanager.yaml)
- First-Login- und Schnellstart-Ablauf: [Benutzerhandbuch](./docs/use_guide_de.md)
- Deployment-Hinweise und Architekturkontext: [Deployment Guide (English)](./docs/deployment.md)
## Drei Control Planes
<a id="ai-gateway"></a>
### AI Gateway
AI Gateway ist die Governance-Ebene fuer Modellzugriffe in ClawManager. Es stellt verwalteten Agent-Runtimes einen einheitlichen OpenAI-kompatiblen Einstiegspunkt bereit und legt Richtlinien-, Audit- und Kostenkontrollen ueber die Upstream-Provider.
- Einheitlicher Einstiegspunkt fuer Modell-Traffic
- Sichere Modell-Routing-Logik und policy-gesteuerte Modellauswahl
- End-to-End-Audit- und Trace-Aufzeichnungen
- Integrierte Kostenrechnung und Nutzungsanalyse
- Regeln fuer Risikokontrolle mit Block- oder Umleitungslogik
Siehe [AI Gateway Guide (English)](./docs/aigateway.md).
<a id="agent-control-plane"></a>
### Agent Control Plane
Agent Control Plane ist die Runtime-Orchestrierungsschicht fuer verwaltete AI-Agent-Instanzen. Jede Instanz wird damit zu einer verwalteten Runtime, die sich registrieren, Status melden, Commands empfangen und sich am Desired State der Plattform ausrichten kann.
- Agent-Registrierung mit sicherem Bootstrap und Session-Lifecycle
- Heartbeat-basierte Runtime-Status- und Health-Reports
- Desired-State-Synchronisierung zwischen Control Plane und Instanz
- Command-Dispatch fuer Start, Stop, Konfigurationsanwendung, Health Checks und Skill-Operationen
- Sichtbarkeit pro Instanz fuer Agent-Status, channel, skill und Command-Historie
Siehe [Agent Control Plane Guide (English)](./docs/agent-control-plane.md).
<a id="resource-management"></a>
### Ressourcenverwaltung
Ressourcenverwaltung ist die wiederverwendbare Asset-Schicht fuer AI-Agent-Workspaces. Teams koennen channel und skill vorbereiten, zu bundles zusammensetzen, in Instanzen injizieren und Security-Reviews direkt in diesen Ablauf integrieren.
- `Channel`-Verwaltung fuer Workspace-Konnektivitaet und Integrationsvorlagen
- `Skill`-Verwaltung fuer wiederverwendbare Faehigkeitspakete
- `Skill Scanner`-Workflows fuer Risikoanalyse und Scan-Jobs
- Bundle-basierte Ressourcenzusammenstellung fuer reproduzierbare Setups
- Injection-Snapshots zur Nachverfolgung der tatsaechlich angewendeten Inhalte
Siehe [Resource Management Guide (English)](./docs/resource-management.md) und [Security / Skill Scanner Guide (English)](./docs/security-skill-scanner.md).
## Produktgalerie
ClawManager ist so gestaltet, dass Administration, Zugriff und AI-Governance nicht wie getrennte Werkzeuge wirken, sondern wie eine zusammenhaengende Produkterfahrung.
### Lite-Mode-Deployment
Lite Mode stellt Instanzen ueber einen gemeinsamen Gateway-Runtime-Pool bereit. Jeder Workspace laeuft als isolierter Gateway-Prozess in verwalteten Runtime-Pods. Das sorgt fuer schnelle Starts und reduziert dedizierte CPU-, Memory-, Storage- und GPU-Allocation, waehrend Workspace-Zugriff, Share Link / Password Access, channel- und skill-Injection sowie Admin-Sichtbarkeit erhalten bleiben.
![](./docs/main/liteopenclaw.png)
### Pro-Mode-Deployment
Pro Mode stellt fuer jede Instanz eine dedizierte Desktop-Runtime bereit, gestuetzt durch ein eigenes Kubernetes Deployment, einen Service und eine PVC. Es eignet sich fuer Nutzer, die staerkere Isolation, volle Desktop-Ressourcen, Runtime Events, Instanz-Skill-Verwaltung und die vollstaendige Desktop-Management-Erfahrung benoetigen.
![](./docs/main/proopenclaw.png)
### Team Workspace
Die Team-Workspace-Seite bringt Leader-Desktop, Team-Chat, Member-Tabelle und Dispatch-Workflow in eine gemeinsame Betriebsansicht, damit Nutzer den Kollaborationsfortschritt direkt in ClawManager verfolgen koennen.
<p align="center">
<img src="./docs/main/team-workspace.png" alt="ClawManager Team Workspace" width="100%" />
</p>
### Admin Console
Die Admin-Konsole vereint Nutzer, Quotas, Runtime-Operationen, Security-Kontrollen und plattformweite Richtlinien in einer Oberflaeche. Sie ist die zentrale Arbeitsflaeche fuer Teams, die AI-Agent-Infrastruktur im grossen Massstab betreiben.
<p align="center">
<img src="./docs/main/admin.png" alt="ClawManager Admin Console" width="100%" />
</p>
### Portal Access
Das Portal bietet Nutzern einen klaren Einstiegspunkt in ihre Workspaces. Der Zugriff erfolgt browserbasiert, waehrend Runtime-Zustand und Plattformsicht erhalten bleiben, ohne dass Infrastrukturdetails direkt exponiert werden.
<p align="center">
<img src="./docs/main/portal.png" alt="ClawManager Portal Access" width="100%" />
</p>
### AI Gateway
AI Gateway integriert Modell-Governance direkt in die Workspace-Erfahrung. Audit-Trails, Kostentransparenz und risikobasiertes Routing machen AI-Nutzung zu einem Teil der Plattform statt zu einer losen Einzelintegration.
<p align="center">
<img src="./docs/main/aigateway.png" alt="ClawManager AI Gateway" width="100%" />
</p>
## So funktioniert es
1. Administratoren definieren Governance-Richtlinien und wiederverwendbare Ressourcen.
2. Nutzer erstellen oder betreten verwaltete AI-Agent-Workspaces auf Kubernetes.
3. Team Workspaces koennen mehrere Member-Runtimes mit Redis Team Bus und Shared-Storage-Konfiguration bereitstellen.
4. Agents verbinden sich mit der Control Plane und melden Runtime-Zustaende.
5. Channel, skill und bundle werden kompiliert und auf Instanzen angewendet.
6. AI-Traffic fliesst ueber das AI Gateway und erhaelt Audit-, Risiko- und Kostenkontrollen.
## Entwicklerueberblick
ClawManager ist eine Kubernetes-native Plattform mit React-Frontend, Go-Backend, MySQL fuer Zustandsdaten sowie Integrationen wie `skill-scanner` und Object Storage. Die Codebasis ist nach Produktsubsystemen organisiert, daher ist der schnellste Einstieg, mit dem passenden Guide zu beginnen und danach in den Code zu gehen.
- Frontend fuer Admin- und Nutzeroberflaechen unter `frontend/`
- Backend-Services, Handler, Repositorys und Migrationen unter `backend/`
- Deployment-Assets unter `deployments/`
- Produktdokumentation und Medien unter `docs/`
Siehe [Developer Guide (English)](./docs/developer-guide.md).
## Dokumentation
- [Benutzerhandbuch](./docs/use_guide_de.md)
- [Team Workspace Quick Guide](./docs/team-workspaces-guide_de.md)
- [Deployment Guide (English)](./docs/deployment.md)
- [Admin and User Guide (English)](./docs/admin-user-guide.md)
- [Agent Control Plane Guide (English)](./docs/agent-control-plane.md)
- [AI Gateway Guide (English)](./docs/aigateway.md)
- [Security / Skill Scanner Guide (English)](./docs/security-skill-scanner.md)
- [Resource Management Guide (English)](./docs/resource-management.md)
- [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md)
- [Generic Runtime Agent Integration Guide](./docs/runtime-agent-integration-guide.md)
- [Skill Content MD5 Spec](./docs/skill-content-md5-spec.md)
- [Developer Guide (English)](./docs/developer-guide.md)
## Lizenz
Dieses Projekt steht unter der MIT License.
## Open Source
Issues und Pull Requests sind willkommen.
## Star History
<a href="https://www.star-history.com/?repos=Yuan-lab-LLM%2FClawManager&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Yuan-lab-LLM/ClawManager&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Yuan-lab-LLM/ClawManager&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Yuan-lab-LLM/ClawManager&type=date&legend=top-left" />
</picture>
</a>
+293
View File
@@ -0,0 +1,293 @@
# ClawManager
<p align="center">
<img src="frontend/public/openclaw_github_logo.png" alt="ClawManager" width="100%" />
</p>
<p align="center">
ClawManager は、AI エージェントインスタンス管理のための Kubernetes ネイティブなコントロールプレーンです。ガバナンス付きの AI アクセス、ランタイムオーケストレーション、そして複数の Agent Runtime にまたがる再利用可能なリソース管理を提供します。
</p>
<p align="center">
<strong>言語:</strong>
<a href="./README.md">English</a> |
<a href="./README.zh-CN.md">简体中文</a> |
日本語 |
<a href="./README.ko.md">한국어</a> |
<a href="./README.de.md">Deutsch</a>
</p>
<p align="center">
<img src="https://img.shields.io/badge/ClawManager-Control%20Plane-e25544?style=for-the-badge" alt="ClawManager Control Plane" />
<img src="https://img.shields.io/badge/Go-1.21%2B-00ADD8?style=for-the-badge&logo=go&logoColor=white" alt="Go 1.21+" />
<img src="https://img.shields.io/badge/React-19-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" alt="React 19" />
<img src="https://img.shields.io/badge/Kubernetes-Native-326CE5?style=for-the-badge&logo=kubernetes&logoColor=white" alt="Kubernetes Native" />
<img src="https://img.shields.io/badge/License-MIT-2ea44f?style=for-the-badge" alt="MIT License" />
<a href="https://discord.gg/9RwgbGJD5R">
<img src="https://img.shields.io/badge/Discord-Join%20Us-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="ClawManager Discord コミュニティに参加" />
</a>
</p>
<p align="center">
<a href="#product-tour">製品紹介</a> |
<a href="#team-workspaces">Team ワークスペース</a> |
<a href="#ai-gateway">AI Gateway</a> |
<a href="#agent-control-plane">Agent Control Plane</a> |
<a href="#runtime-integrations">Runtime 連携</a> |
<a href="#resource-management">リソース管理</a> |
<a href="#get-started">はじめに</a>
</p>
<p align="center">
<a href="https://github.com/Yuan-lab-LLM/ClawManager/stargazers">
<img src="https://img.shields.io/github/stars/Yuan-lab-LLM/ClawManager?style=for-the-badge&logo=github&label=Star%20ClawManager" alt="Star ClawManager on GitHub" />
</a>
</p>
<h2 align="center">60 秒でわかる ClawManager</h2>
<p align="center">
<img src="https://raw.githubusercontent.com/Yuan-lab-LLM/ClawManager-Assets/main/gif/clawmanager-launch-60s-hd.gif" alt="ClawManager 製品デモ" width="100%" />
</p>
<p align="center">
エージェントの高速プロビジョニング、Skill 管理とスキャン、AI Gateway ガバナンスを短時間で確認できます。
</p>
## 最新情報
最近の重要な製品アップデートとドキュメント更新です。
- [2026-07-07] セキュリティ保護プラットフォーム(secplane)フロントエンドコンソールを追加しました。ランタイム防御(入力/状態/決定/出力サーフェス、資産改ざん防止、ヒューマン承認)、ホスト強化とコンテナ分離、アウトバウンド信頼エンドポイントガバナンス、ポリシーガバナンス、キルスイッチ/サーキットブレーカー、フルチェーン監査、SecureClaw データ・コンポーネント信頼監査、コラボレーションガバナンス、入力検出をカバーする4層防御の統合管理UIを5言語i18nで提供します。
- [2026-06-14] Lite / Pro ランタイムモードとロールアウト対応を追加しました。Lite インスタンスは共有 gateway runtime pool で動作し、Pro インスタンスはより強い分離のため専用 desktop deployment を維持します。
- [2026-05-18] Team ワークスペース MVP の紹介とプレビューを追加しました。ワンクリック Team 作成、OpenClaw メンバーのオーケストレーション、Redis Team Bus 注入、共有ストレージ、メンバー状態、タスク配布、イベント/結果ビューをカバーします。
- [2026-04-29] Hermes Runtime 連携を追加しました。Webtop ベースのインスタンス作成、Agent Control Plane 登録、AI Gateway 注入、channel と skill のブートストラップ、`.hermes` のインポート/エクスポートに対応しています。詳しくは [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md) を参照してください。
- [2026-04-08] プラットフォームに Skill 管理と Skill スキャンのワークフローを追加しました。詳細は [Merged PR #52](https://github.com/Yuan-lab-LLM/ClawManager/pull/52) を参照してください。
- [2026-03-26] AI Gateway ドキュメントを更新し、モデルガバナンス、監査とトレース、コスト計算、リスク制御の説明を強化しました。詳しくは [AI Gateway Guide](./docs/aigateway.md) を参照してください。
- [2026-03-20] ClawManager は、AI エージェントワークスペース向けのより広いコントロールプレーンへと進化し、ランタイム制御、再利用可能なリソース、安全スキャンのワークフローを強化しました。
> ClawManager があなたのチームに役立つなら、ぜひ Star を付けて、より多くのユーザーや開発者に届くよう応援してください。
<p align="center">
<a href="https://github.com/Yuan-lab-LLM/ClawManager/stargazers">
<img src="https://raw.githubusercontent.com/Yuan-lab-LLM/ClawManager-Assets/main/gif/clawmanager-star.gif" alt="Star ClawManager on GitHub" width="100%" />
</a>
</p>
## コミュニティ
ClawManager オープンソースコミュニティに WeChat または Discord から参加してください。プロダクト更新の確認、使い方の相談、コントリビューター同士の交流にご活用ください。
<table align="center">
<tr>
<td align="center" width="320" valign="top">
<img src="./docs/main/clawmanager_group_chat.jpg" alt="ClawManager WeChatグループのQRコード" height="300" />
<br /><br />
<strong>WeChat</strong>
<br />
QRコードをスキャンして WeChat グループに参加
</td>
<td align="center" width="320" valign="top">
<img src="./docs/main/clawmanager_discord.jpg" alt="ClawManager Discord 招待QRコード" height="300" />
<br /><br />
<strong>Discord</strong>
<br />
<a href="https://discord.gg/9RwgbGJD5R">QRコードをスキャンして Discord サーバーに参加</a>
</td>
</tr>
</table>
<a id="product-tour"></a>
## 製品紹介
ClawManager は、AI エージェントインスタンスの運用を Kubernetes に持ち込み、そのランタイム基盤の上に 3 つの高次なコントロールプレーンを重ねます。チームはこれを使って AI アクセスを統制し、Agent を通じてランタイム動作を編成し、スキャン可能で再利用可能な channel と skill を用いてワークスペース機能を提供できます。
次のようなチームに向いています。
- 複数ユーザー向けに AI エージェントインスタンスを運用するプラットフォームチーム
- ランタイムの可観測性、コマンド配布、 desired state 管理が必要な運用チーム
- 手作業の設定ではなく、再利用可能なリソースで Agent ワークスペースを届けたい開発チーム
<a id="team-workspaces"></a>
## Team ワークスペース
Team ワークスペースは、簡素化された OpenClaw Lite の協働フローを提供します。ロールテンプレートを選び、Team を作成して、Team チャットで目標を説明するだけです。Leader が計画、メンバー調整、成果物収集、最終結果の提示を担当します。
- メンバーごとの Runtime やリソースプリセットを設定せず、Leader 仲介型の協働に固定
- デリバリー、製品探索、ソフトウェア開発向けの組み込みテンプレート
- 計画、割り当て、進捗、レビュー、成果物、最終統合を表示する Team チャット
- 総合タスク状態と現在の成果物を表示する Execution Kanban
作成、協働段階、結果の確認方法は [Team Workspace Quick Guide](./docs/team-workspaces-guide_ja.md) を参照してください。
<a id="runtime-integrations"></a>
## Runtime 連携
ClawManager は現在、次の管理対象 Runtime をサポートします。
- <img src="frontend/public/openclaw.png" alt="OpenClaw icon" width="18" /> `OpenClaw`: ClawManager が管理するデスクトップインスタンスで使われる標準の OpenClaw スタイル Runtime
- <img src="frontend/public/hermes.png" alt="Hermes icon" width="18" /> `Hermes`: 永続化された `.hermes` ワークスペースと内蔵 Hermes agent を備えた Webtop ベースの Runtime 連携
Runtime プレビュー:
**<img src="frontend/public/openclaw.png" alt="OpenClaw icon" width="18" /> OpenClaw**
![openclaw](./docs/images/openclaw.png)
**<img src="frontend/public/hermes.png" alt="Hermes icon" width="18" /> Hermes**
![hermes](./docs/images/hermes.png)
Runtime 開発者は、[Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md)、[Generic Runtime Agent Integration Guide](./docs/runtime-agent-integration-guide.md)、[Skill Content MD5 Spec](./docs/skill-content-md5-spec.md) を参照して互換 agent を実装できます。
<a id="get-started"></a>
## はじめに
ClawManager は、標準 Kubernetes と軽量クラスタの両方に対して、より明確な導入入口を提供します。まずは自分の環境に合うデプロイパスを選び、その後に初回ログインと基本操作のフローへ進むのがおすすめです。
- 標準 Kubernetes デプロイ: [deployments/k8s/clawmanager.yaml](./deployments/k8s/clawmanager.yaml)
- K3s / 軽量クラスタ向けデプロイ: [deployments/k3s/clawmanager.yaml](./deployments/k3s/clawmanager.yaml)
- 初回ログインと基本操作フロー: [ユーザーガイド](./docs/use_guide_ja.md)
- デプロイ説明とアーキテクチャ背景: [Deployment Guide (English)](./docs/deployment.md)
## 3 つのコントロールプレーン
<a id="ai-gateway"></a>
### AI Gateway
AI Gateway は、ClawManager におけるモデルアクセスのガバナンスプレーンです。管理対象の Agent Runtime に統一された OpenAI 互換エントリポイントを提供し、上流プロバイダの上にポリシー、監査、コスト制御を追加します。
- モデルトラフィックの統一エントリポイント
- セキュアモデルのルーティングとポリシー駆動のモデル選択
- エンドツーエンドの監査・トレース記録
- 組み込みのコスト計算と利用分析
- ブロックやルート変更を行えるリスク制御ルール
[AI Gateway Guide (English)](./docs/aigateway.md) を参照してください。
<a id="agent-control-plane"></a>
### Agent Control Plane
Agent Control Plane は、管理対象 AI エージェントインスタンスのランタイム編成レイヤーです。各インスタンスを、登録・状態報告・コマンド受信・プラットフォーム側 desired state への整合が可能な管理対象ランタイムへと変えます。
- セキュアなブートストラップとセッションライフサイクルによる Agent 登録
- ハートビートベースのランタイム状態とヘルス報告
- コントロールプレーンとインスタンス間の desired state 同期
- 起動、停止、設定適用、ヘルスチェック、Skill 操作のコマンド配布
- インスタンス単位での Agent 状態、channel、skill、コマンド履歴の可視化
[Agent Control Plane Guide (English)](./docs/agent-control-plane.md) を参照してください。
<a id="resource-management"></a>
### リソース管理
リソース管理は、AI エージェントワークスペース向けの再利用可能な資産レイヤーです。チームは channel や skill を準備し、bundle として組み合わせ、インスタンスへ注入し、安全レビューをその流れに組み込むことができます。
- `Channel` 管理: ワークスペース接続と統合テンプレート
- `Skill` 管理: 再利用可能な機能パッケージ
- `Skill Scanner` ワークフロー: リスク確認とスキャンジョブ
- bundle ベースのリソース構成: 再現性の高いセットアップ
- 注入スナップショットによる実適用内容の追跡
[Resource Management Guide (English)](./docs/resource-management.md) と [Security / Skill Scanner Guide (English)](./docs/security-skill-scanner.md) を参照してください。
## 製品ギャラリー
ClawManager は、管理、アクセス、AI ガバナンスを別々のツールとして扱うのではなく、ひとつの製品体験としてまとめるよう設計されています。
### Lite モードデプロイ
Lite モードは共有 gateway runtime pool 経由でインスタンスをプロビジョニングします。各ワークスペースは管理された runtime Pod 内の独立した gateway プロセスとして動作するため、起動が速く、専用 CPU、メモリ、ストレージ、GPU 割り当ての負担を抑えながら、ワークスペースアクセス、Share Link / Password アクセス、channel と skill の注入、管理画面での可視性を維持します。
![](./docs/main/liteopenclaw.png)
### Pro モードデプロイ
Pro モードは各インスタンスに専用 desktop runtime をプロビジョニングし、独自の Kubernetes Deployment、Service、PVC で構成します。より強い分離、フルデスクトップリソース、runtime events、インスタンス単位の skill 管理、完全なデスクトップ管理体験が必要な場合に適しています。
![](./docs/main/proopenclaw.png)
### Team ワークスペース
Team ワークスペース画面は、Leader デスクトップ、Team チャット、メンバー表、配布ワークフローを 1 つの運用ビューにまとめ、ClawManager から離れずに協調作業の進捗を追えるようにします。
<p align="center">
<img src="./docs/main/team-workspace.png" alt="ClawManager Team ワークスペース" width="100%" />
</p>
### 管理コンソール
管理コンソールでは、ユーザー、クォータ、ランタイム操作、セキュリティ制御、プラットフォームレベルのポリシーをひとつの画面に集約します。大規模な AI エージェント基盤を運用するチームの中心となる作業面です。
<p align="center">
<img src="./docs/main/admin.png" alt="ClawManager 管理コンソール" width="100%" />
</p>
### Portal Access
Portal は、ユーザーに一貫したワークスペース入口を提供します。ブラウザベースでアクセスしながら、コントロールプレーンと同期したランタイム状態を確認でき、インフラの細部を直接意識する必要はありません。
<p align="center">
<img src="./docs/main/portal.png" alt="ClawManager Portal Access" width="100%" />
</p>
### AI Gateway
AI Gateway は、モデル利用のガバナンスをワークスペース体験そのものに統合します。監査ログ、コスト可視化、リスクルーティングを通じて、AI 利用を単発の統合ではなく、プラットフォーム機能として扱えるようにします。
<p align="center">
<img src="./docs/main/aigateway.png" alt="ClawManager AI Gateway" width="100%" />
</p>
## 動作の流れ
1. 管理者がガバナンスポリシーと再利用可能なリソースを定義します。
2. ユーザーが Kubernetes 上で管理対象の AI エージェントワークスペースを作成または利用します。
3. Team ワークスペースは、複数のメンバー Runtime を Redis Team Bus と共有ストレージ設定付きでプロビジョニングできます。
4. Agent がコントロールプレーンへ接続し、ランタイム状態を報告します。
5. Channel、skill、bundle がコンパイルされ、インスタンスへ適用されます。
6. AI トラフィックは AI Gateway を経由し、監査、リスク、コスト制御が付与されます。
## 開発者向け概要
ClawManager は、React フロントエンド、Go バックエンド、状態管理用 MySQL、そして `skill-scanner` やオブジェクトストレージ統合を含む Kubernetes ネイティブなプラットフォームです。コードベースは製品サブシステムごとに整理されているため、該当ガイドから入り、その後コードへ進むのが最も効率的です。
- フロントエンドの管理画面とユーザー画面は `frontend/`
- バックエンドのサービス、handler、repository、migration は `backend/`
- デプロイ資産は `deployments/`
- 製品ドキュメントと素材は `docs/`
[Developer Guide (English)](./docs/developer-guide.md) を参照してください。
## ドキュメント
- [ユーザーガイド](./docs/use_guide_ja.md)
- [Team Workspace Quick Guide](./docs/team-workspaces-guide_ja.md)
- [Deployment Guide (English)](./docs/deployment.md)
- [Admin and User Guide (English)](./docs/admin-user-guide.md)
- [Agent Control Plane Guide (English)](./docs/agent-control-plane.md)
- [AI Gateway Guide (English)](./docs/aigateway.md)
- [Security / Skill Scanner Guide (English)](./docs/security-skill-scanner.md)
- [Resource Management Guide (English)](./docs/resource-management.md)
- [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md)
- [Generic Runtime Agent Integration Guide](./docs/runtime-agent-integration-guide.md)
- [Skill Content MD5 Spec](./docs/skill-content-md5-spec.md)
- [Developer Guide (English)](./docs/developer-guide.md)
## ライセンス
このプロジェクトは MIT License のもとで公開されています。
## オープンソース
Issue と Pull Request を歓迎します。
## Star History
<a href="https://www.star-history.com/?repos=Yuan-lab-LLM%2FClawManager&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Yuan-lab-LLM/ClawManager&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Yuan-lab-LLM/ClawManager&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Yuan-lab-LLM/ClawManager&type=date&legend=top-left" />
</picture>
</a>
+293
View File
@@ -0,0 +1,293 @@
# ClawManager
<p align="center">
<img src="frontend/public/openclaw_github_logo.png" alt="ClawManager" width="100%" />
</p>
<p align="center">
ClawManager는 AI Agent 인스턴스 관리를 위한 Kubernetes 네이티브 컨트롤 플레인으로, 거버넌스가 적용된 AI 접근, 런타임 오케스트레이션, 그리고 여러 Agent Runtime 전반에 걸친 재사용 가능한 리소스 관리를 제공합니다.
</p>
<p align="center">
<strong>언어:</strong>
<a href="./README.md">English</a> |
<a href="./README.zh-CN.md">简体中文</a> |
<a href="./README.ja.md">日本語</a> |
한국어 |
<a href="./README.de.md">Deutsch</a>
</p>
<p align="center">
<img src="https://img.shields.io/badge/ClawManager-Control%20Plane-e25544?style=for-the-badge" alt="ClawManager Control Plane" />
<img src="https://img.shields.io/badge/Go-1.21%2B-00ADD8?style=for-the-badge&logo=go&logoColor=white" alt="Go 1.21+" />
<img src="https://img.shields.io/badge/React-19-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" alt="React 19" />
<img src="https://img.shields.io/badge/Kubernetes-Native-326CE5?style=for-the-badge&logo=kubernetes&logoColor=white" alt="Kubernetes Native" />
<img src="https://img.shields.io/badge/License-MIT-2ea44f?style=for-the-badge" alt="MIT License" />
<a href="https://discord.gg/9RwgbGJD5R">
<img src="https://img.shields.io/badge/Discord-Join%20Us-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="ClawManager Discord 커뮤니티 참여" />
</a>
</p>
<p align="center">
<a href="#product-tour">제품 소개</a> |
<a href="#team-workspaces">Team 워크스페이스</a> |
<a href="#ai-gateway">AI Gateway</a> |
<a href="#agent-control-plane">Agent Control Plane</a> |
<a href="#runtime-integrations">Runtime 연동</a> |
<a href="#resource-management">리소스 관리</a> |
<a href="#get-started">시작하기</a>
</p>
<p align="center">
<a href="https://github.com/Yuan-lab-LLM/ClawManager/stargazers">
<img src="https://img.shields.io/github/stars/Yuan-lab-LLM/ClawManager?style=for-the-badge&logo=github&label=Star%20ClawManager" alt="Star ClawManager on GitHub" />
</a>
</p>
<h2 align="center">60초 안에 보는 ClawManager</h2>
<p align="center">
<img src="https://raw.githubusercontent.com/Yuan-lab-LLM/ClawManager-Assets/main/gif/clawmanager-launch-60s-hd.gif" alt="ClawManager 제품 데모" width="100%" />
</p>
<p align="center">
빠른 Agent 프로비저닝, Skill 관리와 스캔, AI Gateway 거버넌스를 짧게 확인할 수 있습니다.
</p>
## 최신 업데이트
최근의 중요한 제품 및 문서 업데이트입니다.
- [2026-07-07] 보안 방어 플랫폼(secplane) 프론트엔드 콘솔을 추가했습니다. 런타임 방어(입력/상태/의사결정/출력 표면, 자산 변조 방지, 휴먼 승인), 호스트 강화 및 컨테이너 격리, 아웃바운드 신뢰 엔드포인트 거버넌스, 정책 거버넌스, 킬스위치/서킷브레이커, 전체 체인 감사, SecureClaw 데이터 및 컴포넌트 신뢰 감사, 협업 거버넌스, 입력 탐지를 포괄하는 4계층 방어 통합 관리 UI를 5개 언어 i18n으로 제공합니다.
- [2026-06-14] Lite / Pro 런타임 모드와 롤아웃 지원을 추가했습니다. Lite 인스턴스는 공유 gateway runtime pool에서 실행되고, Pro 인스턴스는 더 강한 격리를 위해 전용 desktop deployment를 유지합니다.
- [2026-05-18] Team 워크스페이스 MVP 소개와 미리보기를 추가했습니다. 원클릭 Team 생성, OpenClaw 멤버 오케스트레이션, Redis Team Bus 주입, 공유 스토리지, 멤버 상태, 작업 배포, 이벤트/결과 보기를 포함합니다.
- [2026-04-29] Hermes Runtime 연동을 추가했습니다. Webtop 기반 인스턴스 생성, Agent Control Plane 등록, AI Gateway 주입, channel 및 skill 부트스트랩, `.hermes` 가져오기/내보내기 흐름을 지원합니다. 자세한 내용은 [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md)를 참고하세요.
- [2026-04-08] 플랫폼에 Skill 관리와 Skill 스캔 워크플로우가 추가되었습니다. 자세한 내용은 [Merged PR #52](https://github.com/Yuan-lab-LLM/ClawManager/pull/52)를 참고하세요.
- [2026-03-26] AI Gateway 문서를 업데이트하여 모델 거버넌스, 감사와 추적, 비용 계산, 리스크 제어 설명을 강화했습니다. 자세한 내용은 [AI Gateway Guide](./docs/aigateway.md)를 참고하세요.
- [2026-03-20] ClawManager는 AI Agent 워크스페이스를 위한 더 넓은 컨트롤 플레인으로 발전했으며, 런타임 제어, 재사용 가능한 리소스, 보안 스캔 워크플로우가 강화되었습니다.
> ClawManager가 여러분의 팀에 도움이 된다면, 프로젝트에 Star를 남겨 더 많은 사용자와 개발자가 발견할 수 있도록 도와주세요.
<p align="center">
<a href="https://github.com/Yuan-lab-LLM/ClawManager/stargazers">
<img src="https://raw.githubusercontent.com/Yuan-lab-LLM/ClawManager-Assets/main/gif/clawmanager-star.gif" alt="Star ClawManager on GitHub" width="100%" />
</a>
</p>
## 커뮤니티
ClawManager 오픈소스 커뮤니티에 WeChat 또는 Discord로 참여해 제품 업데이트를 확인하고, 사용 경험을 나누며, 기여자들과 함께 소통해 보세요.
<table align="center">
<tr>
<td align="center" width="320" valign="top">
<img src="./docs/main/clawmanager_group_chat.jpg" alt="ClawManager WeChat 그룹 QR 코드" height="300" />
<br /><br />
<strong>WeChat</strong>
<br />
QR 코드를 스캔하여 WeChat 그룹에 참여
</td>
<td align="center" width="320" valign="top">
<img src="./docs/main/clawmanager_discord.jpg" alt="ClawManager Discord 초대 QR 코드" height="300" />
<br /><br />
<strong>Discord</strong>
<br />
<a href="https://discord.gg/9RwgbGJD5R">QR 코드를 스캔하여 Discord 서버에 참여</a>
</td>
</tr>
</table>
<a id="product-tour"></a>
## 제품 소개
ClawManager는 AI Agent 인스턴스 운영을 Kubernetes 위로 확장하고, 그 런타임 기반 위에 3개의 상위 컨트롤 플레인을 제공합니다. 팀은 이를 통해 AI 접근을 통제하고, Agent를 통해 런타임 동작을 오케스트레이션하며, 스캔 가능하고 재사용 가능한 channel 및 skill 리소스로 워크스페이스 기능을 제공할 수 있습니다.
다음과 같은 팀에 적합합니다.
- 여러 사용자를 대상으로 AI Agent 인스턴스를 운영하는 플랫폼 팀
- 런타임 가시성, 명령 배포, desired state 제어가 필요한 운영 팀
- 수동 설정 대신 재사용 가능한 리소스로 Agent 워크스페이스를 제공하고 싶은 개발 팀
<a id="team-workspaces"></a>
## Team 워크스페이스
Team 워크스페이스는 단순화된 OpenClaw Lite 협업 흐름을 제공합니다. 역할 템플릿을 고르고 Team을 만든 뒤 Team 채팅에 목표를 설명하면 됩니다. Leader가 계획 수립, 멤버 조율, 산출물 수집, 최종 결과 정리를 담당합니다.
- 멤버별 Runtime 또는 리소스 프리셋 설정 없이 Leader 중개 협업으로 고정
- 납품, 제품 탐색, 소프트웨어 엔지니어링을 위한 기본 템플릿
- 계획, 배정, 진행, 검토, 산출물, 최종 종합을 보여 주는 Team 채팅
- 전체 작업 상태와 현재 멤버 산출물을 보여 주는 Execution Kanban
생성 과정, 협업 단계, 결과 확인은 [Team Workspace Quick Guide](./docs/team-workspaces-guide_ko.md)를 참고하세요.
<a id="runtime-integrations"></a>
## Runtime 연동
ClawManager는 현재 다음 관리형 Runtime을 지원합니다.
- <img src="frontend/public/openclaw.png" alt="OpenClaw icon" width="18" /> `OpenClaw`: ClawManager가 관리하는 데스크톱 인스턴스에서 사용하는 기본 OpenClaw 스타일 워크스페이스 Runtime
- <img src="frontend/public/hermes.png" alt="Hermes icon" width="18" /> `Hermes`: 영구 `.hermes` 워크스페이스와 내장 Hermes agent를 포함한 Webtop 기반 Runtime 연동
Runtime 미리보기:
**<img src="frontend/public/openclaw.png" alt="OpenClaw icon" width="18" /> OpenClaw**
![openclaw](./docs/images/openclaw.png)
**<img src="frontend/public/hermes.png" alt="Hermes icon" width="18" /> Hermes**
![hermes](./docs/images/hermes.png)
Runtime 개발자는 [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md), [Generic Runtime Agent Integration Guide](./docs/runtime-agent-integration-guide.md), [Skill Content MD5 Spec](./docs/skill-content-md5-spec.md)를 참고해 호환 agent를 구현할 수 있습니다.
<a id="get-started"></a>
## 시작하기
ClawManager는 이제 표준 Kubernetes 환경과 경량 클러스터 환경 모두에 대해 더 명확한 진입 경로를 제공합니다. 먼저 자신의 환경에 맞는 배포 경로를 선택한 뒤, 첫 로그인 및 기본 사용 흐름으로 이어가면 됩니다.
- 표준 Kubernetes 배포: [deployments/k8s/clawmanager.yaml](./deployments/k8s/clawmanager.yaml)
- K3s / 경량 클러스터 배포: [deployments/k3s/clawmanager.yaml](./deployments/k3s/clawmanager.yaml)
- 첫 로그인 및 기본 사용 흐름: [사용자 가이드](./docs/use_guide_ko.md)
- 배포 설명 및 아키텍처 배경: [Deployment Guide (English)](./docs/deployment.md)
## 세 가지 컨트롤 플레인
<a id="ai-gateway"></a>
### AI Gateway
AI Gateway는 ClawManager에서 모델 접근을 거버넌스하는 컨트롤 플레인입니다. 관리되는 Agent Runtime에 통합된 OpenAI 호환 진입점을 제공하고, 상위 모델 제공자 위에 정책, 감사, 비용 제어를 추가합니다.
- 모델 트래픽을 위한 통합 진입점
- 보안 모델 라우팅과 정책 기반 모델 선택
- 엔드투엔드 감사 및 추적 기록
- 내장된 비용 계산과 사용량 분석
- 차단 또는 라우팅 전환이 가능한 리스크 제어 규칙
[AI Gateway Guide (English)](./docs/aigateway.md)를 참고하세요.
<a id="agent-control-plane"></a>
### Agent Control Plane
Agent Control Plane은 관리되는 AI Agent 인스턴스를 위한 런타임 오케스트레이션 계층입니다. 각 인스턴스를 등록, 상태 보고, 명령 수신, 그리고 플랫폼 측 desired state와의 정렬이 가능한 관리형 런타임으로 만듭니다.
- 보안 부트스트랩과 세션 라이프사이클 기반 Agent 등록
- 하트비트 기반 런타임 상태 및 헬스 리포팅
- 컨트롤 플레인과 인스턴스 간 desired state 동기화
- 시작, 중지, 설정 적용, 헬스체크, Skill 작업을 위한 명령 배포
- 인스턴스 단위의 Agent 상태, channel, skill, 명령 이력 가시화
[Agent Control Plane Guide (English)](./docs/agent-control-plane.md)를 참고하세요.
<a id="resource-management"></a>
### 리소스 관리
리소스 관리는 AI Agent 워크스페이스를 위한 재사용 가능한 자산 계층입니다. 팀은 channel과 skill을 준비하고, bundle로 조합하고, 인스턴스에 주입하며, 그 과정에 보안 검토를 자연스럽게 포함시킬 수 있습니다.
- `Channel` 관리: 워크스페이스 연결과 통합 템플릿
- `Skill` 관리: 재사용 가능한 기능 패키지
- `Skill Scanner` 워크플로우: 리스크 검토와 스캔 작업
- bundle 기반 리소스 조합: 반복 가능한 워크스페이스 구성
- 주입 스냅샷을 통한 실제 적용 결과 추적
[Resource Management Guide (English)](./docs/resource-management.md)와 [Security / Skill Scanner Guide (English)](./docs/security-skill-scanner.md)를 참고하세요.
## 제품 갤러리
ClawManager는 관리, 접근, AI 거버넌스를 서로 분리된 도구로 다루지 않고, 하나의 일관된 제품 경험으로 묶도록 설계되었습니다.
### Lite 모드 배포
Lite 모드는 공유 gateway runtime pool을 통해 인스턴스를 프로비저닝합니다. 각 워크스페이스는 관리되는 runtime Pod 안의 독립 gateway 프로세스로 실행되어 빠르게 시작되고 전용 CPU, 메모리, 스토리지, GPU 할당 부담을 줄이면서도 워크스페이스 접근, Share Link / Password 접근, channel 및 skill 주입, 관리자 가시성을 유지합니다.
![](./docs/main/liteopenclaw.png)
### Pro 모드 배포
Pro 모드는 각 인스턴스에 전용 desktop runtime을 프로비저닝하며, 독립 Kubernetes Deployment, Service, PVC로 구성됩니다. 더 강한 격리, 전체 데스크톱 리소스, runtime events, 인스턴스 skill 관리, 완전한 데스크톱 관리 경험이 필요한 경우에 적합합니다.
![](./docs/main/proopenclaw.png)
### Team 워크스페이스
Team 워크스페이스 화면은 Leader 데스크톱, Team 채팅, 멤버 테이블, 배포 워크플로우를 하나의 운영 화면에 모아 ClawManager 안에서 협업 진행 상황을 따라갈 수 있게 합니다.
<p align="center">
<img src="./docs/main/team-workspace.png" alt="ClawManager Team 워크스페이스" width="100%" />
</p>
### 관리 콘솔
관리 콘솔은 사용자, 쿼터, 런타임 작업, 보안 제어, 플랫폼 수준 정책을 하나의 화면으로 묶습니다. 대규모 AI Agent 인프라를 운영하는 팀의 핵심 작업 공간입니다.
<p align="center">
<img src="./docs/main/admin.png" alt="ClawManager 관리 콘솔" width="100%" />
</p>
### Portal Access
Portal은 사용자에게 일관된 워크스페이스 진입점을 제공합니다. 브라우저 기반으로 접근하면서도 컨트롤 플레인과 동기화된 런타임 상태를 확인할 수 있어, 사용자가 인프라 세부 사항을 직접 다루지 않아도 됩니다.
<p align="center">
<img src="./docs/main/portal.png" alt="ClawManager Portal Access" width="100%" />
</p>
### AI Gateway
AI Gateway는 모델 사용 거버넌스를 워크스페이스 경험 자체에 통합합니다. 감사 로그, 비용 가시성, 리스크 라우팅을 제공하여 AI 사용을 개별 통합이 아닌 플랫폼 기능으로 다룰 수 있게 합니다.
<p align="center">
<img src="./docs/main/aigateway.png" alt="ClawManager AI Gateway" width="100%" />
</p>
## 동작 방식
1. 관리자가 거버넌스 정책과 재사용 가능한 리소스를 정의합니다.
2. 사용자가 Kubernetes에서 관리되는 AI Agent 워크스페이스를 생성하거나 진입합니다.
3. Team 워크스페이스는 여러 멤버 Runtime을 Redis Team Bus와 공유 스토리지 설정과 함께 프로비저닝할 수 있습니다.
4. Agent가 컨트롤 플레인에 연결해 런타임 상태를 보고합니다.
5. Channel, skill, bundle이 컴파일되어 인스턴스에 적용됩니다.
6. AI 트래픽은 AI Gateway를 통해 전달되며, 감사, 리스크, 비용 제어가 함께 적용됩니다.
## 개발자 개요
ClawManager는 React 프런트엔드, Go 백엔드, 상태 저장용 MySQL, 그리고 `skill-scanner` 및 오브젝트 스토리지 통합을 포함한 Kubernetes 네이티브 플랫폼입니다. 코드베이스는 제품 서브시스템 단위로 구성되어 있으므로, 관련 가이드에서 시작한 뒤 코드로 들어가는 방식이 가장 효율적입니다.
- 프런트엔드의 관리자 및 사용자 화면은 `frontend/`
- 백엔드 서비스, handler, repository, migration은 `backend/`
- 배포 자산은 `deployments/`
- 제품 문서와 이미지 자산은 `docs/`
[Developer Guide (English)](./docs/developer-guide.md)를 참고하세요.
## 문서
- [사용자 가이드](./docs/use_guide_ko.md)
- [Team Workspace Quick Guide](./docs/team-workspaces-guide_ko.md)
- [Deployment Guide (English)](./docs/deployment.md)
- [Admin and User Guide (English)](./docs/admin-user-guide.md)
- [Agent Control Plane Guide (English)](./docs/agent-control-plane.md)
- [AI Gateway Guide (English)](./docs/aigateway.md)
- [Security / Skill Scanner Guide (English)](./docs/security-skill-scanner.md)
- [Resource Management Guide (English)](./docs/resource-management.md)
- [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md)
- [Generic Runtime Agent Integration Guide](./docs/runtime-agent-integration-guide.md)
- [Skill Content MD5 Spec](./docs/skill-content-md5-spec.md)
- [Developer Guide (English)](./docs/developer-guide.md)
## 라이선스
이 프로젝트는 MIT License로 공개됩니다.
## 오픈소스
Issue와 Pull Request를 환영합니다.
## Star History
<a href="https://www.star-history.com/?repos=Yuan-lab-LLM%2FClawManager&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Yuan-lab-LLM/ClawManager&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Yuan-lab-LLM/ClawManager&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Yuan-lab-LLM/ClawManager&type=date&legend=top-left" />
</picture>
</a>
+293
View File
@@ -0,0 +1,293 @@
# ClawManager
<p align="center">
<img src="frontend/public/openclaw_github_logo.png" alt="ClawManager" width="100%" />
</p>
<p align="center">
A Kubernetes-native control plane for AI agent instance management, with governed AI access, runtime orchestration, and reusable resources across multiple agent runtimes.
</p>
<p align="center">
<strong>Languages:</strong>
English |
<a href="./README.zh-CN.md">Chinese</a> |
<a href="./README.ja.md">Japanese</a> |
<a href="./README.ko.md">Korean</a> |
<a href="./README.de.md">Deutsch</a>
</p>
<p align="center">
<img src="https://img.shields.io/badge/ClawManager-Control%20Plane-e25544?style=for-the-badge" alt="ClawManager Control Plane" />
<img src="https://img.shields.io/badge/Go-1.21%2B-00ADD8?style=for-the-badge&logo=go&logoColor=white" alt="Go 1.21+" />
<img src="https://img.shields.io/badge/React-19-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" alt="React 19" />
<img src="https://img.shields.io/badge/Kubernetes-Native-326CE5?style=for-the-badge&logo=kubernetes&logoColor=white" alt="Kubernetes Native" />
<img src="https://img.shields.io/badge/License-MIT-2ea44f?style=for-the-badge" alt="MIT License" />
<a href="https://discord.gg/9RwgbGJD5R">
<img src="https://img.shields.io/badge/Discord-Join%20Us-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join ClawManager on Discord" />
</a>
</p>
<p align="center">
<a href="#product-tour">Explore the Product</a> |
<a href="#team-workspaces">Team Workspaces</a> |
<a href="#ai-gateway">AI Gateway</a> |
<a href="#agent-control-plane">Agent Control Plane</a> |
<a href="#runtime-integrations">Runtime Integrations</a> |
<a href="#resource-management">Resource Management</a> |
<a href="#get-started">Get Started</a>
</p>
<p align="center">
<a href="https://github.com/Yuan-lab-LLM/ClawManager/stargazers">
<img src="https://img.shields.io/github/stars/Yuan-lab-LLM/ClawManager?style=for-the-badge&logo=github&label=Star%20ClawManager" alt="Star ClawManager on GitHub" />
</a>
</p>
<h2 align="center">See ClawManager in 60 Seconds</h2>
<p align="center">
<img src="https://raw.githubusercontent.com/Yuan-lab-LLM/ClawManager-Assets/main/gif/clawmanager-launch-60s-hd.gif" alt="ClawManager product launch demo" width="100%" />
</p>
<p align="center">
A quick look at fast agent provisioning, skill management and scanning, and AI Gateway governance.
</p>
## What's New
Recent highlights from the latest product and documentation updates.
- [2026-07-07] Added the Security Protection Platform (secplane) frontend — a comprehensive security console covering runtime defense (input/state/decision/output surface, asset tamper-proofing, human approval), host hardening & container isolation, outbound trusted-endpoint governance, policy governance, kill-switch/circuit-breaker, full-chain audit, SecureClaw data-and-component trust auditing, collaboration governance, and input detection. All 4 defense layers are accessible from a unified admin UI with full i18n for 5 languages.
- [2026-06-14] Added Lite / Pro runtime modes and rollout support, so Lite instances can run through shared gateway runtime pools while Pro instances keep dedicated desktop deployments for stronger isolation.
- [2026-05-18] Added the Team workspace MVP introduction and preview, covering one-click Team creation, OpenClaw member orchestration, Redis Team Bus injection, shared storage, member status, task dispatch, and event/result views.
- [2026-04-29] Added Hermes runtime integration support, including Webtop-based instance provisioning, Agent Control Plane registration, AI Gateway injection, channel and skill bootstrap, and `.hermes` import/export workflows. See the [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md).
- [2026-04-08] Added skill management and skill scanning workflows to the platform, via [Merged PR #52](https://github.com/Yuan-lab-LLM/ClawManager/pull/52).
- [2026-03-26] AI Gateway documentation was refreshed with stronger coverage for model governance, audit and trace, cost accounting, and risk control. See the [AI Gateway Guide](./docs/aigateway.md).
- [2026-03-20] ClawManager evolved into a broader control plane for AI agent workspaces, with stronger runtime control, reusable resources, and security scanning workflows.
> If ClawManager is useful to your team, please star the project to help more users and contributors discover it.
<p align="center">
<a href="https://github.com/Yuan-lab-LLM/ClawManager/stargazers">
<img src="https://raw.githubusercontent.com/Yuan-lab-LLM/ClawManager-Assets/main/gif/clawmanager-star.gif" alt="Star ClawManager on GitHub" width="100%" />
</a>
</p>
## Community
Join the ClawManager open source community on WeChat or Discord for product updates, usage discussion, and contributor collaboration.
<table align="center">
<tr>
<td align="center" width="320" valign="top">
<img src="./docs/main/clawmanager_group_chat.jpg" alt="ClawManager WeChat group QR code" height="300" />
<br /><br />
<strong>WeChat</strong>
<br />
Scan to join the WeChat community group
</td>
<td align="center" width="320" valign="top">
<img src="./docs/main/clawmanager_discord.jpg" alt="ClawManager Discord invite QR code" height="300" />
<br /><br />
<strong>Discord</strong>
<br />
<a href="https://discord.gg/9RwgbGJD5R">Scan to join our Discord server</a>
</td>
</tr>
</table>
## Product Tour
ClawManager brings AI agent instance operations to Kubernetes and layers three higher-level control planes on top of that runtime foundation. Teams use it to govern AI access, orchestrate runtime behavior through agents, and manage reusable channels and skills with scanning and bundle-based delivery.
It is designed for:
- platform teams running AI agent instances for multiple users
- operators who need runtime visibility, command dispatch, and desired-state control
- builders who want governed AI access and reusable resource injection instead of manual per-instance setup
<a id="team-workspaces"></a>
## Team Workspaces
Team Workspaces provide a simplified OpenClaw Lite collaboration flow: choose a role template, create the Team, and describe the goal in the Team chat. The Leader plans the work, coordinates members, collects deliveries, and publishes the final result.
- fixed Leader-mediated collaboration, without per-member runtime or resource-preset setup
- built-in templates for focused delivery, product discovery, and software engineering work
- Team chat for plans, assignments, progress, reviews, deliveries, and final synthesis
- Execution Kanban for the root-task state and current member deliveries
See the [Team Workspace Quick Guide](./docs/team-workspaces-guide_en.md) for the creation flow, collaboration stages, and result viewing.
<a id="runtime-integrations"></a>
## Runtime Integrations
ClawManager currently supports the following managed runtimes:
- <img src="frontend/public/openclaw.png" alt="OpenClaw icon" width="18" /> `OpenClaw`: the default OpenClaw-style workspace runtime used by ClawManager-managed desktop instances
- <img src="frontend/public/hermes.png" alt="Hermes icon" width="18" /> `Hermes`: a Webtop-based runtime integration with a persistent `.hermes` workspace and embedded Hermes agent
Runtime previews:
**<img src="frontend/public/openclaw.png" alt="OpenClaw icon" width="18" /> OpenClaw**
![openclaw](./docs/images/openclaw.png)
**<img src="frontend/public/hermes.png" alt="Hermes icon" width="18" /> Hermes**
![hermes](./docs/images/hermes.png)
Runtime authors can follow the [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md), the [Generic Runtime Agent Integration Guide](./docs/runtime-agent-integration-guide.md), and the [Skill Content MD5 Spec](./docs/skill-content-md5-spec.md) to build compatible agents.
## Get Started
ClawManager now separates the Kubernetes distribution from the storage profile. Choose `k3s` or `k8s` first, then choose the storage profile that matches the cluster shape:
- k3s single-node HostPath: [deployments/k3s/single-node/clawmanager.yaml](./deployments/k3s/single-node/clawmanager.yaml)
- k3s cluster CSI/RWX example: [deployments/k3s/cluster/clawmanager.yaml](./deployments/k3s/cluster/clawmanager.yaml)
- Kubernetes single-node HostPath: [deployments/k8s/single-node/clawmanager.yaml](./deployments/k8s/single-node/clawmanager.yaml)
- Kubernetes cluster CSI/RWX example: [deployments/k8s/cluster/clawmanager.yaml](./deployments/k8s/cluster/clawmanager.yaml)
- Operations-oriented quick start and first login flow: [User Guide](./docs/use_guide_en.md)
- Deployment notes and architecture-level context: [Deployment Guide](./docs/deployment.md)
The cluster profile is validated with Longhorn (`longhorn` for RWO data and `longhorn-rwx` for RWX workspaces), but these StorageClass names are examples. You can replace them with any CSI classes that provide the same access modes.
## Three Control Planes
### AI Gateway
AI Gateway is the governance plane for model access inside ClawManager. It gives managed agent runtimes a unified OpenAI-compatible entry point while adding policy and audit controls on top of upstream providers.
- Unified gateway entry for model traffic
- Secure model routing and policy-aware model selection
- End-to-end audit and trace records
- Built-in cost accounting and usage analysis
- Risk control rules that can block or reroute requests
See the [AI Gateway Guide](./docs/aigateway.md).
### Agent Control Plane
Agent Control Plane is the runtime orchestration layer for managed AI agent instances. It turns each instance into a managed runtime that can register, report status, receive commands, and stay aligned with platform-side desired state.
- Agent registration with secure bootstrap and session lifecycle
- Heartbeat-driven runtime status and health reporting
- Desired-state synchronization between the control plane and the instance
- Runtime command dispatch for start, stop, config apply, health checks, and skill operations
- Instance-level visibility into agent status, channels, skills, and command history
See the [Agent Control Plane Guide](./docs/agent-control-plane.md).
### Resource Management
Resource Management is the reusable asset layer for AI agent workspaces. It helps teams prepare channels and skills once, organize them into bundles, inject them into instances, and keep security review in the loop.
- Channel management for workspace connectivity and integration templates
- Skill management for reusable packaged capabilities
- Skill Scanner workflows for risk review and scan operations
- Bundle-based resource composition for repeatable workspace setup
- Injection snapshots and runtime-level visibility into what was applied
See the [Resource Management Guide](./docs/resource-management.md) and the [Security / Skill Scanner Guide](./docs/security-skill-scanner.md).
## Product Gallery
The product is designed to feel coherent across administration, workspace access, and AI governance. Instead of treating these as separate tools, ClawManager brings them into one control surface.
### Lite Mode Deployment
Lite mode provisions instances through a shared gateway runtime pool. Each workspace runs as an isolated gateway process inside managed runtime Pods, which keeps startup fast and lowers dedicated CPU, memory, storage, and GPU allocation overhead while preserving workspace access, Share Link / Password access, channel and skill injection, and admin visibility.
![](./docs/main/liteopenclaw.png)
### Pro Mode Deployment
Pro mode provisions a dedicated desktop runtime for each instance, backed by its own Kubernetes Deployment, Service, and PVC. Use it when users need stronger isolation, full desktop resources, runtime events, instance skill management, and the complete desktop management experience.
![](./docs/main/proopenclaw.png)
### Team Workspace
The Team workspace page brings the leader desktop, Team chat, member table, and dispatch workflow into one operational view, so users can follow collaboration progress without leaving ClawManager.
<p align="center">
<img src="./docs/main/team-workspace.png" alt="ClawManager Team workspace" width="100%" />
</p>
### Admin Console
The admin console brings together users, quotas, runtime operations, security controls, and platform-level policies in one place. It is the operational center for teams running AI agent infrastructure at scale.
<p align="center">
<img src="./docs/main/admin.png" alt="ClawManager admin console" width="100%" />
</p>
### Portal Access
The portal experience gives users a clean entry point into their workspaces, with browser-based access and runtime visibility that stays connected to the control plane instead of exposing infrastructure details directly.
<p align="center">
<img src="./docs/main/portal.png" alt="ClawManager portal access" width="100%" />
</p>
### AI Gateway
AI Gateway extends the workspace experience with governed model access, audit trails, cost visibility, and risk-aware routing, making AI usage manageable as part of the platform rather than an isolated integration.
<p align="center">
<img src="./docs/main/aigateway.png" alt="ClawManager AI Gateway" width="100%" />
</p>
## How It Works
1. Admins define governance policies and reusable resources.
2. Users create or enter managed AI agent workspaces on Kubernetes.
3. Team workspaces can provision multiple member runtimes with Redis Team Bus and shared storage configuration.
4. Agents connect back to the control plane and report runtime state.
5. Channels, skills, and bundles are compiled and applied to instances.
6. AI traffic flows through AI Gateway with audit, risk, and cost controls.
## Developer Snapshot
ClawManager is built as a Kubernetes-native platform with a React frontend, a Go backend, MySQL for state, and supporting services such as skill-scanner and object storage integrations. The repository is organized around product subsystems rather than a single monolith page, so the best developer experience is to start from the relevant guide and then move into the code.
- Frontend app and admin/user surfaces live under `frontend/`
- Backend services, handlers, repositories, and migrations live under `backend/`
- Deployment assets live under `deployments/`
- Supporting product docs live under `docs/`
See the [Developer Guide](./docs/developer-guide.md).
## Documentation
- [User Guide](./docs/use_guide_en.md)
- [Team Workspace Quick Guide](./docs/team-workspaces-guide_en.md)
- [Deployment Guide](./docs/deployment.md)
- [Admin and User Guide](./docs/admin-user-guide.md)
- [Agent Control Plane Guide](./docs/agent-control-plane.md)
- [AI Gateway Guide](./docs/aigateway.md)
- [Security / Skill Scanner Guide](./docs/security-skill-scanner.md)
- [Resource Management Guide](./docs/resource-management.md)
- [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md)
- [Generic Runtime Agent Integration Guide](./docs/runtime-agent-integration-guide.md)
- [Skill Content MD5 Spec](./docs/skill-content-md5-spec.md)
- [Developer Guide](./docs/developer-guide.md)
## License
This project is licensed under the MIT License.
## Open Source
Issues and pull requests are welcome.
## Star History
<a href="https://www.star-history.com/?repos=Yuan-lab-LLM%2FClawManager&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Yuan-lab-LLM/ClawManager&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Yuan-lab-LLM/ClawManager&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Yuan-lab-LLM/ClawManager&type=date&legend=top-left" />
</picture>
</a>
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`Yuan-lab-LLM/ClawManager`
- 原始仓库:https://github.com/Yuan-lab-LLM/ClawManager
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+297
View File
@@ -0,0 +1,297 @@
# ClawManager
<p align="center">
<img src="frontend/public/openclaw_github_logo.png" alt="ClawManager" width="100%" />
</p>
<p align="center">
一个面向 AI Agent 实例管理的 Kubernetes 原生控制平面,提供受治理的 AI 访问、运行时编排,以及适用于多种 Agent Runtime 的可复用资源管理能力。
</p>
<p align="center">
<strong>语言:</strong>
<a href="./README.md">English</a> |
简体中文 |
<a href="./README.ja.md">日本語</a> |
<a href="./README.ko.md">한국어</a> |
<a href="./README.de.md">Deutsch</a>
</p>
<p align="center">
<img src="https://img.shields.io/badge/ClawManager-Control%20Plane-e25544?style=for-the-badge" alt="ClawManager Control Plane" />
<img src="https://img.shields.io/badge/Go-1.21%2B-00ADD8?style=for-the-badge&logo=go&logoColor=white" alt="Go 1.21+" />
<img src="https://img.shields.io/badge/React-19-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" alt="React 19" />
<img src="https://img.shields.io/badge/Kubernetes-Native-326CE5?style=for-the-badge&logo=kubernetes&logoColor=white" alt="Kubernetes Native" />
<img src="https://img.shields.io/badge/License-MIT-2ea44f?style=for-the-badge" alt="MIT License" />
<a href="https://discord.gg/9RwgbGJD5R">
<img src="https://img.shields.io/badge/Discord-Join%20Us-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="加入 ClawManager Discord 社区" />
</a>
</p>
<p align="center">
<a href="#product-tour">了解产品</a> |
<a href="#team-workspaces">Team 协作</a> |
<a href="#ai-gateway">AI Gateway</a> |
<a href="#agent-control-plane">Agent Control Plane</a> |
<a href="#runtime-integrations">Runtime 接入</a> |
<a href="#resource-management">资源管理</a> |
<a href="#get-started">快速开始</a>
</p>
<p align="center">
<a href="https://github.com/Yuan-lab-LLM/ClawManager/stargazers">
<img src="https://img.shields.io/github/stars/Yuan-lab-LLM/ClawManager?style=for-the-badge&logo=github&label=Star%20ClawManager" alt="Star ClawManager on GitHub" />
</a>
</p>
<h2 align="center">60 秒认识 ClawManager</h2>
<p align="center">
<img src="https://raw.githubusercontent.com/Yuan-lab-LLM/ClawManager-Assets/main/gif/clawmanager-launch-60s-hd.gif" alt="ClawManager 产品演示" width="100%" />
</p>
<p align="center">
快速了解 Agent 实例创建、Skill 管理与扫描,以及 AI Gateway 治理能力。
</p>
## 最新动态
这里展示最近的重要产品与文档更新。
- [2026-07-07] 新增安全防护平台(secplane)前端控制台——覆盖运行时防御(输入面/状态面/决策面/输出面、资产防篡改、人因审批)、主机加固与容器隔离、出站可信端点治理、策略治理、应急熔断、全链路审计、SecureClaw 数据与组件可信审计、协同接入治理及输入检测,4 层防护统一管理界面,5 语言 i18n 完整支持。
- [2026-06-14] 新增 Lite / Pro 运行时模式与滚动升级支持,Lite 实例可通过共享 gateway 运行时池运行,Pro 实例保留专属 desktop deployment 以获得更强隔离。
- [2026-05-18] 新增 Team 工作空间 MVP 介绍与界面预览,覆盖一键创建 Team、OpenClaw 成员编排、Redis Team Bus 配置注入、共享存储、成员状态、任务派发,以及事件和结果查看。
- [2026-04-29] 新增 Hermes Runtime 接入支持,覆盖基于 Webtop 的实例创建、Agent Control Plane 注册、AI Gateway 注入、channel 与 skill 引导注入,以及 `.hermes` 导入导出流程。见 [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md)。
- [2026-04-08] 平台新增了 Skill 管理与 Skill 扫描工作流,见 [Merged PR #52](https://github.com/Yuan-lab-LLM/ClawManager/pull/52)。
- [2026-03-26] AI Gateway 文档已更新,补充了模型治理、审计追踪、成本核算与风险控制能力,见 [AI Gateway Guide](./docs/aigateway.md)。
- [2026-03-20] ClawManager 进一步演进为面向 AI Agent 工作空间的控制平面,强化了运行时控制、可复用资源与安全扫描工作流。
> 如果 ClawManager 对你的团队有帮助,欢迎为项目点一个 Star,帮助更多用户和开发者发现它。
<p align="center">
<a href="https://github.com/Yuan-lab-LLM/ClawManager/stargazers">
<img src="https://raw.githubusercontent.com/Yuan-lab-LLM/ClawManager-Assets/main/gif/clawmanager-star.gif" alt="Star ClawManager on GitHub" width="100%" />
</a>
</p>
## 社区交流
欢迎加入 ClawManager 开源社区,可通过微信群或 Discord 获取产品更新、交流使用经验,并与贡献者一起讨论共建。
<table align="center">
<tr>
<td align="center" width="320" valign="top">
<img src="./docs/main/clawmanager_group_chat.jpg" alt="ClawManager 微信群二维码" height="300" />
<br /><br />
<strong>微信群</strong>
<br />
扫描二维码加入微信群
</td>
<td align="center" width="320" valign="top">
<img src="./docs/main/clawmanager_discord.jpg" alt="ClawManager Discord 邀请二维码" height="300" />
<br /><br />
<strong>Discord</strong>
<br />
<a href="https://discord.gg/9RwgbGJD5R">扫描二维码加入 Discord 服务器</a>
</td>
</tr>
</table>
<a id="product-tour"></a>
## 产品介绍
ClawManager 将 AI Agent 实例的运行、治理与运维能力带到 Kubernetes,并在运行时基础之上叠加三层更高阶的控制平面。团队可以用它治理 AI 访问、通过 Agent 编排运行时行为,并通过可扫描、可复用的 channel 与 skill 资源交付工作空间能力。
它适合以下场景:
- 面向多用户运行 AI Agent 实例的平台团队
- 需要运行时可观测性、命令下发与期望态控制的运维团队
- 希望以可复用资源而不是手工配置方式交付 Agent 工作空间的开发团队
<a id="team-workspaces"></a>
## Team 工作空间
Team 工作空间提供简化的 OpenClaw Lite 协作流程:选择角色模板、创建 Team,然后在团队群聊中描述目标即可。Leader 会负责制定计划、协调成员、收集交付并输出最终结果。
- 固定为 Leader 中介协作,无需逐个配置成员运行时或资源预设
- 内置交付、产品探索和软件工程等成员模板
- 团队群聊展示计划、派发、进度、验收、交付和最终汇总
- Execution Kanban 展示总任务状态及当前成员交付
参见 [Team 协作快速指南](./docs/team-workspaces-guide.md),了解创建、协作阶段和查看交付结果的流程。
<a id="runtime-integrations"></a>
## Runtime 接入
ClawManager 当前支持以下受管 Runtime
- <img src="frontend/public/openclaw.png" alt="OpenClaw icon" width="18" /> `OpenClaw`ClawManager 默认支持的 OpenClaw 风格桌面工作空间 Runtime
- <img src="frontend/public/hermes.png" alt="Hermes icon" width="18" /> `Hermes`:基于 Webtop 的 Runtime 接入,带有持久化 `.hermes` 工作空间和内置 Hermes agent
Runtime 预览:
**<img src="frontend/public/openclaw.png" alt="OpenClaw icon" width="18" /> OpenClaw**
![openclaw](./docs/images/openclaw.png)
**<img src="frontend/public/hermes.png" alt="Hermes icon" width="18" /> Hermes**
![hermes](./docs/images/hermes.png)
Runtime 开发方可以参考 [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md)、[通用 Runtime Agent 接入指南](./docs/runtime-agent-integration-guide.md) 与 [Skill Content MD5 规范](./docs/skill-content-md5-spec.md) 实现兼容 agent。
<a id="get-started"></a>
## 快速开始
ClawManager 现在将 Kubernetes 发行版与存储 profile 拆开。先选择 `k3s``k8s`,再选择匹配集群形态的存储 profile:
- k3s 单节点 HostPath: [deployments/k3s/single-node/clawmanager.yaml](./deployments/k3s/single-node/clawmanager.yaml)
- k3s 多节点 CSI/RWX 示例: [deployments/k3s/cluster/clawmanager.yaml](./deployments/k3s/cluster/clawmanager.yaml)
- Kubernetes 单节点 HostPath: [deployments/k8s/single-node/clawmanager.yaml](./deployments/k8s/single-node/clawmanager.yaml)
- Kubernetes 多节点 CSI/RWX 示例: [deployments/k8s/cluster/clawmanager.yaml](./deployments/k8s/cluster/clawmanager.yaml)
- 首次登录与操作流程: [用户指南](./docs/use_guide_cn.md)
- 部署说明与架构背景: [Deployment Guide(英文)](./docs/deployment.md)
多节点 cluster profile 使用 Longhorn 作为官方验证示例,其中 `longhorn` 用于 RWO 数据卷,`longhorn-rwx` 用于 RWX workspace。项目不绑定 Longhorn,用户可以替换为具备相同访问模式能力的 CSI StorageClass。
## 三大控制平面
<a id="ai-gateway"></a>
### AI Gateway
AI Gateway 是 ClawManager 中负责模型访问治理的控制平面。它为受管 Agent Runtime 提供统一的 OpenAI 兼容入口,同时在上游模型服务之上叠加策略、审计与成本控制能力。
- 统一的模型访问入口
- 安全模型路由与策略驱动的模型选择
- 端到端审计与追踪记录
- 内建成本核算与使用分析
- 可阻断或改道路由的风险控制规则
参见 [AI Gateway Guide(英文)](./docs/aigateway.md)。
<a id="agent-control-plane"></a>
### Agent Control Plane
Agent Control Plane 是受管 AI Agent 实例的运行时编排层。它让每一个实例都成为可注册、可汇报状态、可接收命令,并持续对齐平台期望态的受管运行时。
- 基于安全引导与会话生命周期的 Agent 注册
- 依靠心跳机制进行运行时状态与健康上报
- 控制平面与实例之间的期望态同步
- 支持启动、停止、配置应用、健康检查与 Skill 操作的命令下发
- 在实例维度查看 Agent 状态、channel、skill 与命令历史
参见 [Agent Control Plane Guide(英文)](./docs/agent-control-plane.md)。
<a id="resource-management"></a>
### 资源管理
资源管理是 AI Agent 工作空间的可复用资产层。团队可以先准备好 channel 和 skill,再通过 bundle 进行组合、注入到实例中,并把安全审查纳入整个交付流程。
- `Channel` 管理,用于工作空间连接与集成模板
- `Skill` 管理,用于可复用能力包
- `Skill Scanner` 工作流,用于风险审查与扫描任务
- 基于 bundle 的资源组合,用于可重复交付
- 通过注入快照追踪实际下发到实例的内容
参见 [Resource Management Guide(英文)](./docs/resource-management.md) 与 [Security / Skill Scanner Guide(英文)](./docs/security-skill-scanner.md)。
## 产品界面
ClawManager 的设计目标,是让管理、访问与 AI 治理体验形成统一的产品界面,而不是分散在多个孤立工具中。
### Lite 模式部署
Lite 模式通过共享 gateway 运行时池创建实例。每个工作空间作为受管 runtime Pod 中的独立 gateway 进程运行,启动更快,并减少专属 CPU、内存、存储和 GPU 配额开销,同时保留工作空间访问、Share Link / Password 访问、channel 与 skill 注入,以及管理端可见性。
![](./docs/main/liteopenclaw.png)
### Pro 模式部署
Pro 模式为每个实例创建专属 desktop runtime,并使用独立 Kubernetes Deployment、Service 和 PVC。适用于需要更强隔离、完整桌面资源、runtime events、实例级 skill 管理和完整桌面管理体验的场景。
![](./docs/main/proopenclaw.png)
### Team 工作空间
Team 工作空间页面把 Leader 桌面、团队群聊、成员表格和调试派发流程集中在同一个操作视图中,用户可以直接观察协作进度、成员反馈和任务结果。
<p align="center">
<img src="./docs/main/team-workspace.png" alt="ClawManager Team 工作空间" width="100%" />
</p>
### 管理控制台
管理控制台将用户、配额、运行时操作、安全控制与平台级策略集中到一起,是团队管理 AI Agent 基础设施的核心工作台。
<p align="center">
<img src="./docs/main/admin.png" alt="ClawManager 管理控制台" width="100%" />
</p>
### Portal 访问
Portal 为用户提供统一的工作空间入口。用户可以通过浏览器访问实例,并查看与控制平面保持一致的运行时状态,而不需要直接面对底层基础设施细节。
<p align="center">
<img src="./docs/main/portal.png" alt="ClawManager Portal 访问" width="100%" />
</p>
### AI Gateway
AI Gateway 将模型访问治理纳入工作空间体验本身,提供审计记录、成本可见性与风险路由能力,让 AI 使用成为平台能力的一部分,而不是零散接入。
<p align="center">
<img src="./docs/main/aigateway.png" alt="ClawManager AI Gateway" width="100%" />
</p>
## 工作方式
1. 管理员先定义治理策略与可复用资源。
2. 用户在 Kubernetes 上创建或进入受管 AI Agent 工作空间。
3. Team 工作空间可以一次编排多个成员 Runtime,并注入 Redis Team Bus 与共享存储配置。
4. Agent 回连控制平面并上报运行时状态。
5. Channel、skill 与 bundle 被编译并应用到实例中。
6. AI 流量通过 AI Gateway 进入上游服务,并附带审计、风险与成本控制。
## 开发者概览
ClawManager 是一个 Kubernetes 原生平台,包含 React 前端、Go 后端、MySQL 状态存储,以及 `skill-scanner` 与对象存储等支撑组件。代码库按产品子系统组织,因此更适合从对应能力的指南切入,再进入代码实现。
- 前端管理界面与用户界面位于 `frontend/`
- 后端服务、handler、repository 与 migration 位于 `backend/`
- 部署资产位于 `deployments/`
- 产品文档与素材位于 `docs/`
参见 [Developer Guide(英文)](./docs/developer-guide.md)。
## 文档
- [用户指南](./docs/use_guide_cn.md)
- [Team 协作快速指南](./docs/team-workspaces-guide.md)
- [Deployment Guide(英文)](./docs/deployment.md)
- [Admin and User Guide(英文)](./docs/admin-user-guide.md)
- [Agent Control Plane Guide(英文)](./docs/agent-control-plane.md)
- [AI Gateway Guide(英文)](./docs/aigateway.md)
- [Security / Skill Scanner Guide(英文)](./docs/security-skill-scanner.md)
- [Resource Management Guide(英文)](./docs/resource-management.md)
- [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md)
- [通用 Runtime Agent 接入指南](./docs/runtime-agent-integration-guide.md)
- [Skill Content MD5 规范](./docs/skill-content-md5-spec.md)
- [Developer Guide(英文)](./docs/developer-guide.md)
## 许可证
本项目基于 MIT License 开源。
## 开源协作
欢迎提交 Issue 与 Pull Request。
## Star History
<a href="https://www.star-history.com/?repos=Yuan-lab-LLM%2FClawManager&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Yuan-lab-LLM/ClawManager&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Yuan-lab-LLM/ClawManager&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Yuan-lab-LLM/ClawManager&type=date&legend=top-left" />
</picture>
</a>
+35
View File
@@ -0,0 +1,35 @@
# Binaries
bin/
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
# Go workspace file
go.work
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment variables
.env
.env.local
# Database
*.db
*.sqlite
+61
View File
@@ -0,0 +1,61 @@
.PHONY: build run test clean docker-build docker-up docker-down migrate
# Variables
APP_NAME=clawreef
BINARY=bin/server
MAIN_FILE=cmd/server/main.go
# Build the application
build:
go build -o $(BINARY) $(MAIN_FILE)
# Run the application
run:
go run $(MAIN_FILE)
# Run tests
test:
go test -v ./...
# Clean build artifacts
clean:
rm -rf bin/
go clean
# Download dependencies
deps:
go mod download
go mod tidy
# Format code
fmt:
go fmt ./...
# Lint code
lint:
golangci-lint run
# Docker commands
docker-build:
docker build -t $(APP_NAME):latest -f deployments/docker/Dockerfile .
docker-up:
docker-compose -f deployments/docker/docker-compose.yml up -d
docker-down:
docker-compose -f deployments/docker/docker-compose.yml down
# Database migration (requires mysql client)
migrate:
for file in internal/db/migrations/*.sql; do mysql -h localhost -u clawreef -pclawreef123 clawreef < $$file; done
# Install tools
install-tools:
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
# Development setup
dev-setup: deps
@echo "Development environment setup complete"
@echo "Run 'make docker-up' to start MySQL"
@echo "Run 'make migrate' to initialize database"
@echo "Run 'make run' to start the server"
+85
View File
@@ -0,0 +1,85 @@
# ClawReef Backend
ClawReef virtual desktop management platform backend API.
## Tech Stack
- Golang 1.21+
- Gin 1.9+
- upper/db 4.x
- MySQL 8.0+
- JWT Authentication
## Quick Start
### Prerequisites
- Go 1.21 or higher
- MySQL 8.0+
- Docker (optional)
### Development Setup
1. **Install dependencies**
```bash
make deps
```
2. **Start MySQL with Docker**
```bash
make docker-up
```
3. **Run database migration**
```bash
make migrate
```
4. **Start the server**
```bash
make run
```
### API Endpoints
Server runs on port **9001**.
#### Authentication
- `POST /api/v1/auth/register` - User registration
- `POST /api/v1/auth/login` - User login
- `POST /api/v1/auth/refresh` - Refresh token
- `POST /api/v1/auth/logout` - User logout
- `GET /api/v1/auth/me` - Get current user
### Default Admin Account
- Username: `admin`
- Password: `admin123`
## Project Structure
```
backend/
├── cmd/server/ # Application entry point
├── internal/
│ ├── config/ # Configuration
│ ├── db/ # Database connection & migrations
│ ├── models/ # Data models
│ ├── repository/ # Data access layer
│ ├── services/ # Business logic
│ ├── handlers/ # HTTP handlers
│ ├── middleware/ # HTTP middleware
│ └── utils/ # Utilities
├── deployments/ # Docker & K8s configs
└── configs/ # Configuration files
```
## Make Commands
- `make build` - Build the binary
- `make run` - Run the server
- `make test` - Run tests
- `make fmt` - Format code
- `make lint` - Run linter
- `make docker-up` - Start MySQL container
- `make migrate` - Run database migrations
+48
View File
@@ -0,0 +1,48 @@
//go:build ignore
// +build ignore
package main
import (
"fmt"
"os"
"clawreef/internal/config"
"clawreef/internal/db"
"clawreef/internal/repository"
)
func main() {
cfg, err := config.Load()
if err != nil {
fmt.Printf("Failed to load config: %v\n", err)
os.Exit(1)
}
database, err := db.Initialize(cfg.Database)
if err != nil {
fmt.Printf("Failed to init DB: %v\n", err)
os.Exit(1)
}
defer db.Close()
instanceRepo := repository.NewInstanceRepository(database)
// Get all instances
instances, err := instanceRepo.GetByUserID(2, 0, 100)
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
fmt.Println("Current instances:")
for _, inst := range instances {
fmt.Printf("ID=%d, Name=%s, Status=%s\n", inst.ID, inst.Name, inst.Status)
if inst.Status == "creating" {
fmt.Printf(" -> Deleting failed instance %d\n", inst.ID)
instanceRepo.Delete(inst.ID)
}
}
fmt.Println("\nCleanup complete!")
}
+44
View File
@@ -0,0 +1,44 @@
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
"golang.org/x/crypto/bcrypt"
)
func main() {
// 生成正确的密码哈希
password := "admin123"
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Password: %s\n", password)
fmt.Printf("Hash: %s\n", string(hash))
// 验证
err = bcrypt.CompareHashAndPassword(hash, []byte(password))
fmt.Printf("Verify result: %v\n", err == nil)
// 更新数据库
dsn := "root:123456@tcp(localhost:13306)/clawreef"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
_, err = db.Exec("UPDATE users SET password_hash = ? WHERE username = 'admin'", string(hash))
if err != nil {
log.Fatal("Failed to update password:", err)
}
fmt.Println("Password updated successfully!")
fmt.Println("\nYou can now login with:")
fmt.Println(" Username: admin")
fmt.Println(" Password: admin123")
}
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"bufio"
"database/sql"
"fmt"
"log"
"os"
"path/filepath"
"strings"
_ "github.com/go-sql-driver/mysql"
)
func main() {
// 连接MySQL(不指定数据库)
dsn := "root:123456@tcp(localhost:13306)/"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal("Failed to connect to MySQL:", err)
}
defer db.Close()
// 创建数据库
_, err = db.Exec("CREATE DATABASE IF NOT EXISTS clawreef CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
if err != nil {
log.Fatal("Failed to create database:", err)
}
log.Println("Database 'clawreef' created successfully")
// 连接到新创建的数据库
db.Close()
dsn = "root:123456@tcp(localhost:13306)/clawreef?multiStatements=true"
db, err = sql.Open("mysql", dsn)
if err != nil {
log.Fatal("Failed to connect to clawreef database:", err)
}
defer db.Close()
migrationFiles, err := filepath.Glob("internal/db/migrations/*.sql")
if err != nil {
log.Fatal("Failed to list migrations:", err)
}
for _, migrationFile := range migrationFiles {
sqlBytes, readErr := os.ReadFile(migrationFile)
if readErr != nil {
log.Fatal("Failed to read SQL file:", readErr)
}
scanner := bufio.NewScanner(strings.NewReader(string(sqlBytes)))
var statement strings.Builder
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(strings.TrimSpace(line), "--") || strings.TrimSpace(line) == "" {
continue
}
statement.WriteString(line)
statement.WriteString("\n")
if strings.HasSuffix(strings.TrimSpace(line), ";") {
sql := statement.String()
_, err = db.Exec(sql)
if err != nil {
log.Printf("Failed to execute statement from %s: %v", migrationFile, err)
log.Printf("Statement: %s", sql)
}
statement.Reset()
}
}
}
log.Println("Database schema initialized successfully")
fmt.Println("Admin user created: username='admin', password='admin123'")
}
+620
View File
@@ -0,0 +1,620 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
"clawreef/internal/aigateway"
"clawreef/internal/config"
"clawreef/internal/db"
"clawreef/internal/handlers"
"clawreef/internal/middleware"
"clawreef/internal/models"
"clawreef/internal/repository"
"clawreef/internal/services"
"clawreef/internal/services/k8s"
"clawreef/internal/services/leader"
"github.com/gin-gonic/gin"
)
func main() {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// Initialize database
database, err := db.Initialize(cfg.Database)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer db.Close()
// Initialize Kubernetes client
log.Printf("K8s StorageClass config: %s", cfg.GetStorageClass())
if err := k8s.Initialize(cfg); err != nil {
log.Printf("Warning: Failed to initialize Kubernetes client: %v", err)
log.Println("Instance management features will not work without K8s connectivity")
} else {
client := k8s.GetClient()
log.Printf("Kubernetes client initialized successfully (mode: %s, storageClass: %s)",
client.GetConnectionMode(), client.StorageClass)
}
// Initialize repositories
userRepo := repository.NewUserRepository(database)
quotaRepo := repository.NewQuotaRepository(database)
instanceRepo := repository.NewInstanceRepository(database)
systemImageSettingRepo := repository.NewSystemImageSettingRepository(database)
llmModelRepo := repository.NewLLMModelRepository(database)
modelInvocationRepo := repository.NewModelInvocationRepository(database)
auditEventRepo := repository.NewAuditEventRepository(database)
costRecordRepo := repository.NewCostRecordRepository(database)
chatSessionRepo := repository.NewChatSessionRepository(database)
chatMessageRepo := repository.NewChatMessageRepository(database)
riskRuleRepo := repository.NewRiskRuleRepository(database)
riskHitRepo := repository.NewRiskHitRepository(database)
openClawConfigRepo := repository.NewOpenClawConfigRepository(database)
instanceAgentRepo := repository.NewInstanceAgentRepository(database)
instanceRuntimeStatusRepo := repository.NewInstanceRuntimeStatusRepository(database)
instanceDesiredStateRepo := repository.NewInstanceDesiredStateRepository(database)
instanceCommandRepo := repository.NewInstanceCommandRepository(database)
instanceConfigRevisionRepo := repository.NewInstanceConfigRevisionRepository(database)
runtimePodRepo := repository.NewRuntimePodRepository(database)
bindingRepo := repository.NewInstanceRuntimeBindingRepository(database)
rolloutRepo := repository.NewRuntimeRolloutRepository(database)
workspaceFileAuditRepo := repository.NewWorkspaceFileAuditRepository(database)
teamRepo := repository.NewTeamRepository(database)
skillRepo := repository.NewSkillRepository(database)
securityScanRepo := repository.NewSecurityScanRepository(database)
instanceExternalAccessRepo := repository.NewInstanceExternalAccessRepository(database)
if repaired, repairErr := services.RepairSeededAdminPassword(userRepo); repairErr != nil {
log.Printf("Warning: failed to repair seeded admin password: %v", repairErr)
} else if repaired {
log.Printf("Repaired seeded admin password hash for default admin account")
}
// Initialize services
authService := services.NewAuthService(userRepo, cfg.JWT)
quotaService := services.NewQuotaService(quotaRepo)
userService := services.NewUserService(userRepo, quotaRepo)
systemImageSettingService := services.NewSystemImageSettingService(systemImageSettingRepo)
llmModelService := services.NewLLMModelService(llmModelRepo)
modelInvocationService := services.NewModelInvocationService(modelInvocationRepo)
auditEventService := services.NewAuditEventService(auditEventRepo)
costRecordService := services.NewCostRecordService(costRecordRepo)
chatSessionService := services.NewChatSessionService(chatSessionRepo)
chatMessageService := services.NewChatMessageService(chatMessageRepo)
riskDetectionService := services.NewRiskDetectionService(riskRuleRepo)
riskHitService := services.NewRiskHitService(riskHitRepo)
riskRuleService := services.NewRiskRuleService(riskRuleRepo)
openClawConfigService := services.NewOpenClawConfigService(openClawConfigRepo, skillRepo)
objectStorageService, err := services.NewObjectStorageService(cfg.ObjectStorage)
if err != nil {
log.Fatalf("Failed to initialize object storage: %v", err)
}
skillScannerClient := services.NewSkillScannerClient(cfg.SkillScanner)
aiObservabilityService := services.NewAIObservabilityService(modelInvocationRepo, auditEventRepo, costRecordRepo, riskHitRepo, chatMessageRepo, llmModelRepo, instanceRepo, userRepo)
clusterResourceService := services.NewClusterResourceService(instanceRepo)
services.SetRuntimeImageSettingsProvider(systemImageSettingService)
services.SetOpenClawTransferRuntimeRepositories(instanceRepo, bindingRepo, runtimePodRepo)
runtimeAgentClient := services.NewRuntimeAgentClient(cfg.Runtime.AgentControlToken)
instanceService := services.NewInstanceService(
instanceRepo,
quotaRepo,
llmModelRepo,
openClawConfigService,
services.WithPrivilegedInstancePods(cfg.Kubernetes.Runtime.Pod.Privileged),
services.WithV2RuntimeLifecycle(runtimePodRepo, bindingRepo, runtimeAgentClient, cfg.Runtime.WorkspaceRoot),
)
instanceAgentService := services.NewInstanceAgentService(instanceRepo, instanceAgentRepo, instanceDesiredStateRepo, instanceRuntimeStatusRepo, instanceCommandRepo)
instanceRuntimeStatusService := services.NewInstanceRuntimeStatusService(instanceRuntimeStatusRepo, instanceAgentRepo, instanceDesiredStateRepo)
instanceCommandService := services.NewInstanceCommandService(instanceCommandRepo, instanceRuntimeStatusRepo, instanceDesiredStateRepo, skillRepo)
instanceConfigRevisionService := services.NewInstanceConfigRevisionService(instanceConfigRevisionRepo)
teamService := services.NewTeamService(
teamRepo,
instanceService,
services.WithTeamRuntimeWorkspaceRoot(cfg.Runtime.WorkspaceRoot),
services.WithTeamOpenClawConfigService(openClawConfigService),
)
var platformRedis services.PlatformRedisClient
if redisURL := strings.TrimSpace(cfg.Runtime.RedisURL); redisURL != "" {
var redisErr error
platformRedis, redisErr = services.NewPlatformRedisClient(redisURL)
if redisErr != nil {
log.Printf("platform redis disabled: %v", redisErr)
}
} else {
log.Printf("platform redis disabled: redis url is empty")
}
runtimeEvents := services.NewRuntimeEventService(platformRedis)
workspaceFileService := services.NewWorkspaceFileService(workspaceFileAuditRepo)
runtimeWorkspaceFileService := services.NewRuntimeWorkspaceFileService(workspaceFileAuditRepo)
skillService := services.NewSkillService(skillRepo, instanceRepo, instanceCommandService, objectStorageService, skillScannerClient)
securityScanService := services.NewSecurityScanService(securityScanRepo, skillRepo, objectStorageService, skillScannerClient)
externalAccessService := services.NewInstanceExternalAccessService(instanceExternalAccessRepo)
aiGatewayService := aigateway.NewService(llmModelRepo, modelInvocationService, auditEventService, costRecordService, riskDetectionService, riskHitService, chatSessionService, chatMessageService)
// Initialize handlers
authHandler := handlers.NewAuthHandler(authService)
userHandler := handlers.NewUserHandler(userService, quotaService)
instanceHandler := handlers.NewInstanceHandler(
instanceService,
instanceAgentService,
instanceRuntimeStatusService,
instanceCommandService,
instanceConfigRevisionService,
openClawConfigService,
skillService,
externalAccessService,
services.WithInstanceProxyRuntimeRepositories(instanceRepo, runtimePodRepo, bindingRepo),
)
systemSettingsHandler := handlers.NewSystemSettingsHandler(systemImageSettingService)
llmModelHandler := handlers.NewLLMModelHandler(llmModelService)
aiGatewayHandler := handlers.NewAIGatewayHandler(aiGatewayService)
aiObservabilityHandler := handlers.NewAIObservabilityHandler(aiObservabilityService)
riskRuleHandler := handlers.NewRiskRuleHandler(riskRuleService)
clusterResourceHandler := handlers.NewClusterResourceHandler(clusterResourceService)
egressProxyHandler := handlers.NewEgressProxyHandler()
openClawConfigHandler := handlers.NewOpenClawConfigHandler(openClawConfigService)
skillHandler := handlers.NewSkillHandler(skillService, instanceService)
securityHandler := handlers.NewSecurityHandler(securityScanService)
agentHandler := handlers.NewAgentHandler(instanceAgentService, instanceCommandService, instanceRuntimeStatusService, instanceConfigRevisionService, skillService)
teamHandler := handlers.NewTeamHandler(teamService)
workspaceFileHandler := handlers.NewWorkspaceFileHandler(instanceService, workspaceFileService, runtimeWorkspaceFileService)
workspaceFileHandler.SetSkillRepository(skillRepo)
runtimeAgentHandler := handlers.NewRuntimeAgentHandler(cfg.Runtime, runtimePodRepo, bindingRepo, instanceRepo, runtimeEvents)
// Initialize WebSocket hub and handler
wsHub := services.GetHub()
wsHandler := handlers.NewWebSocketHandler(wsHub)
var runtimeAdminEventBridgeCancel context.CancelFunc
if platformRedis != nil {
var bridgeCtx context.Context
bridgeCtx, runtimeAdminEventBridgeCancel = context.WithCancel(context.Background())
services.StartRuntimeAdminEventBridge(bridgeCtx, runtimeEvents, wsHub)
}
// Control-plane singleton background loops. The HTTP API and the in-pod
// nginx desktop data plane run on every replica, but these loops must run
// on exactly one replica. With leader election enabled they only run on the
// elected leader and migrate on failover; with it disabled (single-replica
// deployments) they run directly.
syncService := services.NewSyncService(instanceRepo, instanceRuntimeStatusService)
var runtimeSchedulerCancel context.CancelFunc
var runtimeSchedulerMu sync.Mutex
var runtimeScheduler *services.RuntimeScheduler
if cfg.Runtime.SchedulerEnabled {
k8sClient := k8s.GetClient()
if k8sClient == nil || k8sClient.Clientset == nil {
log.Printf("runtime scheduler disabled: k8s client is unavailable")
} else {
runtimeLeader := services.NewRuntimeLeaderService(k8sClient.Clientset, cfg.Runtime.Namespace, cfg.Runtime.BackendReplicaID)
runtimeDeployments := k8s.NewRuntimeDeploymentService(k8sClient.Clientset)
runtimeSchedulerOptions := []services.RuntimeSchedulerOption{
services.WithRuntimeSchedulerWorkspaceRoot(cfg.Runtime.WorkspaceRoot),
services.WithRuntimeSchedulerNamespace(cfg.Runtime.Namespace),
services.WithRuntimeSchedulerGatewayPortRange(cfg.Runtime.GatewayPortStart, cfg.Runtime.GatewayPortEnd),
services.WithRuntimeSchedulerHeartbeatTimeout(cfg.Runtime.HeartbeatTimeout),
services.WithRuntimeSchedulerMaxGatewaysPerPod(cfg.Runtime.MaxGatewaysPerPod),
}
if gatewayEnvProvider, ok := instanceService.(interface {
BuildGatewayEnv(*models.Instance) (map[string]string, error)
}); ok {
runtimeSchedulerOptions = append(runtimeSchedulerOptions, services.WithRuntimeSchedulerGatewayEnvBuilder(gatewayEnvProvider.BuildGatewayEnv))
}
runtimeScheduler = services.NewRuntimeScheduler(
instanceRepo,
runtimePodRepo,
bindingRepo,
rolloutRepo,
runtimeAgentClient,
runtimeEvents,
runtimeLeader,
runtimeDeployments,
cfg.Runtime.SchedulerTick,
runtimeSchedulerOptions...,
)
log.Printf("runtime scheduler initialized")
}
} else {
log.Printf("runtime scheduler disabled by configuration")
}
runtimePoolHandler := handlers.NewRuntimePoolHandler(runtimePodRepo, bindingRepo, rolloutRepo, runtimeScheduler, runtimeEvents)
leaderCtx, leaderCancel := context.WithCancel(context.Background())
defer leaderCancel()
startBackground := func(ctx context.Context) {
log.Printf("Starting leader-only background loops (identity=%s)", cfg.LeaderElection.Identity)
syncService.Start()
teamService.StartBackground(ctx)
if runtimeScheduler != nil {
runtimeSchedulerMu.Lock()
if runtimeSchedulerCancel == nil {
var schedulerCtx context.Context
schedulerCtx, runtimeSchedulerCancel = context.WithCancel(ctx)
runtimeScheduler.Start(schedulerCtx)
log.Printf("runtime scheduler started")
}
runtimeSchedulerMu.Unlock()
}
}
stopBackground := func() {
log.Printf("Stopping leader-only background loops (identity=%s)", cfg.LeaderElection.Identity)
runtimeSchedulerMu.Lock()
if runtimeSchedulerCancel != nil {
runtimeSchedulerCancel()
runtimeSchedulerCancel = nil
}
runtimeSchedulerMu.Unlock()
teamService.StopBackground()
syncService.Stop()
}
if cfg.LeaderElection.Enabled && k8s.GetClient() != nil && k8s.GetClient().Clientset != nil {
go leader.Run(leaderCtx, k8s.GetClient().Clientset, leader.Config{
Namespace: cfg.LeaderElection.Namespace,
LeaseName: cfg.LeaderElection.LeaseName,
Identity: cfg.LeaderElection.Identity,
LeaseDuration: time.Duration(cfg.LeaderElection.LeaseDuration) * time.Second,
RenewDeadline: time.Duration(cfg.LeaderElection.RenewDeadline) * time.Second,
RetryPeriod: time.Duration(cfg.LeaderElection.RetryPeriod) * time.Second,
}, leader.Callbacks{
OnStartedLeading: startBackground,
OnStoppedLeading: stopBackground,
})
} else {
log.Println("Leader election disabled or K8s unavailable; running control-plane background loops directly")
startBackground(leaderCtx)
}
// Setup router
r := gin.Default()
// Middleware
r.Use(middleware.CORS())
r.Use(middleware.ErrorHandler())
r.NoRoute(egressProxyHandler.Handle)
r.NoMethod(egressProxyHandler.Handle)
// Routes
r.Any("/s/:code", instanceHandler.OpenShortExternalAccess)
r.Any("/s/:code/*path", instanceHandler.OpenShortExternalAccess)
api := r.Group("/api/v1")
{
runtimeAgent := api.Group("/runtime-agent")
{
runtimeAgent.POST("/register", runtimeAgentHandler.Register)
runtimeAgent.POST("/heartbeat", runtimeAgentHandler.Heartbeat)
runtimeAgent.POST("/metrics/report", runtimeAgentHandler.ReportMetrics)
runtimeAgent.POST("/gateways/report", runtimeAgentHandler.ReportGateways)
runtimeAgent.POST("/skills/report", runtimeAgentHandler.ReportSkills)
}
// Auth routes
auth := api.Group("/auth")
{
auth.POST("/register", authHandler.Register)
auth.POST("/login", authHandler.Login)
auth.POST("/refresh", authHandler.RefreshToken)
auth.POST("/logout", authHandler.Logout)
auth.GET("/me", middleware.Auth(), middleware.SetUserInfo(userRepo), authHandler.GetCurrentUser)
auth.POST("/change-password", middleware.Auth(), authHandler.ChangePassword)
}
// User routes (authenticated)
users := api.Group("/users")
users.Use(middleware.Auth())
users.Use(middleware.SetUserInfo(userRepo))
{
// Admin only routes
adminOnly := users.Group("")
adminOnly.Use(middleware.NewAdminAuth(userRepo))
{
adminOnly.GET("", userHandler.ListUsers)
adminOnly.POST("", userHandler.CreateUser)
adminOnly.POST("/import", userHandler.ImportUsers)
adminOnly.DELETE("/:id", userHandler.DeleteUser)
adminOnly.PUT("/:id/role", userHandler.UpdateRole)
adminOnly.PUT("/:id/quota", userHandler.UpdateUserQuota)
}
// User or admin routes
users.GET("/:id", userHandler.GetUser)
users.PUT("/:id", userHandler.UpdateUser)
users.GET("/:id/quota", userHandler.GetUserQuota)
}
// Instance routes (authenticated)
instances := api.Group("/instances")
instances.Use(middleware.Auth())
instances.Use(middleware.SetUserInfo(userRepo))
{
instances.GET("", instanceHandler.ListInstances)
instances.POST("", instanceHandler.CreateInstance)
instances.POST("/batch/lite", instanceHandler.BatchCreateLiteInstances)
instances.POST("/batch/delete", instanceHandler.BatchDeleteLiteInstances)
instances.GET("/:id", instanceHandler.GetInstance)
instances.PUT("/:id", instanceHandler.UpdateInstance)
instances.DELETE("/:id", instanceHandler.DeleteInstance)
instances.POST("/:id/start", instanceHandler.StartInstance)
instances.POST("/:id/stop", instanceHandler.StopInstance)
instances.POST("/:id/restart", instanceHandler.RestartInstance)
instances.GET("/:id/status", instanceHandler.GetInstanceStatus)
instances.GET("/:id/runtime", instanceHandler.GetRuntimeDetails)
instances.POST("/:id/runtime/:command", instanceHandler.CreateRuntimeCommand)
instances.GET("/:id/config/revisions", instanceHandler.ListConfigRevisions)
instances.POST("/:id/config/revisions/publish", instanceHandler.PublishConfigRevision)
instances.POST("/:id/access", instanceHandler.GenerateAccessToken)
instances.GET("/:id/access", instanceHandler.AccessInstance)
instances.GET("/:id/shell", instanceHandler.StreamShell)
instances.POST("/:id/sync", instanceHandler.ForceSync)
instances.GET("/:id/openclaw/export", instanceHandler.ExportOpenClaw)
instances.POST("/:id/openclaw/import", instanceHandler.ImportOpenClaw)
instances.GET("/:id/hermes/export", instanceHandler.ExportHermes)
instances.POST("/:id/hermes/import", instanceHandler.ImportHermes)
instances.GET("/:id/external-access", instanceHandler.GetExternalAccess)
instances.POST("/:id/external-access/share-link", instanceHandler.EnableShareLink)
instances.POST("/:id/external-access/password", instanceHandler.CreateExternalAccessPassword)
instances.DELETE("/:id/external-access", instanceHandler.DisableExternalAccess)
instances.GET("/:id/workspace/files", workspaceFileHandler.List)
instances.GET("/:id/workspace/preview", workspaceFileHandler.Preview)
instances.GET("/:id/workspace/download", workspaceFileHandler.Download)
instances.POST("/:id/workspace/upload", workspaceFileHandler.Upload)
instances.POST("/:id/workspace/folders", workspaceFileHandler.Mkdir)
instances.PATCH("/:id/workspace/entries", workspaceFileHandler.Rename)
instances.DELETE("/:id/workspace/entries", workspaceFileHandler.Delete)
instances.GET("/:id/skills", skillHandler.ListInstanceSkills)
instances.GET("/:id/skills/available", skillHandler.ListAvailableInstanceSkills)
instances.POST("/:id/skills", skillHandler.AttachSkillToInstance)
instances.DELETE("/:id/skills/:skillId", skillHandler.RemoveSkillFromInstance)
}
// Admin console: cross-user instance listing. Gated by admin
// middleware — non-admin callers get 403. The workspace
// /instances endpoint above stays caller-scoped regardless of
// role; admin status only unlocks this dedicated surface.
adminInstances := api.Group("/admin/instances")
adminInstances.Use(middleware.Auth())
adminInstances.Use(middleware.SetUserInfo(userRepo))
adminInstances.Use(middleware.NewAdminAuth(userRepo))
{
adminInstances.GET("", instanceHandler.ListAllInstances)
}
adminRuntime := api.Group("/admin")
adminRuntime.Use(middleware.Auth())
adminRuntime.Use(middleware.SetUserInfo(userRepo))
adminRuntime.Use(middleware.NewAdminAuth(userRepo))
{
adminRuntime.GET("/runtime-pods", runtimePoolHandler.ListPods)
adminRuntime.GET("/runtime-pods/:id/gateways", runtimePoolHandler.GetPodGateways)
adminRuntime.POST("/runtime-pods/:id/drain", runtimePoolHandler.DrainPod)
adminRuntime.POST("/runtime-rollouts", runtimePoolHandler.StartRollout)
}
teams := api.Group("/teams")
teams.Use(middleware.Auth())
teams.Use(middleware.SetUserInfo(userRepo))
{
teams.GET("", teamHandler.ListTeams)
teams.POST("", teamHandler.CreateTeam)
teams.GET("/:id", teamHandler.GetTeam)
teams.DELETE("/:id", teamHandler.DeleteTeam)
teams.GET("/:id/tasks", teamHandler.ListTasks)
teams.POST("/:id/tasks", teamHandler.DispatchTask)
teams.GET("/:id/events", teamHandler.ListEvents)
teams.GET("/:id/workspace/files", teamHandler.ListWorkspaceFiles)
teams.GET("/:id/workspace/preview", teamHandler.PreviewWorkspaceFile)
teams.GET("/:id/workspace/download", teamHandler.DownloadWorkspaceFile)
teams.POST("/:id/workspace/folders", teamHandler.CreateWorkspaceFolder)
teams.POST("/:id/workspace/rename", teamHandler.RenameWorkspaceEntry)
teams.POST("/:id/workspace/upload", teamHandler.UploadWorkspaceFiles)
teams.DELETE("/:id/workspace/files", teamHandler.DeleteWorkspaceEntry)
teams.DELETE("/:id/members/:memberID", teamHandler.DeleteMember)
}
openClawConfigs := api.Group("/openclaw-configs")
openClawConfigs.Use(middleware.Auth())
openClawConfigs.Use(middleware.SetUserInfo(userRepo))
{
openClawConfigs.GET("/resources", openClawConfigHandler.ListResources)
openClawConfigs.POST("/resources", openClawConfigHandler.CreateResource)
openClawConfigs.POST("/resources/validate", openClawConfigHandler.ValidateResource)
openClawConfigs.GET("/resources/:id", openClawConfigHandler.GetResource)
openClawConfigs.PUT("/resources/:id", openClawConfigHandler.UpdateResource)
openClawConfigs.DELETE("/resources/:id", openClawConfigHandler.DeleteResource)
openClawConfigs.POST("/resources/:id/clone", openClawConfigHandler.CloneResource)
openClawConfigs.GET("/bundles", openClawConfigHandler.ListBundles)
openClawConfigs.POST("/bundles", openClawConfigHandler.CreateBundle)
openClawConfigs.GET("/bundles/:id", openClawConfigHandler.GetBundle)
openClawConfigs.PUT("/bundles/:id", openClawConfigHandler.UpdateBundle)
openClawConfigs.DELETE("/bundles/:id", openClawConfigHandler.DeleteBundle)
openClawConfigs.POST("/bundles/:id/clone", openClawConfigHandler.CloneBundle)
openClawConfigs.POST("/compile-preview", openClawConfigHandler.CompilePreview)
openClawConfigs.GET("/injections", openClawConfigHandler.ListSnapshots)
openClawConfigs.GET("/injections/:id", openClawConfigHandler.GetSnapshot)
}
skills := api.Group("/skills")
skills.Use(middleware.Auth())
skills.Use(middleware.SetUserInfo(userRepo))
{
skills.GET("", skillHandler.ListSkills)
skills.POST("/import", skillHandler.ImportSkills)
skills.GET("/:id", skillHandler.GetSkill)
skills.PUT("/:id", skillHandler.UpdateSkill)
skills.DELETE("/:id", skillHandler.DeleteSkill)
skills.GET("/:id/download", skillHandler.DownloadSkill)
skills.GET("/:id/versions", skillHandler.ListVersions)
skills.GET("/:id/scan-results", skillHandler.ListScanResults)
}
systemSettings := api.Group("/system-settings")
systemSettings.Use(middleware.Auth())
systemSettings.Use(middleware.SetUserInfo(userRepo))
{
systemSettings.GET("/images", systemSettingsHandler.ListSystemImageSettings)
}
adminSystemSettings := api.Group("/system-settings")
adminSystemSettings.Use(middleware.Auth())
adminSystemSettings.Use(middleware.SetUserInfo(userRepo))
adminSystemSettings.Use(middleware.NewAdminAuth(userRepo))
{
adminSystemSettings.PUT("/images", systemSettingsHandler.UpsertSystemImageSetting)
adminSystemSettings.DELETE("/images/:instanceType", systemSettingsHandler.DeleteSystemImageSetting)
adminSystemSettings.GET("/cluster-resources", clusterResourceHandler.GetOverview)
}
adminModels := api.Group("/admin/models")
adminModels.Use(middleware.Auth())
adminModels.Use(middleware.SetUserInfo(userRepo))
adminModels.Use(middleware.NewAdminAuth(userRepo))
{
adminModels.GET("", llmModelHandler.ListModels)
adminModels.POST("/discover", llmModelHandler.DiscoverModels)
adminModels.PUT("", llmModelHandler.UpsertModel)
adminModels.DELETE("/:id", llmModelHandler.DeleteModel)
}
adminAIAudit := api.Group("/admin/ai-audit")
adminAIAudit.Use(middleware.Auth())
adminAIAudit.Use(middleware.SetUserInfo(userRepo))
adminAIAudit.Use(middleware.NewAdminAuth(userRepo))
{
adminAIAudit.GET("", aiObservabilityHandler.ListAuditItems)
adminAIAudit.GET("/:traceId", aiObservabilityHandler.GetTraceDetail)
}
adminCosts := api.Group("/admin/costs")
adminCosts.Use(middleware.Auth())
adminCosts.Use(middleware.SetUserInfo(userRepo))
adminCosts.Use(middleware.NewAdminAuth(userRepo))
{
adminCosts.GET("", aiObservabilityHandler.GetCostOverview)
}
adminRiskRules := api.Group("/admin/risk-rules")
adminRiskRules.Use(middleware.Auth())
adminRiskRules.Use(middleware.SetUserInfo(userRepo))
adminRiskRules.Use(middleware.NewAdminAuth(userRepo))
{
adminRiskRules.GET("", riskRuleHandler.ListRules)
adminRiskRules.POST("/test", riskRuleHandler.TestRules)
adminRiskRules.POST("/bulk-status", riskRuleHandler.BulkUpdateStatus)
adminRiskRules.PUT("", riskRuleHandler.UpsertRule)
adminRiskRules.DELETE("/:ruleId", riskRuleHandler.DeleteRule)
}
adminSkills := api.Group("/admin/skills")
adminSkills.Use(middleware.Auth())
adminSkills.Use(middleware.SetUserInfo(userRepo))
adminSkills.Use(middleware.NewAdminAuth(userRepo))
{
adminSkills.GET("", skillHandler.ListAllSkills)
}
adminSecurity := api.Group("/admin/security")
adminSecurity.Use(middleware.Auth())
adminSecurity.Use(middleware.SetUserInfo(userRepo))
adminSecurity.Use(middleware.NewAdminAuth(userRepo))
{
adminSecurity.GET("/config", securityHandler.GetConfig)
adminSecurity.PUT("/config", securityHandler.SaveConfig)
adminSecurity.POST("/scan-jobs", securityHandler.StartScan)
adminSecurity.POST("/skills/:id/rescan", securityHandler.RescanSkill)
adminSecurity.GET("/scan-jobs", securityHandler.ListJobs)
adminSecurity.GET("/scan-jobs/:id", securityHandler.GetJob)
}
gatewayLLM := api.Group("/gateway/llm")
gatewayLLM.Use(middleware.GatewayAuth(instanceRepo, bindingRepo))
{
gatewayLLM.GET("/models", aiGatewayHandler.ListModels)
gatewayLLM.POST("/chat/completions", aiGatewayHandler.ChatCompletions)
}
agent := api.Group("/agent")
{
agent.POST("/register", agentHandler.Register)
agent.POST("/heartbeat", agentHandler.Heartbeat)
agent.GET("/commands/next", agentHandler.NextCommand)
agent.POST("/commands/:id/start", agentHandler.StartCommand)
agent.POST("/commands/:id/finish", agentHandler.FinishCommand)
agent.POST("/state/report", agentHandler.ReportState)
agent.POST("/skills/inventory", agentHandler.ReportSkillInventory)
agent.POST("/skills/upload", agentHandler.UploadSkillPackage)
agent.GET("/skills/versions/:skillVersion/download", skillHandler.DownloadSkillVersionForAgent)
agent.GET("/config/revisions/:id", agentHandler.GetConfigRevision)
}
// Instance proxy routes (token-based auth, no session required)
// These routes proxy requests to the actual instance pods
api.Any("/instances/:id/proxy", instanceHandler.ProxyInstance)
api.Any("/instances/:id/proxy/*path", instanceHandler.ProxyInstance)
// WebSocket routes
ws := api.Group("/ws")
ws.Use(middleware.Auth())
ws.Use(middleware.SetUserInfo(userRepo))
{
ws.GET("", wsHandler.HandleWebSocket)
ws.GET("/stats", wsHandler.GetConnectionCount)
}
}
// Start server with graceful shutdown
srv := &http.Server{
Addr: cfg.Server.Address,
Handler: r,
}
go func() {
log.Printf("Server starting on %s", cfg.Server.Address)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Failed to start server: %v", err)
}
}()
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
sig := <-quit
log.Printf("Received signal %v, shutting down gracefully...", sig)
// Give active requests up to 10 seconds to finish
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("HTTP server forced to shutdown: %v", err)
}
// Stop background services. Cancelling leaderCtx releases the lease (and,
// if we were leader, triggers stopBackground); the explicit stopBackground
// call is idempotent and covers the leader-election-disabled path.
leaderCancel()
stopBackground()
if runtimeAdminEventBridgeCancel != nil {
runtimeAdminEventBridgeCancel()
}
wsHub.Stop()
instanceHandler.Shutdown()
log.Println("Server exited cleanly")
}
+15
View File
@@ -0,0 +1,15 @@
server:
address: ":9001"
mode: "debug"
database:
host: "localhost"
port: 13306
user: "root"
password: "123456"
database: "clawreef"
jwt:
secret: "clawreef-dev-secret-key-change-in-production"
access_expiry: 60
refresh_expiry: 168
+128
View File
@@ -0,0 +1,128 @@
# ClawReef Kubernetes 配置文件
# 用于配置 K8s 连接方式和相关参数
kubernetes:
# 连接模式: auto | incluster | outofcluster
# auto: 自动检测,优先尝试 in-cluster,失败则使用 kubeconfig(推荐)
# incluster: 强制使用 in-cluster 配置(适用于 Pod 内运行)
# outofcluster: 强制使用 kubeconfig(适用于本地开发)
mode: "auto"
# Out-of-cluster 配置(当 mode 为 outofcluster 或 auto 回退时使用)
outOfCluster:
# kubeconfig 文件路径
# 空字符串时自动查找顺序:
# 1. 环境变量 KUBECONFIG
# 2. 环境变量 K8S_KUBECONFIG
# 3. ~/.kube/config
kubeconfig: ""
# 当前上下文(空字符串使用 kubeconfig 中的 current-context
context: ""
# API Server 地址(可选,覆盖 kubeconfig 中的配置)
# 示例: https://192.168.1.100:6443
apiServer: ""
# TLS 配置(可选)
tls:
# 是否跳过 TLS 验证(仅开发环境使用)
insecureSkipVerify: false
# CA 证书路径(可选)
caFile: ""
# 客户端证书路径(可选)
certFile: ""
# 客户端私钥路径(可选)
keyFile: ""
# In-cluster 配置(当 mode 为 incluster 时使用,通常无需修改)
inCluster:
# Service Account Token 路径(通常无需修改)
tokenPath: "/var/run/secrets/kubernetes.io/serviceaccount/token"
# CA 证书路径(通常无需修改)
caPath: "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
# Namespace 文件路径(通常无需修改)
namespacePath: "/var/run/secrets/kubernetes.io/serviceaccount/namespace"
# 通用配置
common:
# 默认命名空间前缀
# 实际命名空间会加上用户ID: {namespace}-user-{userID}
namespace: "clawreef"
# 默认 StorageClass
# 空字符串使用集群默认 StorageClass
storageClass: "external-default-sc"
# 请求超时时间(秒)
timeout: 30
# 连接重试次数
retryCount: 3
# 是否创建命名空间(如果不存在)
autoCreateNamespace: true
# 运行时配置
runtime:
# Pod 配置
pod:
# 默认镜像仓库
imageRegistry: "docker.io/clawreef"
# 默认容器端口
containerPort: 8080
# 默认挂载路径
mountPath: "/home/user/data"
# 是否启用特权模式(某些桌面环境需要)
privileged: false
# 额外的标签(会添加到所有 Pod
extraLabels: {}
# env: production
# team: devops
# 节点选择器
nodeSelector: {}
# kubernetes.io/os: linux
# 容忍配置
tolerations: []
# - key: "dedicated"
# operator: "Equal"
# value: "desktop"
# effect: "NoSchedule"
# PVC 配置
pvc:
# 访问模式: ReadWriteOnce | ReadWriteMany | ReadOnlyMany
accessMode: "ReadWriteOnce"
# 存储卷模式: Filesystem | Block
volumeMode: "Filesystem"
# 是否启用卷扩展
allowVolumeExpansion: true
# 保留策略: Retain | Delete | Recycle
reclaimPolicy: "Delete"
# HostPath 前缀(用于手动创建 PV 时的主机路径)
# 注意:不要使用 /tmp/ 等临时目录,节点重启可能导致数据丢失
# 环境变量覆盖: K8S_PV_HOST_PATH_PREFIX
hostPathPrefix: "/data/clawreef"
# 日志配置
logging:
# 日志级别: debug | info | warn | error
level: "info"
# 是否记录 K8s API 调用详情
logApiCalls: false
+33
View File
@@ -0,0 +1,33 @@
# Build stage
FROM golang:1.21-alpine AS builder
WORKDIR /app
# Install dependencies
RUN apk add --no-cache git
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o bin/server cmd/server/main.go
# Final stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
# Copy the binary from builder
COPY --from=builder /app/bin/server .
# Expose port
EXPOSE 8080
# Run the binary
CMD ["./server"]
@@ -0,0 +1,9 @@
FROM scratch
WORKDIR /
COPY bin/server /server
EXPOSE 9001
ENTRYPOINT ["/server"]
@@ -0,0 +1,47 @@
version: '3.8'
services:
mysql:
image: mysql:8.0
container_name: clawreef-mysql
environment:
MYSQL_ROOT_PASSWORD: root123
MYSQL_DATABASE: clawreef
MYSQL_USER: clawreef
MYSQL_PASSWORD: clawreef123
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
- ../internal/db/migrations:/docker-entrypoint-initdb.d
command: --default-authentication-plugin=mysql_native_password
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 20s
retries: 10
backend:
build:
context: ../..
dockerfile: deployments/docker/Dockerfile
container_name: clawreef-backend
environment:
SERVER_ADDRESS: ":9001"
SERVER_MODE: "debug"
DB_HOST: "mysql"
DB_PORT: "3306"
DB_USER: "clawreef"
DB_PASSWORD: "clawreef123"
DB_NAME: "clawreef"
JWT_SECRET: "clawreef-dev-secret-key"
CLAWMANAGER_WORKSPACE_ARCHIVE_MAX_MIB: "500"
ports:
- "9001:9001"
depends_on:
mysql:
condition: service_healthy
volumes:
- ../../configs:/app/configs
volumes:
mysql_data:
@@ -0,0 +1,654 @@
apiVersion: v1
kind: Namespace
metadata:
name: clawreef-system
---
apiVersion: v1
kind: Secret
metadata:
name: clawreef-secrets
namespace: clawreef-system
type: Opaque
stringData:
mysql-root-password: root123
mysql-password: clawreef123
jwt-secret: clawreef-dev-secret-key-change-in-production
---
apiVersion: v1
kind: ConfigMap
metadata:
name: clawreef-mysql-init
namespace: clawreef-system
data:
001_init_schema.sql: |
CREATE DATABASE IF NOT EXISTS clawreef CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE clawreef;
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(320) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role ENUM('admin', 'user') DEFAULT 'user',
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_login TIMESTAMP,
INDEX idx_username (username),
INDEX idx_role (role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS instances (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop', 'hermes') DEFAULT 'ubuntu',
runtime_type ENUM('desktop', 'shell') NOT NULL DEFAULT 'desktop',
status ENUM('creating', 'running', 'stopped', 'error', 'deleting') DEFAULT 'creating',
cpu_cores DECIMAL(10,2) NOT NULL,
memory_gb INT NOT NULL,
disk_gb INT NOT NULL,
gpu_enabled BOOLEAN DEFAULT FALSE,
gpu_type VARCHAR(100),
gpu_count INT DEFAULT 0,
os_type VARCHAR(50) NOT NULL,
os_version VARCHAR(50) NOT NULL,
image_registry VARCHAR(255),
image_tag VARCHAR(100),
storage_class VARCHAR(50) DEFAULT 'standard',
mount_path VARCHAR(255) DEFAULT '/data',
pod_name VARCHAR(255),
pod_namespace VARCHAR(255),
pod_ip VARCHAR(45),
access_url VARCHAR(500),
access_token VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
started_at TIMESTAMP,
stopped_at TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_id (user_id),
INDEX idx_status (status),
INDEX idx_type (type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS persistent_volumes (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NOT NULL,
pvc_name VARCHAR(255) UNIQUE NOT NULL,
pvc_namespace VARCHAR(255) NOT NULL,
storage_size_gb INT NOT NULL,
storage_class VARCHAR(50),
mount_path VARCHAR(255),
status ENUM('pending', 'bound', 'released', 'failed') DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE,
INDEX idx_instance_id (instance_id),
UNIQUE KEY uk_pvc_name_namespace (pvc_name, pvc_namespace)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS backups (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NOT NULL,
backup_name VARCHAR(255) NOT NULL,
backup_size_gb INT,
backup_path VARCHAR(500),
status ENUM('creating', 'completed', 'failed', 'deleted') DEFAULT 'creating',
backup_type ENUM('manual', 'scheduled') DEFAULT 'manual',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
expires_at TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE,
INDEX idx_instance_id (instance_id),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS backup_schedules (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NOT NULL,
schedule_name VARCHAR(255),
cron_expression VARCHAR(100) NOT NULL,
retention_days INT DEFAULT 30,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE,
INDEX idx_instance_id (instance_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS user_quotas (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL UNIQUE,
max_instances INT DEFAULT 10,
max_cpu_cores DECIMAL(10,2) DEFAULT 40,
max_memory_gb INT DEFAULT 100,
max_storage_gb INT DEFAULT 500,
max_gpu_count INT DEFAULT 2,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS instance_usage (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NOT NULL,
cpu_usage_percent DECIMAL(5,2),
memory_usage_gb DECIMAL(10,2),
disk_usage_gb DECIMAL(10,2),
gpu_usage_percent DECIMAL(5,2),
uptime_seconds INT,
recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE,
INDEX idx_instance_recorded (instance_id, recorded_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS audit_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT,
action VARCHAR(100) NOT NULL,
resource_type VARCHAR(50) NOT NULL,
resource_id INT,
details JSON,
ip_address VARCHAR(45),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_user_id (user_id),
INDEX idx_action (action),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO users (username, email, password_hash, role, is_active)
SELECT 'admin', 'admin@clawreef.local', '$2a$10$pbenze514mwv3pvQySQBVOsF5J4DBXL2kVo1hLa8JFhQu5x3AKvBi', 'admin', TRUE
WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = 'admin');
INSERT INTO user_quotas (user_id, max_instances, max_cpu_cores, max_memory_gb, max_storage_gb, max_gpu_count)
SELECT id, 100, 200, 1000, 5000, 10 FROM users
WHERE username = 'admin'
AND NOT EXISTS (SELECT 1 FROM user_quotas WHERE user_id = users.id);
002_add_webtop_instance_type.sql: |
USE clawreef;
ALTER TABLE instances
MODIFY COLUMN type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop', 'hermes') DEFAULT 'ubuntu';
003_add_system_image_settings.sql: |
USE clawreef;
CREATE TABLE IF NOT EXISTS system_image_settings (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_type VARCHAR(50) NOT NULL,
runtime_type ENUM('desktop', 'shell') NOT NULL DEFAULT 'desktop',
display_name VARCHAR(255) NOT NULL,
image VARCHAR(500) NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_instance_type (instance_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
004_fix_seeded_admin_password.sql: |
USE clawreef;
UPDATE users
SET password_hash = '$2a$10$pbenze514mwv3pvQySQBVOsF5J4DBXL2kVo1hLa8JFhQu5x3AKvBi'
WHERE username = 'admin'
AND password_hash = '$2a$10$N9qo8uLOickgx2ZMRZoMy.MqrzL9wGC3qD3Q.ZHqQH6t3q7l1L5uG';
005_update_openclaw_default_image.sql: |
USE clawreef;
UPDATE system_image_settings
SET image = 'ghcr.io/yuan-lab-llm/agentsruntime/openclaw:latest'
WHERE instance_type = 'openclaw'
AND image IN (
'ericpearlee/openclaw:v2026.3.24',
'ghcr.io/yuan-lab-llm/clawmanager-openclaw-image/openclaw:latest'
);
006_add_openclaw_config_center.sql: |
USE clawreef;
SET @openclaw_snapshot_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'instances'
AND COLUMN_NAME = 'openclaw_config_snapshot_id'
);
SET @openclaw_snapshot_column_sql = IF(
@openclaw_snapshot_column_exists = 0,
'ALTER TABLE instances ADD COLUMN openclaw_config_snapshot_id INT NULL AFTER access_token',
'SELECT 1'
);
PREPARE openclaw_snapshot_column_stmt FROM @openclaw_snapshot_column_sql;
EXECUTE openclaw_snapshot_column_stmt;
DEALLOCATE PREPARE openclaw_snapshot_column_stmt;
CREATE TABLE IF NOT EXISTS openclaw_config_resources (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
resource_type VARCHAR(50) NOT NULL,
resource_key VARCHAR(100) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
version INT NOT NULL DEFAULT 1,
tags_json LONGTEXT NOT NULL,
content_json LONGTEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY uk_openclaw_resource_key (user_id, resource_type, resource_key),
INDEX idx_openclaw_resource_user_type (user_id, resource_type),
INDEX idx_openclaw_resource_user_enabled (user_id, enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS openclaw_config_bundles (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
version INT NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_openclaw_bundle_user (user_id),
INDEX idx_openclaw_bundle_user_enabled (user_id, enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS openclaw_config_bundle_items (
id INT AUTO_INCREMENT PRIMARY KEY,
bundle_id INT NOT NULL,
resource_id INT NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
required BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (bundle_id) REFERENCES openclaw_config_bundles(id) ON DELETE CASCADE,
FOREIGN KEY (resource_id) REFERENCES openclaw_config_resources(id) ON DELETE CASCADE,
UNIQUE KEY uk_openclaw_bundle_resource (bundle_id, resource_id),
INDEX idx_openclaw_bundle_item_bundle (bundle_id, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS openclaw_injection_snapshots (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NULL,
user_id INT NOT NULL,
mode VARCHAR(20) NOT NULL,
bundle_id INT NULL,
selected_resource_ids_json LONGTEXT NOT NULL,
resolved_resources_json LONGTEXT NOT NULL,
rendered_manifest_json LONGTEXT NOT NULL,
rendered_env_json LONGTEXT NOT NULL,
secret_name VARCHAR(255) NULL,
status VARCHAR(30) NOT NULL DEFAULT 'pending',
error_message TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
activated_at TIMESTAMP NULL,
INDEX idx_openclaw_snapshot_user_created (user_id, created_at),
INDEX idx_openclaw_snapshot_instance (instance_id),
INDEX idx_openclaw_snapshot_bundle (bundle_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
011_add_hermes_instance_type.sql: |
USE clawreef;
ALTER TABLE instances
MODIFY COLUMN type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop', 'hermes') DEFAULT 'ubuntu';
012_update_agents_runtime_default_images.sql: |
USE clawreef;
UPDATE system_image_settings
SET image = 'ghcr.io/yuan-lab-llm/agentsruntime/openclaw:latest'
WHERE instance_type = 'openclaw'
AND image IN (
'ericpearlee/openclaw:v2026.3.24',
'ghcr.io/yuan-lab-llm/clawmanager-openclaw-image/openclaw:latest'
);
UPDATE system_image_settings
SET image = 'ghcr.io/yuan-lab-llm/agentsruntime/hermes:latest'
WHERE instance_type = 'hermes'
AND image IN (
'registry.example.com/hermes-webtop:latest',
'lscr.io/linuxserver/webtop:ubuntu-xfce'
);
013_normalize_agents_runtime_image_names.sql: |
USE clawreef;
UPDATE system_image_settings
SET image = 'ghcr.io/yuan-lab-llm/agentsruntime/openclaw:latest'
WHERE image = 'ghcr.io/Yuan-lab-LLM/AgentsRuntime/openclaw:latest';
UPDATE system_image_settings
SET image = 'ghcr.io/yuan-lab-llm/agentsruntime/hermes:latest'
WHERE image = 'ghcr.io/Yuan-lab-LLM/AgentsRuntime/hermes:latest';
019_add_instance_runtime_type.sql: |
USE clawreef;
SET @instance_runtime_type_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'instances'
AND COLUMN_NAME = 'runtime_type'
);
SET @instance_runtime_type_column_sql = IF(
@instance_runtime_type_column_exists = 0,
'ALTER TABLE instances ADD COLUMN runtime_type ENUM(''desktop'', ''shell'') NOT NULL DEFAULT ''desktop'' AFTER type',
'SELECT 1'
);
PREPARE instance_runtime_type_column_stmt FROM @instance_runtime_type_column_sql;
EXECUTE instance_runtime_type_column_stmt;
DEALLOCATE PREPARE instance_runtime_type_column_stmt;
020_add_system_image_runtime_type.sql: |
USE clawreef;
SET @system_image_runtime_type_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'system_image_settings'
AND COLUMN_NAME = 'runtime_type'
);
SET @system_image_runtime_type_column_sql = IF(
@system_image_runtime_type_column_exists = 0,
'ALTER TABLE system_image_settings ADD COLUMN runtime_type ENUM(''desktop'', ''shell'') NOT NULL DEFAULT ''desktop'' AFTER instance_type',
'SELECT 1'
);
PREPARE system_image_runtime_type_column_stmt FROM @system_image_runtime_type_column_sql;
EXECUTE system_image_runtime_type_column_stmt;
DEALLOCATE PREPARE system_image_runtime_type_column_stmt;
021_add_openclaw_shell_runtime_image.sql: |
USE clawreef;
SET @system_image_is_enabled_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'system_image_settings'
AND COLUMN_NAME = 'is_enabled'
);
SET @system_image_is_enabled_column_sql = IF(
@system_image_is_enabled_column_exists = 0,
'ALTER TABLE system_image_settings ADD COLUMN is_enabled BOOLEAN NOT NULL DEFAULT TRUE',
'SELECT 1'
);
PREPARE system_image_is_enabled_column_stmt FROM @system_image_is_enabled_column_sql;
EXECUTE system_image_is_enabled_column_stmt;
DEALLOCATE PREPARE system_image_is_enabled_column_stmt;
SET @system_image_unique_instance_type_index_name = (
SELECT INDEX_NAME
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'system_image_settings'
AND COLUMN_NAME = 'instance_type'
AND NON_UNIQUE = 0
AND INDEX_NAME <> 'PRIMARY'
LIMIT 1
);
SET @system_image_unique_instance_type_index_sql = IF(
@system_image_unique_instance_type_index_name IS NOT NULL,
CONCAT(
'ALTER TABLE system_image_settings DROP INDEX `',
REPLACE(@system_image_unique_instance_type_index_name, '`', '``'),
'`'
),
'SELECT 1'
);
PREPARE system_image_unique_instance_type_index_stmt FROM @system_image_unique_instance_type_index_sql;
EXECUTE system_image_unique_instance_type_index_stmt;
DEALLOCATE PREPARE system_image_unique_instance_type_index_stmt;
INSERT INTO system_image_settings (instance_type, runtime_type, display_name, image, is_enabled)
SELECT
'openclaw',
'desktop',
'OpenClaw Desktop',
'ghcr.io/yuan-lab-llm/agentsruntime/openclaw:latest',
TRUE
WHERE NOT EXISTS (
SELECT 1
FROM system_image_settings
WHERE instance_type = 'openclaw'
AND runtime_type = 'desktop'
)
AND NOT EXISTS (
SELECT 1
FROM system_image_settings
WHERE instance_type = 'openclaw'
AND is_enabled = FALSE
);
INSERT INTO system_image_settings (instance_type, runtime_type, display_name, image, is_enabled)
SELECT
'openclaw',
'shell',
'OpenClaw Lite',
'ghcr.io/yuan-lab-llm/agentsruntime/openclaw-lite:latest',
TRUE
WHERE NOT EXISTS (
SELECT 1
FROM system_image_settings
WHERE instance_type = 'openclaw'
AND runtime_type = 'shell'
)
AND NOT EXISTS (
SELECT 1
FROM system_image_settings
WHERE instance_type = 'openclaw'
AND is_enabled = FALSE
);
022_add_openclaw_config_bundle_skills.sql: |
USE clawreef;
CREATE TABLE IF NOT EXISTS openclaw_config_bundle_skills (
id INT AUTO_INCREMENT PRIMARY KEY,
bundle_id INT NOT NULL,
skill_id INT NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
required BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (bundle_id) REFERENCES openclaw_config_bundles(id) ON DELETE CASCADE,
FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE,
UNIQUE KEY uk_openclaw_bundle_skill (bundle_id, skill_id),
INDEX idx_openclaw_bundle_skill_bundle (bundle_id, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
023_normalize_runtime_config_mounts.sql: |
USE clawreef;
UPDATE instances
SET mount_path = '/config',
updated_at = CURRENT_TIMESTAMP
WHERE type IN ('openclaw', 'ubuntu', 'webtop', 'hermes')
AND mount_path IN ('/data', '/home/user/data', '/config/.hermes');
031_add_hermes_lite_runtime_image.sql: |
USE clawreef;
UPDATE system_image_settings
SET
image = 'ghcr.io/yuan-lab-llm/agentsruntime/hermes-lite:latest',
display_name = 'Hermes Lite',
is_enabled = TRUE
WHERE instance_type = 'hermes'
AND runtime_type = 'shell'
AND image IN (
'ghcr.io/yuan-lab-llm/agentsruntime/hermes-shell:latest',
'registry.example.com/your-custom-shell-image:latest'
);
INSERT INTO system_image_settings (instance_type, runtime_type, display_name, image, is_enabled)
SELECT
'hermes',
'shell',
'Hermes Lite',
'ghcr.io/yuan-lab-llm/agentsruntime/hermes-lite:latest',
TRUE
WHERE NOT EXISTS (
SELECT 1
FROM system_image_settings
WHERE instance_type = 'hermes'
AND runtime_type = 'shell'
)
AND NOT EXISTS (
SELECT 1
FROM system_image_settings
WHERE instance_type = 'hermes'
AND is_enabled = FALSE
);
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mysql-data
namespace: clawreef-system
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
storageClassName: local-path
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mysql
namespace: clawreef-system
spec:
replicas: 1
selector:
matchLabels:
app: mysql
template:
metadata:
labels:
app: mysql
spec:
containers:
- name: mysql
image: mysql:8.4
ports:
- containerPort: 3306
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: clawreef-secrets
key: mysql-root-password
- name: MYSQL_DATABASE
value: clawreef
- name: MYSQL_USER
value: clawreef
- name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
name: clawreef-secrets
key: mysql-password
volumeMounts:
- name: mysql-data
mountPath: /var/lib/mysql
- name: mysql-init
mountPath: /docker-entrypoint-initdb.d
readinessProbe:
exec:
command: ["sh", "-c", "mysqladmin ping -h 127.0.0.1 -uroot -p$MYSQL_ROOT_PASSWORD"]
initialDelaySeconds: 20
periodSeconds: 10
volumes:
- name: mysql-data
persistentVolumeClaim:
claimName: mysql-data
- name: mysql-init
configMap:
name: clawreef-mysql-init
---
apiVersion: v1
kind: Service
metadata:
name: mysql
namespace: clawreef-system
spec:
selector:
app: mysql
ports:
- name: mysql
port: 3306
targetPort: 3306
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: clawreef-backend
namespace: clawreef-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: clawreef-backend-cluster-admin
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: cluster-admin
subjects:
- kind: ServiceAccount
name: clawreef-backend
namespace: clawreef-system
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: clawreef-backend
namespace: clawreef-system
spec:
replicas: 1
selector:
matchLabels:
app: clawreef-backend
template:
metadata:
labels:
app: clawreef-backend
spec:
serviceAccountName: clawreef-backend
containers:
- name: backend
image: clawreef-backend:dev
imagePullPolicy: IfNotPresent
ports:
- containerPort: 9001
env:
- name: SERVER_ADDRESS
value: ":9001"
- name: SERVER_MODE
value: "debug"
- name: DB_HOST
value: "host.lima.internal"
- name: DB_PORT
value: "3306"
- name: DB_USER
value: "clawreef"
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: clawreef-secrets
key: mysql-password
- name: DB_NAME
value: "clawreef"
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: clawreef-secrets
key: jwt-secret
- name: K8S_MODE
value: "incluster"
- name: K8S_NAMESPACE
value: "clawreef"
- name: K8S_STORAGE_CLASS
value: "local-path"
- name: CLAWMANAGER_WORKSPACE_ARCHIVE_MAX_MIB
value: "500"
readinessProbe:
tcpSocket:
port: 9001
initialDelaySeconds: 10
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: clawreef-backend
namespace: clawreef-system
spec:
selector:
app: clawreef-backend
ports:
- name: http
port: 9001
targetPort: 9001
+87
View File
@@ -0,0 +1,87 @@
module clawreef
go 1.26.1
require (
github.com/gin-gonic/gin v1.12.0
github.com/go-playground/validator/v10 v10.30.1
github.com/go-sql-driver/mysql v1.9.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/gorilla/websocket v1.5.3
github.com/upper/db/v4 v4.10.0
golang.org/x/crypto v0.49.0
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.32.3
k8s.io/apimachinery v0.32.3
k8s.io/client-go v0.32.3
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/gnostic-models v0.6.8 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minio-go/v7 v7.0.85 // indirect
github.com/moby/spdystream v0.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/segmentio/fasthash v1.0.3 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/oauth2 v0.23.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.7.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
)
+243
View File
@@ -0,0 +1,243 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo=
github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk=
github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.85 h1:9psTLS/NTvC3MWoyjhjXpwcKoNbkongaCSF3PNpSuXo=
github.com/minio/minio-go/v7 v7.0.85/go.mod h1:57YXpvc5l3rjPdhqNrDsvVlY0qPI6UTk1bflAe+9doY=
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/segmentio/fasthash v1.0.3 h1:EI9+KE1EwvMLBWwjpRDc+fEM+prwxDYbslddQGtrmhM=
github.com/segmentio/fasthash v1.0.3/go.mod h1:waKX8l2N8yckOgmSsXJi7x1ZfdKZ4x7KRMzBtS3oedY=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/upper/db/v4 v4.10.0 h1:u5fdqcFZAOwUZWtkS0ueQttecKcSpVF8qmBwZesS9nc=
github.com/upper/db/v4 v4.10.0/go.mod h1:s3qHxKIKvqZNZBG5jrAPufMUXqCBmMdIHa7buGfR+OU=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.32.3 h1:Hw7KqxRusq+6QSplE3NYG4MBxZw1BZnq4aP4cJVINls=
k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k=
k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U=
k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE=
k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU=
k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y=
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4=
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro=
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8=
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo=
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aNqRlpuvjmwA=
sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
File diff suppressed because it is too large Load Diff
+552
View File
@@ -0,0 +1,552 @@
package aigateway
import (
"encoding/json"
"strings"
"testing"
"clawreef/internal/models"
)
type stubModelInvocationService struct {
items []models.ModelInvocation
}
func (s *stubModelInvocationService) RecordInvocation(invocation *models.ModelInvocation) error {
return nil
}
func (s *stubModelInvocationService) GetInvocationByID(id int) (*models.ModelInvocation, error) {
return nil, nil
}
func (s *stubModelInvocationService) ListInvocationsByTraceID(traceID string) ([]models.ModelInvocation, error) {
return s.items, nil
}
func (s *stubModelInvocationService) ListInvocationsBySessionID(sessionID string, limit int) ([]models.ModelInvocation, error) {
return s.items, nil
}
func (s *stubModelInvocationService) ListInvocationsByUserID(userID, limit int) ([]models.ModelInvocation, error) {
return s.items, nil
}
type stubChatSessionService struct {
session *models.ChatSession
}
func (s *stubChatSessionService) GetSession(sessionID string) (*models.ChatSession, error) {
return s.session, nil
}
func (s *stubChatSessionService) EnsureSession(sessionID string, userID, instanceID *int, traceID *string, title *string) (*models.ChatSession, error) {
return s.session, nil
}
func TestBuildProviderRequestPreservesToolConfiguration(t *testing.T) {
req := ChatCompletionRequest{
Model: "gateway-model",
RawBody: []byte(`{"model":"gateway-model","messages":[{"role":"user","content":[{"type":"text","text":"weather in shanghai"}]}],"tools":[{"type":"function","function":{"name":"get_weather","parameters":{"type":"object"}}}],"tool_choice":{"type":"function","function":{"name":"get_weather"}},"stream":true,"stream_options":{"include_usage":false,"custom":"value"},"custom_field":{"nested":[1,2,3]},"session_id":"sess_123","request_id":"req_123","trace_id":"trc_123","instance_id":99,"instance_mode":"lite","runtime_type":"gateway","gateway_id":"gw_123","runtime_pod_id":5}`),
}
model := &models.LLMModel{
ProviderModelName: "provider-model",
}
providerRequestBody, err := buildProviderRequestBody(req, model)
if err != nil {
t.Fatalf("buildProviderRequestBody returned error: %v", err)
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(providerRequestBody, &payload); err != nil {
t.Fatalf("failed to decode provider request body: %v", err)
}
var modelName string
if err := json.Unmarshal(payload["model"], &modelName); err != nil {
t.Fatalf("failed to decode model name: %v", err)
}
if modelName != "provider-model" {
t.Fatalf("expected provider model name to be replaced, got %q", modelName)
}
if _, ok := payload["messages"]; !ok {
t.Fatalf("expected messages to be forwarded")
}
if _, ok := payload["tools"]; !ok {
t.Fatalf("expected tools to be forwarded")
}
if _, ok := payload["tool_choice"]; !ok {
t.Fatalf("expected tool_choice to be forwarded")
}
if _, ok := payload["custom_field"]; !ok {
t.Fatalf("expected unknown provider fields to survive")
}
if _, ok := payload["session_id"]; ok {
t.Fatalf("expected internal session_id to be stripped")
}
if _, ok := payload["request_id"]; ok {
t.Fatalf("expected internal request_id to be stripped")
}
if _, ok := payload["trace_id"]; ok {
t.Fatalf("expected internal trace_id to be stripped")
}
if _, ok := payload["instance_id"]; ok {
t.Fatalf("expected internal instance_id to be stripped")
}
if _, ok := payload["instance_mode"]; ok {
t.Fatalf("expected internal instance_mode to be stripped")
}
if _, ok := payload["runtime_type"]; ok {
t.Fatalf("expected internal runtime_type to be stripped")
}
if _, ok := payload["gateway_id"]; ok {
t.Fatalf("expected internal gateway_id to be stripped")
}
if _, ok := payload["runtime_pod_id"]; ok {
t.Fatalf("expected internal runtime_pod_id to be stripped")
}
if string(payload["stream_options"]) != `{"include_usage":false,"custom":"value"}` {
t.Fatalf("expected stream_options to pass through unchanged, got %s", string(payload["stream_options"]))
}
}
func TestBuildProviderRequestUsesAnthropicProtocolForLocalModel(t *testing.T) {
req := ChatCompletionRequest{
Model: "gateway-model",
Messages: []ChatMessage{
{
Role: "user",
Content: "hello from local anthropic",
},
},
}
model := &models.LLMModel{
ProviderType: models.ProviderTypeLocal,
ProtocolType: models.ProtocolTypeAnthropic,
ProviderModelName: "claude-local",
}
providerRequestBody, err := buildProviderRequestBody(req, model)
if err != nil {
t.Fatalf("buildProviderRequestBody returned error: %v", err)
}
var payload anthropicRequestPayload
if err := json.Unmarshal(providerRequestBody, &payload); err != nil {
t.Fatalf("expected anthropic payload, got decode error: %v", err)
}
if payload.Model != "claude-local" {
t.Fatalf("expected provider model name to be replaced, got %q", payload.Model)
}
if len(payload.Messages) != 1 || payload.Messages[0].Role != "user" {
t.Fatalf("expected anthropic user message, got %#v", payload.Messages)
}
}
func TestRuntimeAttributionHelpersPopulateRecords(t *testing.T) {
mode := "lite"
runtimeType := "gateway"
gatewayID := "gw_instance_1"
runtimePodID := int64(17)
req := ChatCompletionRequest{
InstanceMode: &mode,
RuntimeType: &runtimeType,
GatewayID: &gatewayID,
RuntimePodID: &runtimePodID,
}
invocation := &models.ModelInvocation{}
applyInvocationRuntimeAttribution(invocation, req)
if invocation.InstanceMode == nil || *invocation.InstanceMode != mode {
t.Fatalf("invocation instance mode not populated")
}
if invocation.GatewayID == nil || *invocation.GatewayID != gatewayID {
t.Fatalf("invocation gateway ID not populated")
}
audit := &models.AuditEvent{}
applyAuditRuntimeAttribution(audit, req)
if audit.RuntimeType == nil || *audit.RuntimeType != runtimeType {
t.Fatalf("audit runtime type not populated")
}
if audit.RuntimePodID == nil || *audit.RuntimePodID != runtimePodID {
t.Fatalf("audit runtime pod ID not populated")
}
cost := &models.CostRecord{}
applyCostRuntimeAttribution(cost, req)
if cost.GatewayID == nil || *cost.GatewayID != gatewayID {
t.Fatalf("cost gateway ID not populated")
}
risk := riskHitAttribution(req)
if risk == nil || risk.GatewayID == nil || *risk.GatewayID != gatewayID {
t.Fatalf("risk attribution gateway ID not populated")
}
}
func TestRewriteStreamLineKeepsToolCalls(t *testing.T) {
line := "data: {\"id\":\"chatcmpl-1\",\"model\":\"provider-model\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Shanghai\\\"}\"}}]},\"finish_reason\":null}]}\n"
var assistantText strings.Builder
var promptTokens int
var completionTokens int
var totalTokens int
done := inspectStreamLine(line, &assistantText, &promptTokens, &completionTokens, &totalTokens)
if done {
t.Fatalf("expected tool chunk not to finish the stream")
}
if assistantText.String() != "" {
t.Fatalf("expected tool chunks not to be appended as assistant text, got %q", assistantText.String())
}
payload := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "data:"))
var chunk openAIStreamChunk
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
t.Fatalf("failed to decode chunk: %v", err)
}
if len(chunk.Choices) != 1 || len(chunk.Choices[0].Delta.ToolCalls) != 1 {
t.Fatalf("expected tool_calls to be preserved, got %#v", chunk.Choices)
}
if chunk.Choices[0].Delta.ToolCalls[0].Function == nil || chunk.Choices[0].Delta.ToolCalls[0].Function.Name != "get_weather" {
t.Fatalf("expected function metadata to survive, got %#v", chunk.Choices[0].Delta.ToolCalls[0])
}
}
func TestExtractAssistantContentFallsBackToToolCalls(t *testing.T) {
response := ChatCompletionResponse{
Choices: []struct {
Index int `json:"index"`
Message struct {
Role string `json:"role"`
Content interface{} `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
Refusal interface{} `json:"refusal,omitempty"`
} `json:"message"`
FinishReason string `json:"finish_reason,omitempty"`
}{
{
Index: 0,
Message: struct {
Role string `json:"role"`
Content interface{} `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
Refusal interface{} `json:"refusal,omitempty"`
}{
Role: "assistant",
Content: nil,
ToolCalls: []ToolCall{
{
ID: "call_1",
Type: "function",
Function: &ToolCallFunction{
Name: "get_weather",
Arguments: `{"city":"Shanghai"}`,
},
},
},
},
FinishReason: "tool_calls",
},
},
}
content := extractAssistantContent(response)
if !strings.Contains(content, "get_weather") {
t.Fatalf("expected tool call content to be included, got %q", content)
}
if strings.Contains(content, "null") {
t.Fatalf("expected nil content not to become \"null\", got %q", content)
}
}
func TestResolveTraceIDReusesRecentTraceForToolLoop(t *testing.T) {
toolCallID := "call_weather_123"
svc := &service{
chatSessionService: &stubChatSessionService{
session: &models.ChatSession{
SessionID: "agent:openclaw:main",
LastTraceID: stringPtr("trc_existing"),
},
},
invocationService: &stubModelInvocationService{
items: []models.ModelInvocation{
{
TraceID: "trc_existing",
InstanceID: intPtr(9),
ResponsePayload: stringPtr(`{"choices":[{"message":{"role":"assistant","tool_calls":[{"id":"call_weather_123","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"Beijing\"}"}}]}}]}`),
},
},
},
}
traceID := svc.resolveTraceID(7, ChatCompletionRequest{
InstanceID: intPtr(9),
Messages: []ChatMessage{
{
Role: "tool",
ToolCallID: toolCallID,
Content: `{"temp_c":25}`,
},
},
}, "agent:openclaw:main")
if traceID != "trc_existing" {
t.Fatalf("expected tool continuation to reuse trace trc_existing, got %q", traceID)
}
}
func TestResolveTraceIDReusesSessionLastTraceBeforeInvocationIsPersisted(t *testing.T) {
toolCallID := "call_weather_123"
svc := &service{
chatSessionService: &stubChatSessionService{
session: &models.ChatSession{
SessionID: "agent:openclaw:main",
LastTraceID: stringPtr("trc_existing"),
},
},
invocationService: &stubModelInvocationService{},
}
traceID := svc.resolveTraceID(7, ChatCompletionRequest{
InstanceID: intPtr(9),
Messages: []ChatMessage{
{
Role: "user",
Content: "What is the weather in Beijing now?",
},
{
Role: "assistant",
ToolCalls: []ToolCall{
{
ID: toolCallID,
Function: &ToolCallFunction{
Name: "get_weather",
Arguments: `{"city":"Beijing"}`,
},
},
},
},
{
Role: "tool",
ToolCallID: toolCallID,
Content: `{"temp_c":25}`,
},
},
}, "agent:openclaw:main")
if traceID != "trc_existing" {
t.Fatalf("expected session last trace to be reused before invocation persistence, got %q", traceID)
}
}
func TestResolveTraceIDReusesTraceWhenOpenClawStripsToolCallUnderscore(t *testing.T) {
svc := &service{
invocationService: &stubModelInvocationService{
items: []models.ModelInvocation{
{
TraceID: "trc_existing",
UserID: intPtr(7),
InstanceID: intPtr(19),
ResponsePayload: stringPtr("data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_d1cad4719db94d1594f93456\",\"index\":0,\"type\":\"function\",\"function\":{\"name\":\"exec\",\"arguments\":\"\"}}]}}]}\n\ndata: [DONE]\n"),
},
},
},
}
traceID := svc.resolveTraceID(7, ChatCompletionRequest{
InstanceID: intPtr(19),
Messages: []ChatMessage{
{
Role: "user",
Content: "What is the weather in Changchun?",
},
{
Role: "assistant",
ToolCalls: []ToolCall{
{
ID: "calld1cad4719db94d1594f93456",
Function: &ToolCallFunction{
Name: "exec",
Arguments: `{"command":"curl -s \"wttr.in/Changchun\""}`,
},
},
},
},
{
Role: "tool",
ToolCallID: "calld1cad4719db94d1594f93456",
Content: "changchun: +9C",
},
},
}, "")
if traceID != "trc_existing" {
t.Fatalf("expected trace reuse when tool id separators differ, got %q", traceID)
}
}
func TestResolveTraceIDDoesNotReuseHistoricalToolLoopAcrossNewUserTurn(t *testing.T) {
toolCallID := "call_weather_123"
svc := &service{
chatSessionService: &stubChatSessionService{
session: &models.ChatSession{
SessionID: "agent:openclaw:main",
LastTraceID: stringPtr("trc_existing"),
},
},
invocationService: &stubModelInvocationService{
items: []models.ModelInvocation{
{
TraceID: "trc_existing",
InstanceID: intPtr(9),
ResponsePayload: stringPtr(`{"choices":[{"message":{"role":"assistant","tool_calls":[{"id":"call_weather_123","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"Beijing\"}"}}]}}]}`),
},
},
},
}
traceID := svc.resolveTraceID(7, ChatCompletionRequest{
InstanceID: intPtr(9),
Messages: []ChatMessage{
{
Role: "user",
Content: "What is the weather in Beijing now?",
},
{
Role: "assistant",
ToolCalls: []ToolCall{
{
ID: toolCallID,
Function: &ToolCallFunction{
Name: "get_weather",
Arguments: `{"city":"Beijing"}`,
},
},
},
},
{
Role: "tool",
ToolCallID: toolCallID,
Content: `{"temp_c":25}`,
},
{
Role: "assistant",
Content: "Beijing is cloudy and 25C.",
},
{
Role: "user",
Content: "How about Shanghai?",
},
},
}, "agent:openclaw:main")
if traceID == "trc_existing" {
t.Fatalf("expected a new trace for the next user turn, got %q", traceID)
}
if !strings.HasPrefix(traceID, "trc_") {
t.Fatalf("expected generated trace id to keep trc_ prefix, got %q", traceID)
}
}
func TestBuildPersistedMessagesKeepsOnlyCurrentTurnMessages(t *testing.T) {
items := buildPersistedMessages([]ChatMessage{
{
Role: "system",
Content: "system prompt",
},
{
Role: "user",
Content: "Previous turn question",
},
{
Role: "assistant",
Content: "Previous turn answer",
},
{
Role: "user",
Content: "Current turn question",
},
{
Role: "assistant",
ToolCalls: []ToolCall{
{
ID: "call_current",
Function: &ToolCallFunction{
Name: "get_weather",
Arguments: `{"city":"Shanghai"}`,
},
},
},
},
{
Role: "tool",
ToolCallID: "call_current",
Content: `{"temp_c":16}`,
},
})
if len(items) != 3 {
t.Fatalf("expected only current turn messages to be persisted, got %d", len(items))
}
if items[0].Role != "user" || items[0].Content != "Current turn question" {
t.Fatalf("expected current turn user message first, got %#v", items[0])
}
if items[1].Role != "assistant" || !strings.Contains(items[1].Content, "get_weather") {
t.Fatalf("expected current turn assistant tool call second, got %#v", items[1])
}
if items[2].Role != "tool" || items[2].Content != `{"temp_c":16}` {
t.Fatalf("expected current turn tool output third, got %#v", items[2])
}
if items[0].SequenceNo != 1 || items[1].SequenceNo != 2 || items[2].SequenceNo != 3 {
t.Fatalf("expected current turn sequence numbers to restart at 1, got %#v", items)
}
}
func TestResolveTraceIDKeepsExplicitTraceID(t *testing.T) {
explicit := "trc_explicit"
svc := &service{}
traceID := svc.resolveTraceID(7, ChatCompletionRequest{
TraceID: &explicit,
Messages: []ChatMessage{
{
Role: "user",
Content: "hello",
},
},
}, "")
if traceID != explicit {
t.Fatalf("expected explicit trace id %q, got %q", explicit, traceID)
}
}
func TestResolveSessionIDPrefersExplicitSessionThenOpenAIUser(t *testing.T) {
explicitSession := "agent:main:direct:alice"
if got := resolveSessionID(ChatCompletionRequest{SessionID: &explicitSession}); got != explicitSession {
t.Fatalf("expected explicit session id %q, got %q", explicitSession, got)
}
openAIUser := "agent:main:direct:bob"
if got := resolveSessionID(ChatCompletionRequest{User: &openAIUser}); got != openAIUser {
t.Fatalf("expected OpenAI user to become session id %q, got %q", openAIUser, got)
}
}
func TestNormalizeExistingIdentifierHashesLongOpenClawSessionKeys(t *testing.T) {
longKey := "agent:very-long-openclaw-agent-id:discord:default:direct:12345678901234567890123456789012345678901234567890"
normalized := normalizeExistingIdentifier(longKey, "sess")
if normalized == longKey {
t.Fatalf("expected long session key to be normalized")
}
if len(normalized) > maxStoredIdentifierLength {
t.Fatalf("expected normalized id length <= %d, got %d", maxStoredIdentifierLength, len(normalized))
}
}
+677
View File
@@ -0,0 +1,677 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// Config holds all application configuration
type Config struct {
Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"`
JWT JWTConfig `yaml:"jwt"`
Kubernetes KubernetesConfig `yaml:"kubernetes"`
Storage StorageConfig `yaml:"storage"`
Runtime RuntimePoolConfig `yaml:"runtime"`
ObjectStorage ObjectStorageConfig `yaml:"objectStorage"`
SkillScanner SkillScannerConfig `yaml:"skillScanner"`
LeaderElection LeaderElectionConfig `yaml:"leaderElection"`
}
// LeaderElectionConfig controls the control-plane leader election that gates
// singleton background loops (SyncService + Team event consumers + stale-task
// monitor) so they run on exactly one replica when clawmanager-app is scaled
// horizontally. The HTTP API and the in-pod nginx data plane run on every
// replica regardless.
type LeaderElectionConfig struct {
Enabled bool `yaml:"enabled"`
Namespace string `yaml:"namespace"`
LeaseName string `yaml:"leaseName"`
Identity string `yaml:"identity"`
LeaseDuration int `yaml:"leaseDuration"` // seconds
RenewDeadline int `yaml:"renewDeadline"` // seconds
RetryPeriod int `yaml:"retryPeriod"` // seconds
}
const (
StorageProfileCluster = "cluster"
StorageProfileSingle = "single-node"
StorageProfileLegacyNFS = "legacy-nfs"
DefaultPVCBindTimeout = 2 * time.Minute
)
// ServerConfig holds server-related configuration
type ServerConfig struct {
Address string `yaml:"address"`
Mode string `yaml:"mode"`
}
// DatabaseConfig holds database-related configuration
type DatabaseConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
User string `yaml:"user"`
Password string `yaml:"password"`
Database string `yaml:"database"`
}
// JWTConfig holds JWT-related configuration
type JWTConfig struct {
Secret string `yaml:"secret"`
AccessExpiry int `yaml:"access_expiry"` // minutes
RefreshExpiry int `yaml:"refresh_expiry"` // hours
}
// KubernetesConfig holds Kubernetes-related configuration
type KubernetesConfig struct {
Mode string `yaml:"mode"` // 连接模式: auto, incluster, outofcluster
OutOfCluster OutOfClusterConfig `yaml:"outOfCluster"`
InCluster InClusterConfig `yaml:"inCluster"`
Common CommonKubernetesConfig `yaml:"common"`
Runtime KubernetesRuntimeConfig `yaml:"runtime"`
Logging LoggingConfig `yaml:"logging"`
}
// OutOfClusterConfig holds out-of-cluster Kubernetes configuration
type OutOfClusterConfig struct {
Kubeconfig string `yaml:"kubeconfig"`
Context string `yaml:"context"`
APIServer string `yaml:"apiServer"`
TLS TLSConfig `yaml:"tls"`
}
// TLSConfig holds TLS configuration
type TLSConfig struct {
InsecureSkipVerify bool `yaml:"insecureSkipVerify"`
CAFile string `yaml:"caFile"`
CertFile string `yaml:"certFile"`
KeyFile string `yaml:"keyFile"`
}
// InClusterConfig holds in-cluster Kubernetes configuration
type InClusterConfig struct {
TokenPath string `yaml:"tokenPath"`
CAPath string `yaml:"caPath"`
NamespacePath string `yaml:"namespacePath"`
}
// CommonKubernetesConfig holds common Kubernetes configuration
type CommonKubernetesConfig struct {
Namespace string `yaml:"namespace"`
StorageClass string `yaml:"storageClass"`
Timeout int `yaml:"timeout"`
RetryCount int `yaml:"retryCount"`
AutoCreateNamespace bool `yaml:"autoCreateNamespace"`
}
// KubernetesRuntimeConfig holds Kubernetes runtime configuration
type KubernetesRuntimeConfig struct {
Pod RuntimePodConfig `yaml:"pod"`
PVC RuntimePVCConfig `yaml:"pvc"`
}
// RuntimePodConfig holds pod runtime configuration
type RuntimePodConfig struct {
ImageRegistry string `yaml:"imageRegistry"`
ContainerPort int32 `yaml:"containerPort"`
MountPath string `yaml:"mountPath"`
Privileged bool `yaml:"privileged"`
ExtraLabels map[string]string `yaml:"extraLabels"`
NodeSelector map[string]string `yaml:"nodeSelector"`
Tolerations []Toleration `yaml:"tolerations"`
}
// Toleration represents a Kubernetes toleration
type Toleration struct {
Key string `yaml:"key"`
Operator string `yaml:"operator"`
Value string `yaml:"value"`
Effect string `yaml:"effect"`
}
// RuntimePVCConfig holds PVC runtime configuration
type RuntimePVCConfig struct {
AccessMode string `yaml:"accessMode"`
VolumeMode string `yaml:"volumeMode"`
AllowVolumeExpansion bool `yaml:"allowVolumeExpansion"`
ReclaimPolicy string `yaml:"reclaimPolicy"`
HostPathPrefix string `yaml:"hostPathPrefix"`
}
// StorageConfig controls the validated installation storage profile.
type StorageConfig struct {
Profile string `yaml:"profile"`
HostPathFallbackEnabled bool `yaml:"hostPathFallbackEnabled"`
PVCBindTimeout time.Duration `yaml:"pvcBindTimeout"`
ControlPlaneStorageClass string `yaml:"controlPlaneStorageClass"`
InstanceStorageClass string `yaml:"instanceStorageClass"`
WorkspaceStorageClass string `yaml:"workspaceStorageClass"`
WorkspaceAccessMode string `yaml:"workspaceAccessMode"`
}
// RuntimePoolConfig holds shared V2 runtime pool configuration.
type RuntimePoolConfig struct {
Namespace string `yaml:"namespace"`
WorkspaceRoot string `yaml:"workspaceRoot"`
WorkspacePVCClaimName string `yaml:"workspacePvcClaimName"`
WorkspaceNFSServer string `yaml:"workspaceNfsServer"`
WorkspaceNFSPath string `yaml:"workspaceNfsPath"`
AgentControlToken string `yaml:"agentControlToken"`
AgentReportToken string `yaml:"agentReportToken"`
BackendReplicaID string `yaml:"backendReplicaId"`
RedisURL string `yaml:"redisUrl"`
SchedulerEnabled bool `yaml:"schedulerEnabled"`
HeartbeatTimeout time.Duration `yaml:"heartbeatTimeout"`
SchedulerTick time.Duration `yaml:"schedulerTick"`
OpenClawImage string `yaml:"openClawImage"`
HermesImage string `yaml:"hermesImage"`
MaxGatewaysPerPod int `yaml:"maxGatewaysPerPod"`
GatewayPortStart int `yaml:"gatewayPortStart"`
GatewayPortEnd int `yaml:"gatewayPortEnd"`
}
// LoggingConfig holds logging configuration
type LoggingConfig struct {
Level string `yaml:"level"`
LogAPICalls bool `yaml:"logApiCalls"`
}
type ObjectStorageConfig struct {
Endpoint string `yaml:"endpoint"`
Region string `yaml:"region"`
AccessKey string `yaml:"accessKey"`
SecretKey string `yaml:"secretKey"`
Bucket string `yaml:"bucket"`
UseSSL bool `yaml:"useSSL"`
BasePath string `yaml:"basePath"`
ForcePathStyle bool `yaml:"forcePathStyle"`
LocalFallback string `yaml:"localFallback"`
}
type SkillScannerConfig struct {
BaseURL string `yaml:"baseUrl"`
APIKey string `yaml:"apiKey"`
TimeoutSeconds int `yaml:"timeoutSeconds"`
Enabled bool `yaml:"enabled"`
}
// Load loads configuration from file and environment variables
func Load() (*Config, error) {
runtimeNamespace := getEnv("RUNTIME_NAMESPACE", getEnv("K8S_NAMESPACE", "clawmanager-system"))
defaultStorageClass := getEnv("K8S_STORAGE_CLASS", "standard")
config := &Config{
Server: ServerConfig{
Address: ":9001",
Mode: "debug",
},
Database: DatabaseConfig{
Host: "localhost",
Port: 3306,
User: "clawreef",
Password: "clawreef123",
Database: "clawreef",
},
JWT: JWTConfig{
Secret: getEnv("JWT_SECRET", "clawreef-secret-key-change-in-production"),
AccessExpiry: 60, // 60 minutes
RefreshExpiry: 168, // 7 days
},
Kubernetes: KubernetesConfig{
Mode: getEnv("K8S_MODE", "auto"),
OutOfCluster: OutOfClusterConfig{
Kubeconfig: getEnv("KUBECONFIG", getEnv("K8S_KUBECONFIG", "")),
},
InCluster: InClusterConfig{
TokenPath: "/var/run/secrets/kubernetes.io/serviceaccount/token",
CAPath: "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt",
NamespacePath: "/var/run/secrets/kubernetes.io/serviceaccount/namespace",
},
Common: CommonKubernetesConfig{
Namespace: getEnv("K8S_NAMESPACE", "clawreef"),
StorageClass: defaultStorageClass,
Timeout: 30,
RetryCount: 3,
AutoCreateNamespace: true,
},
Runtime: KubernetesRuntimeConfig{
Pod: RuntimePodConfig{
ImageRegistry: "docker.io/clawreef",
ContainerPort: 3001,
MountPath: "/home/user/data",
Privileged: false,
ExtraLabels: make(map[string]string),
NodeSelector: make(map[string]string),
},
PVC: RuntimePVCConfig{
AccessMode: "ReadWriteOnce",
VolumeMode: "Filesystem",
AllowVolumeExpansion: true,
ReclaimPolicy: "Delete",
HostPathPrefix: getEnv("K8S_PV_HOST_PATH_PREFIX", "/data/clawreef"),
},
},
Logging: LoggingConfig{
Level: "info",
LogAPICalls: false,
},
},
Storage: StorageConfig{
Profile: getEnv("CLAWMANAGER_STORAGE_PROFILE", StorageProfileCluster),
HostPathFallbackEnabled: getEnvBool("K8S_HOSTPATH_FALLBACK_ENABLED", false),
PVCBindTimeout: getEnvDuration("K8S_PVC_BIND_TIMEOUT", DefaultPVCBindTimeout),
ControlPlaneStorageClass: getEnv("K8S_CONTROL_PLANE_STORAGE_CLASS", defaultStorageClass),
InstanceStorageClass: getEnv("K8S_INSTANCE_STORAGE_CLASS", defaultStorageClass),
WorkspaceStorageClass: getEnv("K8S_WORKSPACE_STORAGE_CLASS", defaultStorageClass),
WorkspaceAccessMode: getEnv("K8S_WORKSPACE_ACCESS_MODE", "ReadWriteMany"),
},
Runtime: RuntimePoolConfig{
Namespace: runtimeNamespace,
WorkspaceRoot: getEnv("RUNTIME_WORKSPACE_ROOT", "/workspaces"),
WorkspacePVCClaimName: getEnv("RUNTIME_WORKSPACE_PVC_CLAIM", ""),
WorkspaceNFSServer: getEnv("RUNTIME_WORKSPACE_NFS_SERVER", ""),
WorkspaceNFSPath: getEnv("RUNTIME_WORKSPACE_NFS_PATH", "/"),
AgentControlToken: getEnv("RUNTIME_AGENT_CONTROL_TOKEN", ""),
AgentReportToken: getEnv("RUNTIME_AGENT_REPORT_TOKEN", ""),
BackendReplicaID: getEnv("HOSTNAME", "clawmanager-backend-local"),
RedisURL: getEnv("PLATFORM_REDIS_URL", getEnv("TEAM_REDIS_URL", "")),
SchedulerEnabled: getEnvBool("RUNTIME_SCHEDULER_ENABLED", true),
HeartbeatTimeout: getEnvDuration("RUNTIME_HEARTBEAT_TIMEOUT", 10*time.Second),
SchedulerTick: getEnvDuration("RUNTIME_SCHEDULER_TICK", 2*time.Second),
OpenClawImage: getEnv("OPENCLAW_RUNTIME_IMAGE", "ghcr.io/yuan-lab-llm/agentsruntime/openclaw-lite:latest"),
HermesImage: getEnv("HERMES_RUNTIME_IMAGE", "ghcr.io/yuan-lab-llm/agentsruntime/hermes-lite:latest"),
MaxGatewaysPerPod: getEnvInt("RUNTIME_MAX_GATEWAYS_PER_POD", 100),
GatewayPortStart: getEnvInt("RUNTIME_GATEWAY_PORT_START", 20000),
GatewayPortEnd: getEnvInt("RUNTIME_GATEWAY_PORT_END", 20099),
},
ObjectStorage: ObjectStorageConfig{
Endpoint: getEnv("OBJECT_STORAGE_ENDPOINT", ""),
Region: getEnv("OBJECT_STORAGE_REGION", ""),
AccessKey: getEnv("OBJECT_STORAGE_ACCESS_KEY", ""),
SecretKey: getEnv("OBJECT_STORAGE_SECRET_KEY", ""),
Bucket: getEnv("OBJECT_STORAGE_BUCKET", "clawmanager-skills"),
UseSSL: strings.EqualFold(getEnv("OBJECT_STORAGE_USE_SSL", "false"), "true"),
BasePath: getEnv("OBJECT_STORAGE_BASE_PATH", "skills"),
ForcePathStyle: strings.EqualFold(getEnv("OBJECT_STORAGE_FORCE_PATH_STYLE", "true"), "true"),
LocalFallback: getEnv("OBJECT_STORAGE_LOCAL_FALLBACK", ".data/object-storage"),
},
SkillScanner: SkillScannerConfig{
BaseURL: getEnv("SKILL_SCANNER_BASE_URL", ""),
APIKey: getEnv("SKILL_SCANNER_API_KEY", ""),
TimeoutSeconds: 30,
Enabled: strings.EqualFold(getEnv("SKILL_SCANNER_ENABLED", "false"), "true"),
},
LeaderElection: LeaderElectionConfig{
Enabled: strings.EqualFold(getEnv("CLAWMANAGER_LEADER_ELECTION", "true"), "true"),
Namespace: getEnv("POD_NAMESPACE", "clawmanager-system"),
LeaseName: getEnv("CLAWMANAGER_LEADER_LEASE_NAME", "clawmanager-controlplane-leader"),
Identity: getEnv("POD_NAME", ""),
LeaseDuration: 15,
RenewDeadline: 10,
RetryPeriod: 2,
},
}
// Try to load from k8s config file
configPaths := []string{
"configs/k8s.yaml",
"../configs/k8s.yaml",
"/app/configs/k8s.yaml",
}
for _, path := range configPaths {
if _, err := os.Stat(path); err == nil {
fmt.Printf("Loading k8s config from: %s\n", path)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read k8s config file: %w", err)
}
if err := yaml.Unmarshal(data, config); err != nil {
return nil, fmt.Errorf("failed to parse k8s config file: %w", err)
}
break
}
}
// Try to load from legacy dev.yaml config file
if _, err := os.Stat("configs/dev.yaml"); err == nil {
data, err := os.ReadFile("configs/dev.yaml")
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
if err := yaml.Unmarshal(data, config); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
}
// Override with environment variables
applyEnvOverrides(config)
normalizeStorageConfig(config)
return config, nil
}
// applyEnvOverrides applies environment variable overrides
func applyEnvOverrides(config *Config) {
// Server config
if addr := os.Getenv("SERVER_ADDRESS"); addr != "" {
config.Server.Address = addr
}
if mode := os.Getenv("SERVER_MODE"); mode != "" {
config.Server.Mode = mode
}
// Database config
if host := os.Getenv("DB_HOST"); host != "" {
config.Database.Host = host
}
if port := os.Getenv("DB_PORT"); port != "" {
fmt.Sscanf(port, "%d", &config.Database.Port)
}
if user := os.Getenv("DB_USER"); user != "" {
config.Database.User = user
}
if pass := os.Getenv("DB_PASSWORD"); pass != "" {
config.Database.Password = pass
}
if db := os.Getenv("DB_NAME"); db != "" {
config.Database.Database = db
}
// JWT config
if secret := os.Getenv("JWT_SECRET"); secret != "" {
config.JWT.Secret = secret
}
// Kubernetes config
if mode := os.Getenv("K8S_MODE"); mode != "" {
config.Kubernetes.Mode = mode
}
if kubeconfig := os.Getenv("KUBECONFIG"); kubeconfig != "" {
config.Kubernetes.OutOfCluster.Kubeconfig = kubeconfig
}
if kubeconfig := os.Getenv("K8S_KUBECONFIG"); kubeconfig != "" {
config.Kubernetes.OutOfCluster.Kubeconfig = kubeconfig
}
if namespace := os.Getenv("K8S_NAMESPACE"); namespace != "" {
config.Kubernetes.Common.Namespace = namespace
}
if storageClass := os.Getenv("K8S_STORAGE_CLASS"); storageClass != "" {
config.Kubernetes.Common.StorageClass = storageClass
}
if hostPathPrefix := os.Getenv("K8S_PV_HOST_PATH_PREFIX"); hostPathPrefix != "" {
config.Kubernetes.Runtime.PVC.HostPathPrefix = hostPathPrefix
}
if profile := os.Getenv("CLAWMANAGER_STORAGE_PROFILE"); profile != "" {
config.Storage.Profile = profile
}
if fallback := os.Getenv("K8S_HOSTPATH_FALLBACK_ENABLED"); fallback != "" {
config.Storage.HostPathFallbackEnabled = strings.EqualFold(fallback, "true")
}
if timeout := os.Getenv("K8S_PVC_BIND_TIMEOUT"); timeout != "" {
if parsed, err := time.ParseDuration(timeout); err == nil {
config.Storage.PVCBindTimeout = parsed
}
}
if storageClass := os.Getenv("K8S_CONTROL_PLANE_STORAGE_CLASS"); storageClass != "" {
config.Storage.ControlPlaneStorageClass = storageClass
}
if storageClass := os.Getenv("K8S_INSTANCE_STORAGE_CLASS"); storageClass != "" {
config.Storage.InstanceStorageClass = storageClass
}
if storageClass := os.Getenv("K8S_WORKSPACE_STORAGE_CLASS"); storageClass != "" {
config.Storage.WorkspaceStorageClass = storageClass
}
if accessMode := os.Getenv("K8S_WORKSPACE_ACCESS_MODE"); accessMode != "" {
config.Storage.WorkspaceAccessMode = accessMode
}
config.Runtime.Namespace = getEnv("RUNTIME_NAMESPACE", getEnv("K8S_NAMESPACE", config.Runtime.Namespace))
config.Runtime.WorkspaceRoot = getEnv("RUNTIME_WORKSPACE_ROOT", config.Runtime.WorkspaceRoot)
config.Runtime.WorkspacePVCClaimName = getEnv("RUNTIME_WORKSPACE_PVC_CLAIM", config.Runtime.WorkspacePVCClaimName)
config.Runtime.WorkspaceNFSServer = getEnv("RUNTIME_WORKSPACE_NFS_SERVER", config.Runtime.WorkspaceNFSServer)
config.Runtime.WorkspaceNFSPath = getEnv("RUNTIME_WORKSPACE_NFS_PATH", config.Runtime.WorkspaceNFSPath)
if strings.TrimSpace(config.Runtime.WorkspaceNFSPath) == "" {
config.Runtime.WorkspaceNFSPath = "/"
}
config.Runtime.AgentControlToken = getEnv("RUNTIME_AGENT_CONTROL_TOKEN", config.Runtime.AgentControlToken)
config.Runtime.AgentReportToken = getEnv("RUNTIME_AGENT_REPORT_TOKEN", config.Runtime.AgentReportToken)
config.Runtime.BackendReplicaID = getEnv("HOSTNAME", config.Runtime.BackendReplicaID)
config.Runtime.RedisURL = getEnv("PLATFORM_REDIS_URL", getEnv("TEAM_REDIS_URL", config.Runtime.RedisURL))
config.Runtime.SchedulerEnabled = getEnvBool("RUNTIME_SCHEDULER_ENABLED", config.Runtime.SchedulerEnabled)
config.Runtime.HeartbeatTimeout = getEnvDuration("RUNTIME_HEARTBEAT_TIMEOUT", config.Runtime.HeartbeatTimeout)
config.Runtime.SchedulerTick = getEnvDuration("RUNTIME_SCHEDULER_TICK", config.Runtime.SchedulerTick)
config.Runtime.OpenClawImage = getEnv("OPENCLAW_RUNTIME_IMAGE", config.Runtime.OpenClawImage)
config.Runtime.HermesImage = getEnv("HERMES_RUNTIME_IMAGE", config.Runtime.HermesImage)
config.Runtime.MaxGatewaysPerPod = getEnvInt("RUNTIME_MAX_GATEWAYS_PER_POD", config.Runtime.MaxGatewaysPerPod)
config.Runtime.GatewayPortStart = getEnvInt("RUNTIME_GATEWAY_PORT_START", config.Runtime.GatewayPortStart)
config.Runtime.GatewayPortEnd = getEnvInt("RUNTIME_GATEWAY_PORT_END", config.Runtime.GatewayPortEnd)
config.LeaderElection.Enabled = getEnvBool("CLAWMANAGER_LEADER_ELECTION", config.LeaderElection.Enabled)
config.LeaderElection.Namespace = getEnv("POD_NAMESPACE", config.LeaderElection.Namespace)
config.LeaderElection.LeaseName = getEnv("CLAWMANAGER_LEADER_LEASE_NAME", config.LeaderElection.LeaseName)
config.LeaderElection.Identity = getEnv("POD_NAME", config.LeaderElection.Identity)
config.LeaderElection.LeaseDuration = getEnvInt("CLAWMANAGER_LEADER_LEASE_DURATION", config.LeaderElection.LeaseDuration)
config.LeaderElection.RenewDeadline = getEnvInt("CLAWMANAGER_LEADER_RENEW_DEADLINE", config.LeaderElection.RenewDeadline)
config.LeaderElection.RetryPeriod = getEnvInt("CLAWMANAGER_LEADER_RETRY_PERIOD", config.LeaderElection.RetryPeriod)
if endpoint := os.Getenv("OBJECT_STORAGE_ENDPOINT"); endpoint != "" {
config.ObjectStorage.Endpoint = endpoint
}
if region := os.Getenv("OBJECT_STORAGE_REGION"); region != "" {
config.ObjectStorage.Region = region
}
if accessKey := os.Getenv("OBJECT_STORAGE_ACCESS_KEY"); accessKey != "" {
config.ObjectStorage.AccessKey = accessKey
}
if secretKey := os.Getenv("OBJECT_STORAGE_SECRET_KEY"); secretKey != "" {
config.ObjectStorage.SecretKey = secretKey
}
if bucket := os.Getenv("OBJECT_STORAGE_BUCKET"); bucket != "" {
config.ObjectStorage.Bucket = bucket
}
if useSSL := os.Getenv("OBJECT_STORAGE_USE_SSL"); useSSL != "" {
config.ObjectStorage.UseSSL = strings.EqualFold(useSSL, "true")
}
if basePath := os.Getenv("OBJECT_STORAGE_BASE_PATH"); basePath != "" {
config.ObjectStorage.BasePath = basePath
}
if forcePathStyle := os.Getenv("OBJECT_STORAGE_FORCE_PATH_STYLE"); forcePathStyle != "" {
config.ObjectStorage.ForcePathStyle = strings.EqualFold(forcePathStyle, "true")
}
if localFallback := os.Getenv("OBJECT_STORAGE_LOCAL_FALLBACK"); localFallback != "" {
config.ObjectStorage.LocalFallback = localFallback
}
if baseURL := os.Getenv("SKILL_SCANNER_BASE_URL"); baseURL != "" {
config.SkillScanner.BaseURL = baseURL
}
if apiKey := os.Getenv("SKILL_SCANNER_API_KEY"); apiKey != "" {
config.SkillScanner.APIKey = apiKey
}
if enabled := os.Getenv("SKILL_SCANNER_ENABLED"); enabled != "" {
config.SkillScanner.Enabled = strings.EqualFold(enabled, "true")
}
if timeoutSeconds := os.Getenv("SKILL_SCANNER_TIMEOUT_SECONDS"); timeoutSeconds != "" {
fmt.Sscanf(timeoutSeconds, "%d", &config.SkillScanner.TimeoutSeconds)
}
}
func normalizeStorageConfig(config *Config) {
if config == nil {
return
}
config.Storage.Profile = normalizeStorageProfile(config.Storage.Profile)
if config.Storage.PVCBindTimeout <= 0 {
config.Storage.PVCBindTimeout = DefaultPVCBindTimeout
}
if strings.TrimSpace(config.Kubernetes.Common.StorageClass) == "" {
config.Kubernetes.Common.StorageClass = "standard"
}
if strings.TrimSpace(config.Storage.ControlPlaneStorageClass) == "" {
config.Storage.ControlPlaneStorageClass = config.Kubernetes.Common.StorageClass
}
if strings.TrimSpace(config.Storage.InstanceStorageClass) == "" {
config.Storage.InstanceStorageClass = config.Kubernetes.Common.StorageClass
}
if strings.TrimSpace(config.Storage.WorkspaceStorageClass) == "" {
config.Storage.WorkspaceStorageClass = config.Kubernetes.Common.StorageClass
}
if strings.TrimSpace(config.Storage.WorkspaceAccessMode) == "" {
config.Storage.WorkspaceAccessMode = "ReadWriteMany"
}
if config.Storage.Profile == StorageProfileLegacyNFS && strings.TrimSpace(config.Runtime.WorkspaceNFSServer) == "" {
config.Runtime.WorkspaceNFSServer = defaultWorkspaceNFSServer(config.Runtime.Namespace)
}
if strings.TrimSpace(config.Runtime.WorkspaceNFSPath) == "" {
config.Runtime.WorkspaceNFSPath = "/"
}
}
func normalizeStorageProfile(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "cluster", "cluster-csi", "csi":
return StorageProfileCluster
case "single", "single-node", "single_node", "hostpath":
return StorageProfileSingle
case "legacy", "legacy-nfs", "legacy_nfs", "nfs":
return StorageProfileLegacyNFS
default:
return strings.ToLower(strings.TrimSpace(value))
}
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func defaultWorkspaceNFSServer(namespace string) string {
namespace = strings.TrimSpace(namespace)
if namespace == "" {
namespace = "clawmanager-system"
}
return fmt.Sprintf("workspace-store.%s.svc.cluster.local", namespace)
}
func getEnvInt(key string, defaultValue int) int {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
parsed, err := strconv.Atoi(value)
if err != nil {
return defaultValue
}
return parsed
}
func getEnvBool(key string, defaultValue bool) bool {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return defaultValue
}
return parsed
}
func getEnvDuration(key string, defaultValue time.Duration) time.Duration {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
parsed, err := time.ParseDuration(value)
if err != nil {
return defaultValue
}
return parsed
}
// GetKubeconfigPath returns the kubeconfig path for out-of-cluster mode
func (c *Config) GetKubeconfigPath() string {
return c.Kubernetes.OutOfCluster.Kubeconfig
}
// GetNamespace returns the namespace prefix
func (c *Config) GetNamespace() string {
return c.Kubernetes.Common.Namespace
}
// GetStorageClass returns the storage class
func (c *Config) GetStorageClass() string {
return c.Kubernetes.Common.StorageClass
}
func (c *Config) GetStorageProfile() string {
if c == nil {
return StorageProfileCluster
}
return normalizeStorageProfile(c.Storage.Profile)
}
func (c *Config) GetPVCBindTimeout() time.Duration {
if c == nil || c.Storage.PVCBindTimeout <= 0 {
return DefaultPVCBindTimeout
}
return c.Storage.PVCBindTimeout
}
func (c *Config) GetControlPlaneStorageClass() string {
if c == nil {
return "standard"
}
if value := strings.TrimSpace(c.Storage.ControlPlaneStorageClass); value != "" {
return value
}
return c.GetStorageClass()
}
func (c *Config) GetInstanceStorageClass() string {
if c == nil {
return "standard"
}
if value := strings.TrimSpace(c.Storage.InstanceStorageClass); value != "" {
return value
}
return c.GetStorageClass()
}
func (c *Config) GetWorkspaceStorageClass() string {
if c == nil {
return "standard"
}
if value := strings.TrimSpace(c.Storage.WorkspaceStorageClass); value != "" {
return value
}
return c.GetStorageClass()
}
func (c *Config) GetWorkspaceAccessMode() string {
if c == nil {
return "ReadWriteMany"
}
if value := strings.TrimSpace(c.Storage.WorkspaceAccessMode); value != "" {
return value
}
return "ReadWriteMany"
}
// GetHostPathPrefix returns the host path prefix for PV creation
func (c *Config) GetHostPathPrefix() string {
if c.Kubernetes.Runtime.PVC.HostPathPrefix != "" {
return c.Kubernetes.Runtime.PVC.HostPathPrefix
}
return "/data/clawreef"
}
// GetMode returns the K8s connection mode
func (c *Config) GetMode() string {
return c.Kubernetes.Mode
}
+120
View File
@@ -0,0 +1,120 @@
package config
import (
"testing"
"time"
)
func TestLoadRuntimeDefaults(t *testing.T) {
for _, key := range []string{
"HERMES_RUNTIME_IMAGE",
"OPENCLAW_RUNTIME_IMAGE",
"RUNTIME_NAMESPACE",
"K8S_NAMESPACE",
"HOSTNAME",
"PLATFORM_REDIS_URL",
"TEAM_REDIS_URL",
"RUNTIME_WORKSPACE_NFS_SERVER",
} {
t.Setenv(key, "")
}
cfg, err := Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if got, want := cfg.Runtime.HermesImage, "ghcr.io/yuan-lab-llm/agentsruntime/hermes-lite:latest"; got != want {
t.Fatalf("expected Hermes default image %q, got %q", want, got)
}
if got, want := cfg.Runtime.OpenClawImage, "ghcr.io/yuan-lab-llm/agentsruntime/openclaw-lite:latest"; got != want {
t.Fatalf("expected OpenClaw default image %q, got %q", want, got)
}
}
func TestLoadStorageProfileDefaultsDisableHostPathFallback(t *testing.T) {
for _, key := range []string{
"CLAWMANAGER_STORAGE_PROFILE",
"K8S_HOSTPATH_FALLBACK_ENABLED",
"K8S_PVC_BIND_TIMEOUT",
"K8S_CONTROL_PLANE_STORAGE_CLASS",
"K8S_INSTANCE_STORAGE_CLASS",
"K8S_WORKSPACE_STORAGE_CLASS",
"K8S_WORKSPACE_ACCESS_MODE",
"K8S_STORAGE_CLASS",
"RUNTIME_WORKSPACE_NFS_SERVER",
} {
t.Setenv(key, "")
}
cfg, err := Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if got, want := cfg.Storage.Profile, "cluster"; got != want {
t.Fatalf("storage profile = %q, want %q", got, want)
}
if cfg.Storage.HostPathFallbackEnabled {
t.Fatalf("hostPath fallback must default to disabled for cluster installs")
}
if got, want := cfg.GetPVCBindTimeout(), 2*time.Minute; got != want {
t.Fatalf("PVC bind timeout = %s, want %s", got, want)
}
if got, want := cfg.GetControlPlaneStorageClass(), "standard"; got != want {
t.Fatalf("control-plane storage class = %q, want %q", got, want)
}
if got, want := cfg.GetInstanceStorageClass(), "standard"; got != want {
t.Fatalf("instance storage class = %q, want %q", got, want)
}
if got, want := cfg.GetWorkspaceStorageClass(), "standard"; got != want {
t.Fatalf("workspace storage class = %q, want %q", got, want)
}
if got, want := cfg.GetWorkspaceAccessMode(), "ReadWriteMany"; got != want {
t.Fatalf("workspace access mode = %q, want %q", got, want)
}
if got := cfg.Runtime.WorkspaceNFSServer; got != "" {
t.Fatalf("workspace NFS server must not default to in-cluster service DNS, got %q", got)
}
}
func TestLoadStorageProfileEnvOverrides(t *testing.T) {
t.Setenv("CLAWMANAGER_STORAGE_PROFILE", "single-node")
t.Setenv("K8S_HOSTPATH_FALLBACK_ENABLED", "true")
t.Setenv("K8S_PVC_BIND_TIMEOUT", "45s")
t.Setenv("K8S_CONTROL_PLANE_STORAGE_CLASS", "manual")
t.Setenv("K8S_INSTANCE_STORAGE_CLASS", "manual")
t.Setenv("K8S_WORKSPACE_STORAGE_CLASS", "manual-workspace")
t.Setenv("K8S_WORKSPACE_ACCESS_MODE", "ReadWriteMany")
t.Setenv("RUNTIME_WORKSPACE_PVC_CLAIM", "clawmanager-workspaces")
cfg, err := Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if got, want := cfg.Storage.Profile, "single-node"; got != want {
t.Fatalf("storage profile = %q, want %q", got, want)
}
if !cfg.Storage.HostPathFallbackEnabled {
t.Fatalf("hostPath fallback should be explicitly enabled")
}
if got, want := cfg.GetPVCBindTimeout(), 45*time.Second; got != want {
t.Fatalf("PVC bind timeout = %s, want %s", got, want)
}
if got, want := cfg.GetControlPlaneStorageClass(), "manual"; got != want {
t.Fatalf("control-plane storage class = %q, want %q", got, want)
}
if got, want := cfg.GetInstanceStorageClass(), "manual"; got != want {
t.Fatalf("instance storage class = %q, want %q", got, want)
}
if got, want := cfg.GetWorkspaceStorageClass(), "manual-workspace"; got != want {
t.Fatalf("workspace storage class = %q, want %q", got, want)
}
if got, want := cfg.GetWorkspaceAccessMode(), "ReadWriteMany"; got != want {
t.Fatalf("workspace access mode = %q, want %q", got, want)
}
if got, want := cfg.Runtime.WorkspacePVCClaimName, "clawmanager-workspaces"; got != want {
t.Fatalf("runtime workspace PVC claim = %q, want %q", got, want)
}
}
+59
View File
@@ -0,0 +1,59 @@
package db
import (
"fmt"
"log"
"clawreef/internal/config"
"github.com/upper/db/v4"
"github.com/upper/db/v4/adapter/mysql"
)
// Session holds the database session
var Session db.Session
// Initialize initializes the database connection
func Initialize(cfg config.DatabaseConfig) (db.Session, error) {
hostPort := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
settings := mysql.ConnectionURL{
Host: hostPort,
User: cfg.User,
Password: cfg.Password,
Database: cfg.Database,
Options: map[string]string{
"charset": "utf8mb4",
"collation": "utf8mb4_unicode_ci",
"parseTime": "true",
},
}
session, err := mysql.Open(settings)
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
if _, err := session.SQL().Exec("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
_ = session.Close()
return nil, fmt.Errorf("failed to configure database connection charset: %w", err)
}
if err := applyEmbeddedMigrations(session); err != nil {
_ = session.Close()
return nil, fmt.Errorf("failed to apply database migrations: %w", err)
}
Session = session
log.Println("Database connected successfully")
return session, nil
}
// Close closes the database connection
func Close() error {
if Session != nil {
return Session.Close()
}
return nil
}
// GetSession returns the current database session
func GetSession() db.Session {
return Session
}
+229
View File
@@ -0,0 +1,229 @@
package db
import (
"embed"
"fmt"
"log"
"path"
"sort"
"strings"
"time"
"github.com/upper/db/v4"
)
//go:embed migrations/*.sql
var embeddedMigrations embed.FS
const schemaMigrationsTable = "schema_migrations"
func applyEmbeddedMigrations(session db.Session) error {
if err := ensureSchemaMigrationsTable(session); err != nil {
return err
}
applied, err := listAppliedMigrations(session)
if err != nil {
return err
}
entries, err := embeddedMigrations.ReadDir("migrations")
if err != nil {
return fmt.Errorf("failed to list embedded migrations: %w", err)
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}
if _, ok := applied[entry.Name()]; ok {
continue
}
rawSQL, err := embeddedMigrations.ReadFile(path.Join("migrations", entry.Name()))
if err != nil {
return fmt.Errorf("failed to read embedded migration %s: %w", entry.Name(), err)
}
statements := splitSQLStatements(string(rawSQL))
for idx, statement := range statements {
if _, err := session.SQL().Exec(statement); err != nil {
return fmt.Errorf("failed to execute migration %s statement %d: %w", entry.Name(), idx+1, err)
}
}
if _, err := session.SQL().Exec(
fmt.Sprintf("INSERT INTO %s (filename, applied_at) VALUES (?, ?)", schemaMigrationsTable),
entry.Name(),
time.Now().UTC(),
); err != nil {
return fmt.Errorf("failed to record applied migration %s: %w", entry.Name(), err)
}
log.Printf("Applied database migration %s", entry.Name())
}
return nil
}
func ensureSchemaMigrationsTable(session db.Session) error {
_, err := session.SQL().Exec(fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS %s (
id INT AUTO_INCREMENT PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_schema_migrations_filename (filename)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`, schemaMigrationsTable))
if err != nil {
return fmt.Errorf("failed to ensure schema migrations table: %w", err)
}
return nil
}
func listAppliedMigrations(session db.Session) (map[string]struct{}, error) {
rows, err := session.SQL().Query(fmt.Sprintf("SELECT filename FROM %s", schemaMigrationsTable))
if err != nil {
return nil, fmt.Errorf("failed to query applied migrations: %w", err)
}
defer rows.Close()
applied := make(map[string]struct{})
for rows.Next() {
var filename string
if err := rows.Scan(&filename); err != nil {
return nil, fmt.Errorf("failed to scan applied migration: %w", err)
}
applied[filename] = struct{}{}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to iterate applied migrations: %w", err)
}
return applied, nil
}
func splitSQLStatements(input string) []string {
statements := make([]string, 0)
var current strings.Builder
inSingleQuote := false
inDoubleQuote := false
inBacktick := false
inLineComment := false
inBlockComment := false
for i := 0; i < len(input); i++ {
ch := input[i]
var next byte
if i+1 < len(input) {
next = input[i+1]
}
if inLineComment {
if ch == '\n' {
inLineComment = false
}
continue
}
if inBlockComment {
if ch == '*' && next == '/' {
inBlockComment = false
i++
}
continue
}
if inSingleQuote {
current.WriteByte(ch)
if ch == '\\' && next != 0 {
i++
current.WriteByte(next)
continue
}
if ch == '\'' {
if next == '\'' {
i++
current.WriteByte(next)
continue
}
inSingleQuote = false
}
continue
}
if inDoubleQuote {
current.WriteByte(ch)
if ch == '\\' && next != 0 {
i++
current.WriteByte(next)
continue
}
if ch == '"' {
if next == '"' {
i++
current.WriteByte(next)
continue
}
inDoubleQuote = false
}
continue
}
if inBacktick {
current.WriteByte(ch)
if ch == '`' {
inBacktick = false
}
continue
}
if ch == '-' && next == '-' {
var afterNext byte
if i+2 < len(input) {
afterNext = input[i+2]
}
if afterNext == 0 || afterNext == ' ' || afterNext == '\t' || afterNext == '\r' || afterNext == '\n' {
inLineComment = true
i++
continue
}
}
if ch == '/' && next == '*' {
inBlockComment = true
i++
continue
}
switch ch {
case '\'':
inSingleQuote = true
current.WriteByte(ch)
case '"':
inDoubleQuote = true
current.WriteByte(ch)
case '`':
inBacktick = true
current.WriteByte(ch)
case ';':
statement := strings.TrimSpace(current.String())
if statement != "" {
statements = append(statements, statement)
}
current.Reset()
default:
current.WriteByte(ch)
}
}
if statement := strings.TrimSpace(current.String()); statement != "" {
statements = append(statements, statement)
}
return statements
}
@@ -0,0 +1,169 @@
-- Initial schema for ClawReef
-- Created: 2026-03-15
-- Users table
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(320) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role ENUM('admin', 'user') DEFAULT 'user',
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_login TIMESTAMP,
INDEX idx_username (username),
INDEX idx_role (role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Instances table
CREATE TABLE IF NOT EXISTS instances (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop', 'hermes') DEFAULT 'ubuntu',
runtime_type ENUM('desktop', 'shell') NOT NULL DEFAULT 'desktop',
status ENUM('creating', 'running', 'stopped', 'error', 'deleting') DEFAULT 'creating',
cpu_cores INT NOT NULL,
memory_gb INT NOT NULL,
disk_gb INT NOT NULL,
gpu_enabled BOOLEAN DEFAULT FALSE,
gpu_type VARCHAR(100),
gpu_count INT DEFAULT 0,
os_type VARCHAR(50) NOT NULL,
os_version VARCHAR(50) NOT NULL,
image_registry VARCHAR(255),
image_tag VARCHAR(100),
environment_overrides_json LONGTEXT,
storage_class VARCHAR(50) DEFAULT 'standard',
mount_path VARCHAR(255) DEFAULT '/data',
pod_name VARCHAR(255),
pod_namespace VARCHAR(255),
pod_ip VARCHAR(45),
access_url VARCHAR(500),
access_token VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
started_at TIMESTAMP,
stopped_at TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_id (user_id),
INDEX idx_status (status),
INDEX idx_type (type),
UNIQUE KEY uk_user_instance_name (user_id, name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Persistent volumes table
CREATE TABLE IF NOT EXISTS persistent_volumes (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NOT NULL,
pvc_name VARCHAR(255) UNIQUE NOT NULL,
pvc_namespace VARCHAR(255) NOT NULL,
storage_size_gb INT NOT NULL,
storage_class VARCHAR(50),
mount_path VARCHAR(255),
status ENUM('pending', 'bound', 'released', 'failed') DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE,
INDEX idx_instance_id (instance_id),
UNIQUE KEY uk_pvc_name_namespace (pvc_name, pvc_namespace)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Backups table
CREATE TABLE IF NOT EXISTS backups (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NOT NULL,
backup_name VARCHAR(255) NOT NULL,
backup_size_gb INT,
backup_path VARCHAR(500),
status ENUM('creating', 'completed', 'failed', 'deleted') DEFAULT 'creating',
backup_type ENUM('manual', 'scheduled') DEFAULT 'manual',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
expires_at TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE,
INDEX idx_instance_id (instance_id),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Backup schedules table
CREATE TABLE IF NOT EXISTS backup_schedules (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NOT NULL,
schedule_name VARCHAR(255),
cron_expression VARCHAR(100) NOT NULL,
retention_days INT DEFAULT 30,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE,
INDEX idx_instance_id (instance_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- User quotas table
CREATE TABLE IF NOT EXISTS user_quotas (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL UNIQUE,
max_instances INT DEFAULT 10,
max_cpu_cores INT DEFAULT 40,
max_memory_gb INT DEFAULT 100,
max_storage_gb INT DEFAULT 500,
max_gpu_count INT DEFAULT 2,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Instance usage table
CREATE TABLE IF NOT EXISTS instance_usage (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NOT NULL,
cpu_usage_percent DECIMAL(5,2),
memory_usage_gb DECIMAL(10,2),
disk_usage_gb DECIMAL(10,2),
gpu_usage_percent DECIMAL(5,2),
uptime_seconds INT,
recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE,
INDEX idx_instance_recorded (instance_id, recorded_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Audit logs table
CREATE TABLE IF NOT EXISTS audit_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT,
action VARCHAR(100) NOT NULL,
resource_type VARCHAR(50) NOT NULL,
resource_id INT,
details JSON,
ip_address VARCHAR(45),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_user_id (user_id),
INDEX idx_action (action),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Insert default admin user (password: admin123)
INSERT INTO users (username, email, password_hash, role, is_active)
SELECT 'admin', 'admin@clawreef.local', '$2a$10$pbenze514mwv3pvQySQBVOsF5J4DBXL2kVo1hLa8JFhQu5x3AKvBi', 'admin', TRUE
FROM DUAL
WHERE NOT EXISTS (
SELECT 1
FROM users
WHERE username = 'admin' OR email = 'admin@clawreef.local'
);
-- Insert default quota for admin
INSERT INTO user_quotas (user_id, max_instances, max_cpu_cores, max_memory_gb, max_storage_gb, max_gpu_count)
SELECT users.id, 100, 200, 1000, 5000, 10
FROM users
WHERE username = 'admin'
AND NOT EXISTS (
SELECT 1
FROM user_quotas
WHERE user_quotas.user_id = users.id
);
@@ -0,0 +1,2 @@
ALTER TABLE instances
MODIFY COLUMN type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop', 'hermes') DEFAULT 'ubuntu';
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS system_image_settings (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_type VARCHAR(50) NOT NULL,
runtime_type ENUM('desktop', 'shell') NOT NULL DEFAULT 'desktop',
display_name VARCHAR(255) NOT NULL,
image VARCHAR(500) NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_instance_type (instance_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,6 @@
-- Repair previously shipped broken admin seed hashes without touching
-- user-changed passwords.
UPDATE users
SET password_hash = '$2a$10$pbenze514mwv3pvQySQBVOsF5J4DBXL2kVo1hLa8JFhQu5x3AKvBi'
WHERE username = 'admin'
AND password_hash = '$2a$10$N9qo8uLOickgx2ZMRZoMy.MqrzL9wGC3qD3Q.ZHqQH6t3q7l1L5uG';
@@ -0,0 +1,7 @@
UPDATE system_image_settings
SET image = 'ghcr.io/yuan-lab-llm/agentsruntime/openclaw:latest'
WHERE instance_type = 'openclaw'
AND image IN (
'ericpearlee/openclaw:v2026.3.24',
'ghcr.io/yuan-lab-llm/clawmanager-openclaw-image/openclaw:latest'
);
@@ -0,0 +1,82 @@
SET @openclaw_snapshot_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'instances'
AND COLUMN_NAME = 'openclaw_config_snapshot_id'
);
SET @openclaw_snapshot_column_sql = IF(
@openclaw_snapshot_column_exists = 0,
'ALTER TABLE instances ADD COLUMN openclaw_config_snapshot_id INT NULL AFTER access_token',
'SELECT 1'
);
PREPARE openclaw_snapshot_column_stmt FROM @openclaw_snapshot_column_sql;
EXECUTE openclaw_snapshot_column_stmt;
DEALLOCATE PREPARE openclaw_snapshot_column_stmt;
CREATE TABLE IF NOT EXISTS openclaw_config_resources (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
resource_type VARCHAR(50) NOT NULL,
resource_key VARCHAR(100) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
version INT NOT NULL DEFAULT 1,
tags_json LONGTEXT NOT NULL,
content_json LONGTEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY uk_openclaw_resource_key (user_id, resource_type, resource_key),
INDEX idx_openclaw_resource_user_type (user_id, resource_type),
INDEX idx_openclaw_resource_user_enabled (user_id, enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS openclaw_config_bundles (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
version INT NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_openclaw_bundle_user (user_id),
INDEX idx_openclaw_bundle_user_enabled (user_id, enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS openclaw_config_bundle_items (
id INT AUTO_INCREMENT PRIMARY KEY,
bundle_id INT NOT NULL,
resource_id INT NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
required BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (bundle_id) REFERENCES openclaw_config_bundles(id) ON DELETE CASCADE,
FOREIGN KEY (resource_id) REFERENCES openclaw_config_resources(id) ON DELETE CASCADE,
UNIQUE KEY uk_openclaw_bundle_resource (bundle_id, resource_id),
INDEX idx_openclaw_bundle_item_bundle (bundle_id, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS openclaw_injection_snapshots (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NULL,
user_id INT NOT NULL,
mode VARCHAR(20) NOT NULL,
bundle_id INT NULL,
selected_resource_ids_json LONGTEXT NOT NULL,
resolved_resources_json LONGTEXT NOT NULL,
rendered_manifest_json LONGTEXT NOT NULL,
rendered_env_json LONGTEXT NOT NULL,
secret_name VARCHAR(255) NULL,
status VARCHAR(30) NOT NULL DEFAULT 'pending',
error_message TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
activated_at TIMESTAMP NULL,
INDEX idx_openclaw_snapshot_user_created (user_id, created_at),
INDEX idx_openclaw_snapshot_instance (instance_id),
INDEX idx_openclaw_snapshot_bundle (bundle_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,123 @@
SET @instance_agent_bootstrap_token_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'instances'
AND COLUMN_NAME = 'agent_bootstrap_token'
);
SET @instance_agent_bootstrap_token_column_sql = IF(
@instance_agent_bootstrap_token_column_exists = 0,
'ALTER TABLE instances ADD COLUMN agent_bootstrap_token VARCHAR(255) NULL AFTER access_token',
'SELECT 1'
);
PREPARE instance_agent_bootstrap_token_column_stmt FROM @instance_agent_bootstrap_token_column_sql;
EXECUTE instance_agent_bootstrap_token_column_stmt;
DEALLOCATE PREPARE instance_agent_bootstrap_token_column_stmt;
CREATE TABLE IF NOT EXISTS instance_agents (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NOT NULL,
agent_id VARCHAR(255) NOT NULL,
agent_version VARCHAR(50) NOT NULL,
protocol_version VARCHAR(50) NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'online',
capabilities_json LONGTEXT NOT NULL,
host_info_json LONGTEXT NULL,
session_token VARCHAR(255) NULL,
session_expires_at TIMESTAMP NULL,
last_heartbeat_at TIMESTAMP NULL,
last_reported_at TIMESTAMP NULL,
last_seen_ip VARCHAR(45) NULL,
registered_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE,
UNIQUE KEY uk_instance_agents_instance (instance_id),
UNIQUE KEY uk_instance_agents_session_token (session_token),
INDEX idx_instance_agents_agent_id (agent_id),
INDEX idx_instance_agents_status (status),
INDEX idx_instance_agents_last_heartbeat (last_heartbeat_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS instance_runtime_status (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NOT NULL,
infra_status VARCHAR(30) NOT NULL DEFAULT 'creating',
agent_status VARCHAR(30) NOT NULL DEFAULT 'offline',
openclaw_status VARCHAR(30) NOT NULL DEFAULT 'unknown',
openclaw_pid INT NULL,
openclaw_version VARCHAR(100) NULL,
current_config_revision_id INT NULL,
desired_config_revision_id INT NULL,
summary_json LONGTEXT NULL,
system_info_json LONGTEXT NULL,
health_json LONGTEXT NULL,
last_reported_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE,
UNIQUE KEY uk_instance_runtime_status_instance (instance_id),
INDEX idx_instance_runtime_status_agent_status (agent_status),
INDEX idx_instance_runtime_status_openclaw_status (openclaw_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS instance_desired_state (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NOT NULL,
desired_power_state VARCHAR(30) NOT NULL DEFAULT 'running',
desired_config_revision_id INT NULL,
desired_runtime_action VARCHAR(50) NULL,
updated_by INT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE,
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uk_instance_desired_state_instance (instance_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS instance_commands (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NOT NULL,
agent_id VARCHAR(255) NULL,
command_type VARCHAR(50) NOT NULL,
payload_json LONGTEXT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'pending',
idempotency_key VARCHAR(255) NOT NULL,
issued_by INT NULL,
issued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
dispatched_at TIMESTAMP NULL,
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL,
timeout_seconds INT NOT NULL DEFAULT 300,
result_json LONGTEXT NULL,
error_message TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE,
FOREIGN KEY (issued_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uk_instance_commands_idempotency (instance_id, idempotency_key),
INDEX idx_instance_commands_instance_status (instance_id, status),
INDEX idx_instance_commands_agent_status (agent_id, status),
INDEX idx_instance_commands_issued_at (issued_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS instance_config_revisions (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NOT NULL,
source_snapshot_id INT NULL,
source_bundle_id INT NULL,
revision_no INT NOT NULL,
content_json LONGTEXT NOT NULL,
checksum VARCHAR(255) NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'published',
published_by INT NULL,
published_at TIMESTAMP NULL,
activated_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE,
FOREIGN KEY (published_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uk_instance_config_revision_unique (instance_id, revision_no),
INDEX idx_instance_config_revision_instance (instance_id, revision_no),
INDEX idx_instance_config_revision_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,91 @@
CREATE TABLE IF NOT EXISTS skill_blobs (
id INT AUTO_INCREMENT PRIMARY KEY,
content_hash VARCHAR(128) NOT NULL,
archive_hash VARCHAR(128) NOT NULL,
object_key VARCHAR(512) NOT NULL,
file_name VARCHAR(255) NOT NULL,
media_type VARCHAR(100) NOT NULL DEFAULT 'application/gzip',
size_bytes BIGINT NOT NULL DEFAULT 0,
scan_status VARCHAR(30) NOT NULL DEFAULT 'pending',
risk_level VARCHAR(30) NOT NULL DEFAULT 'unknown',
last_scanned_at TIMESTAMP NULL,
last_scan_result_id INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_skill_blobs_content_hash (content_hash),
INDEX idx_skill_blobs_scan_status (scan_status),
INDEX idx_skill_blobs_risk_level (risk_level)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS skills (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
skill_key VARCHAR(120) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
current_version_id INT NULL,
source_type VARCHAR(30) NOT NULL DEFAULT 'uploaded',
status VARCHAR(30) NOT NULL DEFAULT 'active',
risk_level VARCHAR(30) NOT NULL DEFAULT 'unknown',
last_scanned_at TIMESTAMP NULL,
last_scan_result_id INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY uk_skills_user_key (user_id, skill_key),
INDEX idx_skills_user_status (user_id, status),
INDEX idx_skills_risk_level (risk_level)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS skill_versions (
id INT AUTO_INCREMENT PRIMARY KEY,
skill_id INT NOT NULL,
blob_id INT NOT NULL,
version_no INT NOT NULL,
manifest_json LONGTEXT NULL,
source_type VARCHAR(30) NOT NULL DEFAULT 'uploaded',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE,
FOREIGN KEY (blob_id) REFERENCES skill_blobs(id) ON DELETE RESTRICT,
UNIQUE KEY uk_skill_versions_skill_version (skill_id, version_no),
UNIQUE KEY uk_skill_versions_skill_blob (skill_id, blob_id),
INDEX idx_skill_versions_skill_id (skill_id, version_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS instance_skills (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NOT NULL,
skill_id INT NOT NULL,
skill_version_id INT NULL,
source_type VARCHAR(40) NOT NULL DEFAULT 'discovered_in_instance',
install_path VARCHAR(1024) NULL,
observed_hash VARCHAR(128) NULL,
status VARCHAR(30) NOT NULL DEFAULT 'active',
last_seen_at TIMESTAMP NULL,
removed_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE,
FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE,
FOREIGN KEY (skill_version_id) REFERENCES skill_versions(id) ON DELETE SET NULL,
UNIQUE KEY uk_instance_skills_instance_skill (instance_id, skill_id),
INDEX idx_instance_skills_instance (instance_id, status),
INDEX idx_instance_skills_skill (skill_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS skill_scan_results (
id INT AUTO_INCREMENT PRIMARY KEY,
blob_id INT NOT NULL,
engine VARCHAR(60) NOT NULL,
risk_level VARCHAR(30) NOT NULL DEFAULT 'unknown',
status VARCHAR(30) NOT NULL DEFAULT 'completed',
summary TEXT NULL,
findings_json LONGTEXT NULL,
scanned_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (blob_id) REFERENCES skill_blobs(id) ON DELETE CASCADE,
INDEX idx_skill_scan_results_blob (blob_id, scanned_at),
INDEX idx_skill_scan_results_risk (risk_level, scanned_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,5 @@
-- Allow fractional CPU cores for instances and quotas
-- Created: 2026-04-14
ALTER TABLE instances MODIFY COLUMN cpu_cores DECIMAL(10,2) NOT NULL;
ALTER TABLE user_quotas MODIFY COLUMN max_cpu_cores DECIMAL(10,2) DEFAULT 40;
@@ -0,0 +1,15 @@
SET @instance_environment_overrides_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'instances'
AND COLUMN_NAME = 'environment_overrides_json'
);
SET @instance_environment_overrides_column_sql = IF(
@instance_environment_overrides_column_exists = 0,
'ALTER TABLE instances ADD COLUMN environment_overrides_json LONGTEXT NULL AFTER image_tag',
'SELECT 1'
);
PREPARE instance_environment_overrides_column_stmt FROM @instance_environment_overrides_column_sql;
EXECUTE instance_environment_overrides_column_stmt;
DEALLOCATE PREPARE instance_environment_overrides_column_stmt;
@@ -0,0 +1,2 @@
ALTER TABLE instances
MODIFY COLUMN type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop', 'hermes') DEFAULT 'ubuntu';
@@ -0,0 +1,15 @@
UPDATE system_image_settings
SET image = 'ghcr.io/yuan-lab-llm/agentsruntime/openclaw:latest'
WHERE instance_type = 'openclaw'
AND image IN (
'ericpearlee/openclaw:v2026.3.24',
'ghcr.io/yuan-lab-llm/clawmanager-openclaw-image/openclaw:latest'
);
UPDATE system_image_settings
SET image = 'ghcr.io/yuan-lab-llm/agentsruntime/hermes:latest'
WHERE instance_type = 'hermes'
AND image IN (
'registry.example.com/hermes-webtop:latest',
'lscr.io/linuxserver/webtop:ubuntu-xfce'
);
@@ -0,0 +1,7 @@
UPDATE system_image_settings
SET image = 'ghcr.io/yuan-lab-llm/agentsruntime/openclaw:latest'
WHERE image = 'ghcr.io/Yuan-lab-LLM/AgentsRuntime/openclaw:latest';
UPDATE system_image_settings
SET image = 'ghcr.io/yuan-lab-llm/agentsruntime/hermes:latest'
WHERE image = 'ghcr.io/Yuan-lab-LLM/AgentsRuntime/hermes:latest';
@@ -0,0 +1,90 @@
CREATE TABLE IF NOT EXISTS teams (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'creating',
communication_mode VARCHAR(50) NOT NULL DEFAULT 'leader_only',
redis_url_secret_name VARCHAR(255) NULL,
redis_url_secret_key VARCHAR(255) NULL,
team_token_secret_name VARCHAR(255) NULL,
team_token_secret_key VARCHAR(255) NULL,
redis_events_last_id VARCHAR(128) NOT NULL DEFAULT '0-0',
shared_pvc_name VARCHAR(255) NULL,
shared_pvc_namespace VARCHAR(255) NULL,
shared_mount_path VARCHAR(255) NOT NULL DEFAULT '/team',
storage_class VARCHAR(100) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY uk_teams_user_name (user_id, name),
INDEX idx_teams_user_status (user_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS team_members (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
instance_id INT NULL,
user_id INT NOT NULL,
member_key VARCHAR(100) NOT NULL,
display_name VARCHAR(255) NOT NULL,
role VARCHAR(100) NOT NULL,
description TEXT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'creating',
current_task_id INT NULL,
progress INT NOT NULL DEFAULT 0,
last_seen_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE SET NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY uk_team_members_key (team_id, member_key),
UNIQUE KEY uk_team_members_instance (team_id, instance_id),
INDEX idx_team_members_team_status (team_id, status),
INDEX idx_team_members_instance (instance_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS team_tasks (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
target_member_id INT NOT NULL,
created_by INT NULL,
message_id VARCHAR(255) NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'pending',
payload_json LONGTEXT NOT NULL,
result_json LONGTEXT NULL,
error_message TEXT NULL,
redis_stream_id VARCHAR(128) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
dispatched_at TIMESTAMP NULL,
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
FOREIGN KEY (target_member_id) REFERENCES team_members(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uk_team_tasks_message (team_id, message_id),
INDEX idx_team_tasks_team_status (team_id, status),
INDEX idx_team_tasks_member_status (target_member_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS team_events (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
member_id INT NULL,
task_id INT NULL,
message_id VARCHAR(255) NULL,
event_type VARCHAR(100) NOT NULL,
payload_json LONGTEXT NULL,
redis_stream_id VARCHAR(128) NULL,
occurred_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
FOREIGN KEY (member_id) REFERENCES team_members(id) ON DELETE SET NULL,
FOREIGN KEY (task_id) REFERENCES team_tasks(id) ON DELETE SET NULL,
UNIQUE KEY uk_team_events_stream_id (team_id, redis_stream_id),
INDEX idx_team_events_team_created (team_id, created_at),
INDEX idx_team_events_task (task_id),
INDEX idx_team_events_member (member_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,15 @@
SET @team_member_description_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'team_members'
AND COLUMN_NAME = 'description'
);
SET @team_member_description_column_sql = IF(
@team_member_description_column_exists = 0,
'ALTER TABLE team_members ADD COLUMN description TEXT NULL AFTER role',
'SELECT 1'
);
PREPARE team_member_description_column_stmt FROM @team_member_description_column_sql;
EXECUTE team_member_description_column_stmt;
DEALLOCATE PREPARE team_member_description_column_stmt;
@@ -0,0 +1,8 @@
UPDATE teams
SET name = CONCAT(
LEFT(name, GREATEST(1, 255 - CHAR_LENGTH(CONCAT('__deleted_', id)))),
'__deleted_',
id
)
WHERE status = 'deleted'
AND name NOT REGEXP '__deleted_[0-9]+$';
@@ -0,0 +1,95 @@
SET @team_member_availability_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'team_members'
AND COLUMN_NAME = 'availability'
);
SET @team_member_availability_column_sql = IF(
@team_member_availability_column_exists = 0,
'ALTER TABLE team_members ADD COLUMN availability VARCHAR(30) NOT NULL DEFAULT ''unknown'' AFTER last_seen_at',
'SELECT 1'
);
PREPARE team_member_availability_column_stmt FROM @team_member_availability_column_sql;
EXECUTE team_member_availability_column_stmt;
DEALLOCATE PREPARE team_member_availability_column_stmt;
SET @team_member_runtime_status_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'team_members'
AND COLUMN_NAME = 'runtime_status'
);
SET @team_member_runtime_status_column_sql = IF(
@team_member_runtime_status_column_exists = 0,
'ALTER TABLE team_members ADD COLUMN runtime_status VARCHAR(50) NULL AFTER availability',
'SELECT 1'
);
PREPARE team_member_runtime_status_column_stmt FROM @team_member_runtime_status_column_sql;
EXECUTE team_member_runtime_status_column_stmt;
DEALLOCATE PREPARE team_member_runtime_status_column_stmt;
SET @team_member_runtime_task_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'team_members'
AND COLUMN_NAME = 'runtime_task_id'
);
SET @team_member_runtime_task_column_sql = IF(
@team_member_runtime_task_column_exists = 0,
'ALTER TABLE team_members ADD COLUMN runtime_task_id VARCHAR(255) NULL AFTER runtime_status',
'SELECT 1'
);
PREPARE team_member_runtime_task_column_stmt FROM @team_member_runtime_task_column_sql;
EXECUTE team_member_runtime_task_column_stmt;
DEALLOCATE PREPARE team_member_runtime_task_column_stmt;
SET @team_member_runtime_intent_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'team_members'
AND COLUMN_NAME = 'runtime_intent'
);
SET @team_member_runtime_intent_column_sql = IF(
@team_member_runtime_intent_column_exists = 0,
'ALTER TABLE team_members ADD COLUMN runtime_intent VARCHAR(100) NULL AFTER runtime_task_id',
'SELECT 1'
);
PREPARE team_member_runtime_intent_column_stmt FROM @team_member_runtime_intent_column_sql;
EXECUTE team_member_runtime_intent_column_stmt;
DEALLOCATE PREPARE team_member_runtime_intent_column_stmt;
SET @team_member_blocked_reason_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'team_members'
AND COLUMN_NAME = 'blocked_reason'
);
SET @team_member_blocked_reason_column_sql = IF(
@team_member_blocked_reason_column_exists = 0,
'ALTER TABLE team_members ADD COLUMN blocked_reason TEXT NULL AFTER runtime_intent',
'SELECT 1'
);
PREPARE team_member_blocked_reason_column_stmt FROM @team_member_blocked_reason_column_sql;
EXECUTE team_member_blocked_reason_column_stmt;
DEALLOCATE PREPARE team_member_blocked_reason_column_stmt;
SET @team_member_last_summary_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'team_members'
AND COLUMN_NAME = 'last_summary'
);
SET @team_member_last_summary_column_sql = IF(
@team_member_last_summary_column_exists = 0,
'ALTER TABLE team_members ADD COLUMN last_summary TEXT NULL AFTER blocked_reason',
'SELECT 1'
);
PREPARE team_member_last_summary_column_stmt FROM @team_member_last_summary_column_sql;
EXECUTE team_member_last_summary_column_stmt;
DEALLOCATE PREPARE team_member_last_summary_column_stmt;
@@ -0,0 +1,15 @@
SET @team_member_runtime_type_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'team_members'
AND COLUMN_NAME = 'runtime_type'
);
SET @team_member_runtime_type_column_sql = IF(
@team_member_runtime_type_column_exists = 0,
'ALTER TABLE team_members ADD COLUMN runtime_type VARCHAR(30) NOT NULL DEFAULT ''openclaw'' AFTER role',
'SELECT 1'
);
PREPARE team_member_runtime_type_column_stmt FROM @team_member_runtime_type_column_sql;
EXECUTE team_member_runtime_type_column_stmt;
DEALLOCATE PREPARE team_member_runtime_type_column_stmt;
@@ -0,0 +1,15 @@
SET @instance_runtime_type_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'instances'
AND COLUMN_NAME = 'runtime_type'
);
SET @instance_runtime_type_column_sql = IF(
@instance_runtime_type_column_exists = 0,
'ALTER TABLE instances ADD COLUMN runtime_type ENUM(''desktop'', ''shell'') NOT NULL DEFAULT ''desktop'' AFTER type',
'SELECT 1'
);
PREPARE instance_runtime_type_column_stmt FROM @instance_runtime_type_column_sql;
EXECUTE instance_runtime_type_column_stmt;
DEALLOCATE PREPARE instance_runtime_type_column_stmt;
@@ -0,0 +1,15 @@
SET @system_image_runtime_type_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'system_image_settings'
AND COLUMN_NAME = 'runtime_type'
);
SET @system_image_runtime_type_column_sql = IF(
@system_image_runtime_type_column_exists = 0,
'ALTER TABLE system_image_settings ADD COLUMN runtime_type ENUM(''desktop'', ''shell'') NOT NULL DEFAULT ''desktop'' AFTER instance_type',
'SELECT 1'
);
PREPARE system_image_runtime_type_column_stmt FROM @system_image_runtime_type_column_sql;
EXECUTE system_image_runtime_type_column_stmt;
DEALLOCATE PREPARE system_image_runtime_type_column_stmt;
@@ -0,0 +1,78 @@
SET @system_image_is_enabled_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'system_image_settings'
AND COLUMN_NAME = 'is_enabled'
);
SET @system_image_is_enabled_column_sql = IF(
@system_image_is_enabled_column_exists = 0,
'ALTER TABLE system_image_settings ADD COLUMN is_enabled BOOLEAN NOT NULL DEFAULT TRUE',
'SELECT 1'
);
PREPARE system_image_is_enabled_column_stmt FROM @system_image_is_enabled_column_sql;
EXECUTE system_image_is_enabled_column_stmt;
DEALLOCATE PREPARE system_image_is_enabled_column_stmt;
SET @system_image_unique_instance_type_index_name = (
SELECT INDEX_NAME
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'system_image_settings'
AND COLUMN_NAME = 'instance_type'
AND NON_UNIQUE = 0
AND INDEX_NAME <> 'PRIMARY'
LIMIT 1
);
SET @system_image_unique_instance_type_index_sql = IF(
@system_image_unique_instance_type_index_name IS NOT NULL,
CONCAT(
'ALTER TABLE system_image_settings DROP INDEX `',
REPLACE(@system_image_unique_instance_type_index_name, '`', '``'),
'`'
),
'SELECT 1'
);
PREPARE system_image_unique_instance_type_index_stmt FROM @system_image_unique_instance_type_index_sql;
EXECUTE system_image_unique_instance_type_index_stmt;
DEALLOCATE PREPARE system_image_unique_instance_type_index_stmt;
INSERT INTO system_image_settings (instance_type, runtime_type, display_name, image, is_enabled)
SELECT
'openclaw',
'desktop',
'OpenClaw Desktop',
'ghcr.io/yuan-lab-llm/agentsruntime/openclaw:latest',
TRUE
WHERE NOT EXISTS (
SELECT 1
FROM system_image_settings
WHERE instance_type = 'openclaw'
AND runtime_type = 'desktop'
)
AND NOT EXISTS (
SELECT 1
FROM system_image_settings
WHERE instance_type = 'openclaw'
AND is_enabled = FALSE
);
INSERT INTO system_image_settings (instance_type, runtime_type, display_name, image, is_enabled)
SELECT
'openclaw',
'shell',
'OpenClaw Lite',
'ghcr.io/yuan-lab-llm/agentsruntime/openclaw-lite:latest',
TRUE
WHERE NOT EXISTS (
SELECT 1
FROM system_image_settings
WHERE instance_type = 'openclaw'
AND runtime_type = 'shell'
)
AND NOT EXISTS (
SELECT 1
FROM system_image_settings
WHERE instance_type = 'openclaw'
AND is_enabled = FALSE
);
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS openclaw_config_bundle_skills (
id INT AUTO_INCREMENT PRIMARY KEY,
bundle_id INT NOT NULL,
skill_id INT NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
required BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (bundle_id) REFERENCES openclaw_config_bundles(id) ON DELETE CASCADE,
FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE,
UNIQUE KEY uk_openclaw_bundle_skill (bundle_id, skill_id),
INDEX idx_openclaw_bundle_skill_bundle (bundle_id, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,121 @@
SET @stmt = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'instances' AND COLUMN_NAME = 'workspace_path') = 0,
'ALTER TABLE instances ADD COLUMN workspace_path VARCHAR(1024) NULL AFTER mount_path',
'SELECT 1'
);
PREPARE stmt FROM @stmt;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @stmt = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'instances' AND COLUMN_NAME = 'workspace_usage_bytes') = 0,
'ALTER TABLE instances ADD COLUMN workspace_usage_bytes BIGINT NOT NULL DEFAULT 0 AFTER workspace_path',
'SELECT 1'
);
PREPARE stmt FROM @stmt;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @stmt = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'instances' AND COLUMN_NAME = 'runtime_generation') = 0,
'ALTER TABLE instances ADD COLUMN runtime_generation INT NOT NULL DEFAULT 1 AFTER workspace_usage_bytes',
'SELECT 1'
);
PREPARE stmt FROM @stmt;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @stmt = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'instances' AND COLUMN_NAME = 'runtime_error_message') = 0,
'ALTER TABLE instances ADD COLUMN runtime_error_message TEXT NULL AFTER runtime_generation',
'SELECT 1'
);
PREPARE stmt FROM @stmt;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
ALTER TABLE instances
MODIFY COLUMN runtime_type ENUM('desktop', 'shell', 'gateway') NOT NULL DEFAULT 'desktop';
CREATE TABLE IF NOT EXISTS runtime_pods (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
runtime_type VARCHAR(32) NOT NULL,
namespace VARCHAR(128) NOT NULL,
pod_name VARCHAR(255) NOT NULL,
pod_uid VARCHAR(128) NULL,
pod_ip VARCHAR(64) NULL,
node_name VARCHAR(255) NULL,
deployment_name VARCHAR(255) NOT NULL,
image_ref VARCHAR(512) NOT NULL,
agent_endpoint VARCHAR(255) NULL,
state VARCHAR(32) NOT NULL DEFAULT 'pending',
capacity INT NOT NULL DEFAULT 100,
used_slots INT NOT NULL DEFAULT 0,
draining TINYINT(1) NOT NULL DEFAULT 0,
cpu_millis_used BIGINT NOT NULL DEFAULT 0,
memory_bytes_used BIGINT NOT NULL DEFAULT 0,
disk_bytes_used BIGINT NOT NULL DEFAULT 0,
network_rx_bytes BIGINT NOT NULL DEFAULT 0,
network_tx_bytes BIGINT NOT NULL DEFAULT 0,
metrics_json JSON NULL,
last_seen_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_runtime_pod_namespace_name (namespace, pod_name),
KEY idx_runtime_pods_schedulable (runtime_type, state, draining, used_slots, capacity),
KEY idx_runtime_pods_last_seen (last_seen_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS instance_runtime_bindings (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
instance_id INT NOT NULL,
runtime_pod_id BIGINT NOT NULL,
runtime_type VARCHAR(32) NOT NULL,
gateway_id VARCHAR(128) NOT NULL,
gateway_port INT NOT NULL,
gateway_pid INT NULL,
workspace_path VARCHAR(1024) NOT NULL,
state VARCHAR(32) NOT NULL DEFAULT 'creating',
generation INT NOT NULL DEFAULT 1,
last_health_at DATETIME NULL,
error_message TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_instance_runtime_binding_instance (instance_id),
UNIQUE KEY uk_instance_runtime_binding_gateway (runtime_pod_id, gateway_port),
KEY idx_instance_runtime_binding_pod_state (runtime_pod_id, state),
CONSTRAINT fk_instance_runtime_binding_instance
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE,
CONSTRAINT fk_instance_runtime_binding_pod
FOREIGN KEY (runtime_pod_id) REFERENCES runtime_pods(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS runtime_rollouts (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
runtime_type VARCHAR(32) NOT NULL,
target_image_ref VARCHAR(512) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
batch_size INT NOT NULL DEFAULT 1,
max_unavailable INT NOT NULL DEFAULT 1,
started_by INT NULL,
started_at DATETIME NULL,
finished_at DATETIME NULL,
error_message TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_runtime_rollouts_type_status (runtime_type, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS workspace_file_audits (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
instance_id INT NOT NULL,
user_id INT NOT NULL,
action VARCHAR(32) NOT NULL,
relative_path VARCHAR(1024) NOT NULL,
bytes BIGINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_workspace_file_audits_instance_time (instance_id, created_at),
KEY idx_workspace_file_audits_user_time (user_id, created_at),
CONSTRAINT fk_workspace_file_audits_instance
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,5 @@
UPDATE instances
SET mount_path = '/config',
updated_at = CURRENT_TIMESTAMP
WHERE type IN ('openclaw', 'ubuntu', 'webtop', 'hermes')
AND mount_path IN ('/data', '/home/user/data', '/config/.hermes');
@@ -0,0 +1,2 @@
ALTER TABLE instances
MODIFY COLUMN runtime_type ENUM('desktop', 'shell', 'gateway') NOT NULL DEFAULT 'desktop';
@@ -0,0 +1,24 @@
SET @instance_mode_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'instances'
AND COLUMN_NAME = 'instance_mode'
);
SET @instance_mode_column_sql = IF(
@instance_mode_column_exists = 0,
'ALTER TABLE instances ADD COLUMN instance_mode ENUM(''lite'', ''pro'') NOT NULL DEFAULT ''lite'' AFTER runtime_type',
'SELECT 1'
);
PREPARE instance_mode_column_stmt FROM @instance_mode_column_sql;
EXECUTE instance_mode_column_stmt;
DEALLOCATE PREPARE instance_mode_column_stmt;
UPDATE instances
SET instance_mode = CASE
WHEN runtime_type = 'gateway' THEN 'lite'
ELSE 'pro'
END
WHERE instance_mode IS NULL
OR instance_mode = ''
OR (runtime_type <> 'gateway' AND instance_mode = 'lite');
@@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS instance_external_access (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
instance_id INT NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 0,
auth_mode VARCHAR(32) NOT NULL DEFAULT 'public_link',
public_slug VARCHAR(64) NULL,
public_token_hash VARCHAR(128) NULL,
short_code_hash VARCHAR(128) NULL,
api_key_hash VARCHAR(128) NULL,
password_value VARCHAR(128) NULL,
api_key_prefix VARCHAR(32) NULL,
expires_at DATETIME NULL,
created_by INT NULL,
last_used_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_instance_external_access_instance (instance_id),
UNIQUE KEY uk_instance_external_access_slug (public_slug),
UNIQUE KEY uk_instance_external_access_short_code (short_code_hash),
KEY idx_instance_external_access_enabled (enabled, auth_mode),
CONSTRAINT fk_instance_external_access_instance
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,266 @@
CREATE TABLE IF NOT EXISTS model_invocations (
id INT AUTO_INCREMENT PRIMARY KEY,
trace_id VARCHAR(100) NOT NULL,
session_id VARCHAR(100) NULL,
request_id VARCHAR(100) NOT NULL,
user_id INT NULL,
instance_id INT NULL,
instance_mode VARCHAR(16) NULL,
runtime_type VARCHAR(32) NULL,
gateway_id VARCHAR(128) NULL,
runtime_pod_id BIGINT NULL,
model_id INT NULL,
provider_type VARCHAR(100) NOT NULL,
requested_model VARCHAR(255) NOT NULL,
actual_provider_model VARCHAR(255) NOT NULL,
traffic_class VARCHAR(50) NOT NULL,
request_payload LONGTEXT NULL,
response_payload LONGTEXT NULL,
prompt_tokens INT NOT NULL DEFAULT 0,
completion_tokens INT NOT NULL DEFAULT 0,
total_tokens INT NOT NULL DEFAULT 0,
cached_tokens INT NULL,
reasoning_tokens INT NULL,
latency_ms INT NULL,
is_streaming BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR(50) NOT NULL,
error_message TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP NULL,
INDEX idx_model_invocations_trace_id (trace_id),
INDEX idx_model_invocations_session_id (session_id),
INDEX idx_model_invocations_request_id (request_id),
INDEX idx_model_invocations_user_id (user_id),
INDEX idx_model_invocations_instance_id (instance_id),
INDEX idx_model_invocations_gateway_id (gateway_id),
INDEX idx_model_invocations_model_id (model_id),
INDEX idx_model_invocations_status (status),
INDEX idx_model_invocations_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS audit_events (
id INT AUTO_INCREMENT PRIMARY KEY,
trace_id VARCHAR(100) NOT NULL,
session_id VARCHAR(100) NULL,
request_id VARCHAR(100) NULL,
user_id INT NULL,
instance_id INT NULL,
instance_mode VARCHAR(16) NULL,
runtime_type VARCHAR(32) NULL,
gateway_id VARCHAR(128) NULL,
runtime_pod_id BIGINT NULL,
invocation_id INT NULL,
event_type VARCHAR(100) NOT NULL,
traffic_class VARCHAR(50) NOT NULL,
severity VARCHAR(20) NOT NULL,
message VARCHAR(500) NOT NULL,
details LONGTEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_audit_events_trace_id (trace_id),
INDEX idx_audit_events_request_id (request_id),
INDEX idx_audit_events_user_id (user_id),
INDEX idx_audit_events_gateway_id (gateway_id),
INDEX idx_audit_events_invocation_id (invocation_id),
INDEX idx_audit_events_event_type (event_type),
INDEX idx_audit_events_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS cost_records (
id INT AUTO_INCREMENT PRIMARY KEY,
trace_id VARCHAR(100) NOT NULL,
session_id VARCHAR(100) NULL,
request_id VARCHAR(100) NULL,
user_id INT NULL,
instance_id INT NULL,
instance_mode VARCHAR(16) NULL,
runtime_type VARCHAR(32) NULL,
gateway_id VARCHAR(128) NULL,
runtime_pod_id BIGINT NULL,
invocation_id INT NULL,
model_id INT NULL,
provider_type VARCHAR(100) NOT NULL,
model_name VARCHAR(255) NOT NULL,
currency VARCHAR(16) NOT NULL DEFAULT 'USD',
prompt_tokens INT NOT NULL DEFAULT 0,
completion_tokens INT NOT NULL DEFAULT 0,
total_tokens INT NOT NULL DEFAULT 0,
input_unit_price DECIMAL(18,8) NOT NULL DEFAULT 0,
output_unit_price DECIMAL(18,8) NOT NULL DEFAULT 0,
estimated_cost DECIMAL(18,8) NOT NULL DEFAULT 0,
actual_cost DECIMAL(18,8) NULL,
internal_cost DECIMAL(18,8) NOT NULL DEFAULT 0,
recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_cost_records_trace_id (trace_id),
INDEX idx_cost_records_user_id (user_id),
INDEX idx_cost_records_gateway_id (gateway_id),
INDEX idx_cost_records_model_id (model_id),
INDEX idx_cost_records_provider_type (provider_type),
INDEX idx_cost_records_recorded_at (recorded_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS risk_hits (
id INT AUTO_INCREMENT PRIMARY KEY,
trace_id VARCHAR(100) NOT NULL,
session_id VARCHAR(100) NULL,
request_id VARCHAR(100) NULL,
user_id INT NULL,
instance_id INT NULL,
instance_mode VARCHAR(16) NULL,
runtime_type VARCHAR(32) NULL,
gateway_id VARCHAR(128) NULL,
runtime_pod_id BIGINT NULL,
invocation_id INT NULL,
rule_id VARCHAR(100) NOT NULL,
rule_name VARCHAR(255) NOT NULL,
severity VARCHAR(20) NOT NULL,
action VARCHAR(50) NOT NULL,
match_summary VARCHAR(500) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_risk_hits_trace_id (trace_id),
INDEX idx_risk_hits_request_id (request_id),
INDEX idx_risk_hits_user_id (user_id),
INDEX idx_risk_hits_gateway_id (gateway_id),
INDEX idx_risk_hits_invocation_id (invocation_id),
INDEX idx_risk_hits_severity (severity),
INDEX idx_risk_hits_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SET @column_exists = (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'model_invocations' AND COLUMN_NAME = 'instance_mode'
);
SET @ddl = IF(@column_exists = 0, 'ALTER TABLE model_invocations ADD COLUMN instance_mode VARCHAR(16) NULL AFTER instance_id', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @column_exists = (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'model_invocations' AND COLUMN_NAME = 'runtime_type'
);
SET @ddl = IF(@column_exists = 0, 'ALTER TABLE model_invocations ADD COLUMN runtime_type VARCHAR(32) NULL AFTER instance_mode', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @column_exists = (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'model_invocations' AND COLUMN_NAME = 'gateway_id'
);
SET @ddl = IF(@column_exists = 0, 'ALTER TABLE model_invocations ADD COLUMN gateway_id VARCHAR(128) NULL AFTER runtime_type', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @column_exists = (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'model_invocations' AND COLUMN_NAME = 'runtime_pod_id'
);
SET @ddl = IF(@column_exists = 0, 'ALTER TABLE model_invocations ADD COLUMN runtime_pod_id BIGINT NULL AFTER gateway_id', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @column_exists = (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_events' AND COLUMN_NAME = 'instance_mode'
);
SET @ddl = IF(@column_exists = 0, 'ALTER TABLE audit_events ADD COLUMN instance_mode VARCHAR(16) NULL AFTER instance_id', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @column_exists = (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_events' AND COLUMN_NAME = 'runtime_type'
);
SET @ddl = IF(@column_exists = 0, 'ALTER TABLE audit_events ADD COLUMN runtime_type VARCHAR(32) NULL AFTER instance_mode', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @column_exists = (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_events' AND COLUMN_NAME = 'gateway_id'
);
SET @ddl = IF(@column_exists = 0, 'ALTER TABLE audit_events ADD COLUMN gateway_id VARCHAR(128) NULL AFTER runtime_type', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @column_exists = (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_events' AND COLUMN_NAME = 'runtime_pod_id'
);
SET @ddl = IF(@column_exists = 0, 'ALTER TABLE audit_events ADD COLUMN runtime_pod_id BIGINT NULL AFTER gateway_id', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @column_exists = (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cost_records' AND COLUMN_NAME = 'instance_mode'
);
SET @ddl = IF(@column_exists = 0, 'ALTER TABLE cost_records ADD COLUMN instance_mode VARCHAR(16) NULL AFTER instance_id', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @column_exists = (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cost_records' AND COLUMN_NAME = 'runtime_type'
);
SET @ddl = IF(@column_exists = 0, 'ALTER TABLE cost_records ADD COLUMN runtime_type VARCHAR(32) NULL AFTER instance_mode', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @column_exists = (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cost_records' AND COLUMN_NAME = 'gateway_id'
);
SET @ddl = IF(@column_exists = 0, 'ALTER TABLE cost_records ADD COLUMN gateway_id VARCHAR(128) NULL AFTER runtime_type', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @column_exists = (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cost_records' AND COLUMN_NAME = 'runtime_pod_id'
);
SET @ddl = IF(@column_exists = 0, 'ALTER TABLE cost_records ADD COLUMN runtime_pod_id BIGINT NULL AFTER gateway_id', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @column_exists = (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'risk_hits' AND COLUMN_NAME = 'instance_mode'
);
SET @ddl = IF(@column_exists = 0, 'ALTER TABLE risk_hits ADD COLUMN instance_mode VARCHAR(16) NULL AFTER instance_id', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @column_exists = (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'risk_hits' AND COLUMN_NAME = 'runtime_type'
);
SET @ddl = IF(@column_exists = 0, 'ALTER TABLE risk_hits ADD COLUMN runtime_type VARCHAR(32) NULL AFTER instance_mode', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @column_exists = (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'risk_hits' AND COLUMN_NAME = 'gateway_id'
);
SET @ddl = IF(@column_exists = 0, 'ALTER TABLE risk_hits ADD COLUMN gateway_id VARCHAR(128) NULL AFTER runtime_type', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @column_exists = (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'risk_hits' AND COLUMN_NAME = 'runtime_pod_id'
);
SET @ddl = IF(@column_exists = 0, 'ALTER TABLE risk_hits ADD COLUMN runtime_pod_id BIGINT NULL AFTER gateway_id', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @index_exists = (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'model_invocations' AND INDEX_NAME = 'idx_model_invocations_gateway_id'
);
SET @ddl = IF(@index_exists = 0, 'ALTER TABLE model_invocations ADD INDEX idx_model_invocations_gateway_id (gateway_id)', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @index_exists = (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_events' AND INDEX_NAME = 'idx_audit_events_gateway_id'
);
SET @ddl = IF(@index_exists = 0, 'ALTER TABLE audit_events ADD INDEX idx_audit_events_gateway_id (gateway_id)', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @index_exists = (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cost_records' AND INDEX_NAME = 'idx_cost_records_gateway_id'
);
SET @ddl = IF(@index_exists = 0, 'ALTER TABLE cost_records ADD INDEX idx_cost_records_gateway_id (gateway_id)', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @index_exists = (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'risk_hits' AND INDEX_NAME = 'idx_risk_hits_gateway_id'
);
SET @ddl = IF(@index_exists = 0, 'ALTER TABLE risk_hits ADD INDEX idx_risk_hits_gateway_id (gateway_id)', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -0,0 +1,47 @@
SET @column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'instance_external_access'
AND COLUMN_NAME = 'short_code_hash'
);
SET @ddl = IF(
@column_exists = 0,
'ALTER TABLE instance_external_access ADD COLUMN short_code_hash VARCHAR(128) NULL AFTER public_token_hash',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @public_slug_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'instance_external_access'
AND COLUMN_NAME = 'public_slug'
);
SET @ddl = IF(
@public_slug_exists > 0,
'ALTER TABLE instance_external_access MODIFY COLUMN public_slug VARCHAR(64) NULL',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @index_exists = (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'instance_external_access'
AND INDEX_NAME = 'uk_instance_external_access_short_code'
);
SET @ddl = IF(
@index_exists = 0,
'ALTER TABLE instance_external_access ADD UNIQUE KEY uk_instance_external_access_short_code (short_code_hash)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,15 @@
SET @column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'instance_external_access'
AND COLUMN_NAME = 'password_value'
);
SET @ddl = IF(
@column_exists = 0,
'ALTER TABLE instance_external_access ADD COLUMN password_value VARCHAR(128) NULL AFTER api_key_hash',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,31 @@
UPDATE system_image_settings
SET
image = 'ghcr.io/yuan-lab-llm/agentsruntime/hermes-lite:latest',
display_name = 'Hermes Lite',
is_enabled = TRUE
WHERE instance_type = 'hermes'
AND runtime_type = 'shell'
AND image IN (
'ghcr.io/yuan-lab-llm/agentsruntime/hermes-shell:latest',
'registry.example.com/your-custom-shell-image:latest'
);
INSERT INTO system_image_settings (instance_type, runtime_type, display_name, image, is_enabled)
SELECT
'hermes',
'shell',
'Hermes Lite',
'ghcr.io/yuan-lab-llm/agentsruntime/hermes-lite:latest',
TRUE
WHERE NOT EXISTS (
SELECT 1
FROM system_image_settings
WHERE instance_type = 'hermes'
AND runtime_type = 'shell'
)
AND NOT EXISTS (
SELECT 1
FROM system_image_settings
WHERE instance_type = 'hermes'
AND is_enabled = FALSE
);
@@ -0,0 +1,27 @@
SET @team_member_instance_mode_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'team_members'
AND COLUMN_NAME = 'instance_mode'
);
SET @team_member_instance_mode_column_sql = IF(
@team_member_instance_mode_column_exists = 0,
'ALTER TABLE team_members ADD COLUMN instance_mode ENUM(''lite'', ''pro'') NOT NULL DEFAULT ''lite'' AFTER runtime_type',
'SELECT 1'
);
PREPARE team_member_instance_mode_column_stmt FROM @team_member_instance_mode_column_sql;
EXECUTE team_member_instance_mode_column_stmt;
DEALLOCATE PREPARE team_member_instance_mode_column_stmt;
UPDATE team_members tm
LEFT JOIN instances i ON i.id = tm.instance_id
SET tm.instance_mode = CASE
WHEN i.instance_mode IN ('lite', 'pro') THEN i.instance_mode
WHEN i.runtime_type = 'gateway' THEN 'lite'
WHEN i.id IS NOT NULL THEN 'pro'
ELSE tm.instance_mode
END
WHERE tm.instance_mode IS NULL
OR tm.instance_mode = ''
OR (i.id IS NOT NULL AND tm.instance_mode <> COALESCE(i.instance_mode, CASE WHEN i.runtime_type = 'gateway' THEN 'lite' ELSE 'pro' END));
@@ -0,0 +1,26 @@
ALTER TABLE system_image_settings
MODIFY COLUMN runtime_type ENUM('desktop', 'shell', 'gateway') NOT NULL DEFAULT 'desktop';
UPDATE system_image_settings
SET runtime_type = 'gateway',
display_name = 'OpenClaw Lite'
WHERE instance_type = 'openclaw'
AND runtime_type = 'shell';
UPDATE system_image_settings
SET runtime_type = 'gateway',
display_name = 'Hermes Lite'
WHERE instance_type = 'hermes'
AND runtime_type = 'shell';
UPDATE system_image_settings
SET display_name = 'OpenClaw Pro'
WHERE instance_type = 'openclaw'
AND runtime_type = 'desktop'
AND display_name IN ('OpenClaw Desktop', 'OpenClaw Runtime');
UPDATE system_image_settings
SET display_name = 'Hermes Pro'
WHERE instance_type = 'hermes'
AND runtime_type = 'desktop'
AND display_name IN ('Hermes Runtime', 'Hermes Desktop');
@@ -0,0 +1,54 @@
UPDATE system_image_settings
SET
image = 'ghcr.io/yuan-lab-llm/agentsruntime/openclaw-lite:latest',
display_name = 'OpenClaw Lite',
is_enabled = TRUE
WHERE instance_type = 'openclaw'
AND runtime_type IN ('shell', 'gateway')
AND image IN (
'ghcr.io/yuan-lab-llm/agentsruntime/openclaw-shell:latest',
'172.16.1.12:5010/openclaw-shell:local5',
'172.16.1.12:5010/openclaw:v2dev'
);
UPDATE system_image_settings
SET
image = 'ghcr.io/yuan-lab-llm/agentsruntime/hermes-lite:latest',
display_name = 'Hermes Lite',
is_enabled = TRUE
WHERE instance_type = 'hermes'
AND runtime_type IN ('shell', 'gateway')
AND image IN (
'ghcr.io/yuan-lab-llm/agentsruntime/hermes-shell:latest',
'172.16.1.12:5010/hermes:codex-shared-agent-tui-bundle-20260609153019',
'172.16.1.12:5010/hermes:team-lite-tui-dist-20260612174205',
'registry.example.com/your-custom-shell-image:latest'
);
INSERT INTO system_image_settings (instance_type, runtime_type, display_name, image, is_enabled)
SELECT
'openclaw',
'gateway',
'OpenClaw Lite',
'ghcr.io/yuan-lab-llm/agentsruntime/openclaw-lite:latest',
TRUE
WHERE NOT EXISTS (
SELECT 1
FROM system_image_settings
WHERE instance_type = 'openclaw'
AND runtime_type IN ('shell', 'gateway')
);
INSERT INTO system_image_settings (instance_type, runtime_type, display_name, image, is_enabled)
SELECT
'hermes',
'gateway',
'Hermes Lite',
'ghcr.io/yuan-lab-llm/agentsruntime/hermes-lite:latest',
TRUE
WHERE NOT EXISTS (
SELECT 1
FROM system_image_settings
WHERE instance_type = 'hermes'
AND runtime_type IN ('shell', 'gateway')
);
@@ -0,0 +1,34 @@
ALTER TABLE team_events
ADD COLUMN event_id VARCHAR(128) NULL AFTER id,
ADD COLUMN completion_id VARCHAR(255) NULL AFTER event_id,
ADD COLUMN sequence_no BIGINT NOT NULL DEFAULT 0 AFTER completion_id;
UPDATE team_events SET sequence_no = id WHERE sequence_no = 0;
ALTER TABLE team_events
ADD UNIQUE KEY uk_team_events_event_id (team_id, event_id),
ADD UNIQUE KEY uk_team_events_completion_id (team_id, completion_id),
ADD INDEX idx_team_events_team_sequence (team_id, sequence_no);
CREATE TABLE IF NOT EXISTS team_work_items (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
root_task_id INT NOT NULL,
work_id VARCHAR(255) NOT NULL,
owner_member_id INT NULL,
title VARCHAR(500) NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'pending',
depends_on_json LONGTEXT NULL,
result_json LONGTEXT NULL,
artifact_refs_json LONGTEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
FOREIGN KEY (root_task_id) REFERENCES team_tasks(id) ON DELETE CASCADE,
FOREIGN KEY (owner_member_id) REFERENCES team_members(id) ON DELETE SET NULL,
UNIQUE KEY uk_team_work_items_work (team_id, root_task_id, work_id),
INDEX idx_team_work_items_root_status (root_task_id, status),
INDEX idx_team_work_items_owner_status (owner_member_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS team_event_outbox (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
source_event_id VARCHAR(255) NOT NULL,
destination VARCHAR(255) NOT NULL,
message_id VARCHAR(255) NOT NULL,
payload_json LONGTEXT NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
available_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_error TEXT NULL,
delivered_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
UNIQUE KEY uk_team_event_outbox_message (team_id, destination, message_id),
INDEX idx_team_event_outbox_pending (status, available_at),
INDEX idx_team_event_outbox_team (team_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,48 @@
ALTER TABLE team_tasks
ADD COLUMN workflow_state VARCHAR(40) NOT NULL DEFAULT 'open' AFTER status,
ADD COLUMN plan_version BIGINT NOT NULL DEFAULT 0 AFTER workflow_state,
ADD COLUMN ledger_version BIGINT NOT NULL DEFAULT 0 AFTER plan_version,
ADD COLUMN current_phase_id VARCHAR(255) NULL AFTER ledger_version,
ADD COLUMN accepted_completion_id VARCHAR(255) NULL AFTER current_phase_id,
ADD UNIQUE KEY uk_team_tasks_accepted_completion (team_id, accepted_completion_id),
ADD INDEX idx_team_tasks_workflow (team_id, workflow_state, status);
ALTER TABLE team_work_items
ADD COLUMN assignment_id VARCHAR(255) NULL AFTER work_id,
ADD COLUMN canonical_work_id VARCHAR(255) NULL AFTER assignment_id,
ADD COLUMN phase_id VARCHAR(255) NULL AFTER canonical_work_id,
ADD COLUMN revision INT NOT NULL DEFAULT 1 AFTER phase_id,
ADD COLUMN required_for_root BOOLEAN NOT NULL DEFAULT TRUE AFTER revision,
ADD COLUMN superseded_by VARCHAR(255) NULL AFTER required_for_root,
ADD COLUMN review_required BOOLEAN NOT NULL DEFAULT FALSE AFTER superseded_by,
ADD COLUMN validated_revision INT NULL AFTER review_required,
ADD INDEX idx_team_work_items_assignment (root_task_id, assignment_id, revision),
ADD INDEX idx_team_work_items_phase (root_task_id, phase_id, status);
UPDATE team_work_items
SET assignment_id = work_id,
canonical_work_id = work_id,
phase_id = 'legacy'
WHERE assignment_id IS NULL OR canonical_work_id IS NULL OR phase_id IS NULL;
CREATE TABLE IF NOT EXISTS team_workflow_phases (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
root_task_id INT NOT NULL,
phase_id VARCHAR(255) NOT NULL,
plan_version BIGINT NOT NULL DEFAULT 1,
sequence_no INT NOT NULL DEFAULT 0,
status VARCHAR(40) NOT NULL DEFAULT 'planned',
required_for_root BOOLEAN NOT NULL DEFAULT TRUE,
decision_required BOOLEAN NOT NULL DEFAULT FALSE,
depends_on_json LONGTEXT NULL,
next_phase_id VARCHAR(255) NULL,
completion_policy VARCHAR(80) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
FOREIGN KEY (root_task_id) REFERENCES team_tasks(id) ON DELETE CASCADE,
UNIQUE KEY uk_team_workflow_phase (root_task_id, phase_id, plan_version),
INDEX idx_team_workflow_phase_state (root_task_id, plan_version, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+162
View File
@@ -0,0 +1,162 @@
package db
import (
"reflect"
"strings"
"testing"
)
func TestSplitSQLStatements(t *testing.T) {
input := `
-- create table comment
CREATE TABLE demo (
id INT PRIMARY KEY,
note VARCHAR(255) DEFAULT 'hello;world'
);
/* block comment */
INSERT INTO demo (id, note) VALUES (1, 'value');
UPDATE demo SET note = "a;quoted" WHERE id = 1;
`
got := splitSQLStatements(input)
want := []string{
"CREATE TABLE demo (\n id INT PRIMARY KEY,\n note VARCHAR(255) DEFAULT 'hello;world'\n)",
"INSERT INTO demo (id, note) VALUES (1, 'value')",
`UPDATE demo SET note = "a;quoted" WHERE id = 1`,
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected statements:\nwant: %#v\ngot: %#v", want, got)
}
}
func TestMigration023IsEmbedded(t *testing.T) {
files, err := embeddedMigrations.ReadDir("migrations")
if err != nil {
t.Fatalf("read embedded migrations: %v", err)
}
found := false
for _, file := range files {
if file.Name() == "023_add_runtime_pool_v2.sql" {
found = true
break
}
}
if !found {
t.Fatalf("migration 023_add_runtime_pool_v2.sql is not embedded")
}
}
func TestMigration034UpdatesLiteDefaultImages(t *testing.T) {
raw, err := embeddedMigrations.ReadFile("migrations/034_update_lite_default_images.sql")
if err != nil {
t.Fatalf("read migration 034: %v", err)
}
sql := string(raw)
for _, image := range []string{
"ghcr.io/yuan-lab-llm/agentsruntime/openclaw-lite:latest",
"ghcr.io/yuan-lab-llm/agentsruntime/hermes-lite:latest",
} {
if !strings.Contains(sql, image) {
t.Fatalf("migration 034 must update lite image %s", image)
}
}
}
func TestMigration023IsRetrySafe(t *testing.T) {
raw, err := embeddedMigrations.ReadFile("migrations/023_add_runtime_pool_v2.sql")
if err != nil {
t.Fatalf("read migration 023: %v", err)
}
sql := string(raw)
if !strings.Contains(sql, "information_schema.COLUMNS") {
t.Fatalf("migration 023 must guard instance column additions with information_schema.COLUMNS")
}
for _, column := range []string{
"workspace_path",
"workspace_usage_bytes",
"runtime_generation",
"runtime_error_message",
} {
if !strings.Contains(sql, "COLUMN_NAME = '"+column+"'") {
t.Fatalf("migration 023 must guard %s column addition", column)
}
}
for _, table := range []string{
"runtime_pods",
"instance_runtime_bindings",
"runtime_rollouts",
"workspace_file_audits",
} {
if !strings.Contains(sql, "CREATE TABLE IF NOT EXISTS "+table) {
t.Fatalf("migration 023 must create %s idempotently", table)
}
}
}
func TestMigration035HardensTeamEventProtocol(t *testing.T) {
raw, err := embeddedMigrations.ReadFile("migrations/035_harden_team_event_protocol.sql")
if err != nil {
t.Fatalf("read migration 035: %v", err)
}
sql := string(raw)
for _, required := range []string{
"event_id",
"completion_id",
"sequence_no",
"uk_team_events_event_id",
"uk_team_events_completion_id",
"CREATE TABLE IF NOT EXISTS team_work_items",
"uk_team_work_items_work",
} {
if !strings.Contains(sql, required) {
t.Fatalf("migration 035 must contain %s", required)
}
}
}
func TestMigration036AddsReliableTeamEventOutbox(t *testing.T) {
raw, err := embeddedMigrations.ReadFile("migrations/036_add_team_event_outbox.sql")
if err != nil {
t.Fatalf("read migration 036: %v", err)
}
sql := string(raw)
for _, required := range []string{
"CREATE TABLE IF NOT EXISTS team_event_outbox",
"uk_team_event_outbox_message",
"idx_team_event_outbox_pending",
"source_event_id",
"available_at",
} {
if !strings.Contains(sql, required) {
t.Fatalf("migration 036 must contain %s", required)
}
}
}
func TestMigration037AddsTeamWorkflowLedger(t *testing.T) {
raw, err := embeddedMigrations.ReadFile("migrations/037_add_team_workflow_ledger.sql")
if err != nil {
t.Fatalf("read migration 037: %v", err)
}
sql := string(raw)
for _, required := range []string{
"workflow_state",
"plan_version",
"ledger_version",
"accepted_completion_id",
"assignment_id",
"canonical_work_id",
"phase_id",
"required_for_root",
"CREATE TABLE IF NOT EXISTS team_workflow_phases",
"uk_team_workflow_phase",
} {
if !strings.Contains(sql, required) {
t.Fatalf("migration 037 must contain %s", required)
}
}
}
@@ -0,0 +1,78 @@
package db
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
func TestRuntimeManifestsStartHermesRuntime(t *testing.T) {
repoRoot := filepath.Clean(filepath.Join("..", "..", ".."))
for _, manifest := range deploymentRuntimeManifests(repoRoot) {
t.Run(manifest, func(t *testing.T) {
raw, err := os.ReadFile(manifest)
if err != nil {
t.Fatalf("read manifest: %v", err)
}
pattern := regexp.MustCompile(`(?s)name:\s+hermes-runtime.*?spec:\s+replicas:\s+([0-9]+)`)
matches := pattern.FindSubmatch(raw)
if len(matches) != 2 {
t.Fatalf("could not find hermes-runtime replicas in %s", manifest)
}
if string(matches[1]) != "1" {
t.Fatalf("expected hermes-runtime replicas 1 in %s, got %s", manifest, matches[1])
}
})
}
}
func TestRuntimeManifestsSeedLiteDefaultImages(t *testing.T) {
repoRoot := filepath.Clean(filepath.Join("..", "..", ".."))
for _, manifest := range append(deploymentRuntimeManifests(repoRoot), filepath.Join(repoRoot, "backend", "deployments", "k8s", "clawreef-incluster.yaml")) {
t.Run(manifest, func(t *testing.T) {
raw, err := os.ReadFile(manifest)
if err != nil {
t.Fatalf("read manifest: %v", err)
}
for _, image := range []string{
"ghcr.io/yuan-lab-llm/agentsruntime/openclaw-lite:latest",
"ghcr.io/yuan-lab-llm/agentsruntime/hermes-lite:latest",
} {
if !strings.Contains(string(raw), image) {
t.Fatalf("manifest %s must seed lite image %s", manifest, image)
}
}
})
}
}
func TestRuntimeManifestsExposeOpenClawGatewayOnPodIP(t *testing.T) {
repoRoot := filepath.Clean(filepath.Join("..", "..", ".."))
for _, manifest := range deploymentRuntimeManifests(repoRoot) {
t.Run(manifest, func(t *testing.T) {
raw, err := os.ReadFile(manifest)
if err != nil {
t.Fatalf("read manifest: %v", err)
}
text := string(raw)
want := "/usr/local/bin/openclaw gateway run --allow-unconfigured --auth token --bind lan --force"
if !strings.Contains(text, want) {
t.Fatalf("manifest %s must expose OpenClaw gateway on the pod network with %q", manifest, want)
}
if strings.Contains(text, "--auth token --bind auto --force") {
t.Fatalf("manifest %s must not use OpenClaw --bind auto because it can bind to loopback inside runtime pods", manifest)
}
})
}
}
func deploymentRuntimeManifests(repoRoot string) []string {
return []string{
filepath.Join(repoRoot, "deployments", "k8s", "cluster", "clawmanager.yaml"),
filepath.Join(repoRoot, "deployments", "k8s", "single-node", "clawmanager.yaml"),
filepath.Join(repoRoot, "deployments", "k3s", "cluster", "clawmanager.yaml"),
filepath.Join(repoRoot, "deployments", "k3s", "single-node", "clawmanager.yaml"),
}
}
+258
View File
@@ -0,0 +1,258 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"time"
"clawreef/internal/services"
"clawreef/internal/utils"
"github.com/gin-gonic/gin"
)
type AgentHandler struct {
agentService services.InstanceAgentService
commandService services.InstanceCommandService
runtimeStatusService services.InstanceRuntimeStatusService
configRevisionService services.InstanceConfigRevisionService
skillService services.SkillService
}
func NewAgentHandler(agentService services.InstanceAgentService, commandService services.InstanceCommandService, runtimeStatusService services.InstanceRuntimeStatusService, configRevisionService services.InstanceConfigRevisionService, skillService services.SkillService) *AgentHandler {
return &AgentHandler{
agentService: agentService,
commandService: commandService,
runtimeStatusService: runtimeStatusService,
configRevisionService: configRevisionService,
skillService: skillService,
}
}
func (h *AgentHandler) Register(c *gin.Context) {
bootstrapToken := extractBearerToken(c.GetHeader("Authorization"))
if bootstrapToken == "" {
utils.Error(c, http.StatusUnauthorized, "Agent bootstrap token is required")
return
}
var req services.AgentRegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
resp, err := h.agentService.Register(bootstrapToken, req, c.ClientIP())
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Agent registered successfully", resp)
}
func (h *AgentHandler) Heartbeat(c *gin.Context) {
session, ok := h.authenticateAgentSession(c)
if !ok {
return
}
var req services.AgentHeartbeatRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
if req.Timestamp.IsZero() {
req.Timestamp = time.Now().UTC()
}
resp, err := h.agentService.Heartbeat(session, req, c.ClientIP())
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Agent heartbeat accepted", resp)
}
func (h *AgentHandler) NextCommand(c *gin.Context) {
session, ok := h.authenticateAgentSession(c)
if !ok {
return
}
command, err := h.commandService.GetNextForAgent(session)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Agent next command retrieved successfully", gin.H{"command": command})
}
func (h *AgentHandler) StartCommand(c *gin.Context) {
session, ok := h.authenticateAgentSession(c)
if !ok {
return
}
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid command ID")
return
}
var req struct {
AgentID string `json:"agent_id" binding:"required"`
StartedAt *time.Time `json:"started_at,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
if req.AgentID != session.Agent.AgentID {
utils.Error(c, http.StatusForbidden, "Agent ID does not match session")
return
}
if err := h.commandService.MarkStarted(session, commandID, req.StartedAt); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Agent command marked as started", nil)
}
func (h *AgentHandler) FinishCommand(c *gin.Context) {
session, ok := h.authenticateAgentSession(c)
if !ok {
return
}
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid command ID")
return
}
var req services.AgentCommandFinishRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
if err := h.commandService.MarkFinished(session, commandID, req); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Agent command result accepted", nil)
}
func (h *AgentHandler) ReportState(c *gin.Context) {
session, ok := h.authenticateAgentSession(c)
if !ok {
return
}
var req services.AgentStateReportRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
if err := h.runtimeStatusService.Report(session, req, c.ClientIP()); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Agent state reported successfully", nil)
}
func (h *AgentHandler) GetConfigRevision(c *gin.Context) {
session, ok := h.authenticateAgentSession(c)
if !ok {
return
}
revisionID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid config revision ID")
return
}
revision, err := h.configRevisionService.GetByID(revisionID)
if err != nil {
utils.HandleError(c, err)
return
}
if revision.InstanceID != session.Instance.ID {
utils.Error(c, http.StatusForbidden, "Access denied")
return
}
utils.Success(c, http.StatusOK, "Config revision retrieved successfully", gin.H{"revision": revision})
}
func (h *AgentHandler) ReportSkillInventory(c *gin.Context) {
session, ok := h.authenticateAgentSession(c)
if !ok {
return
}
var req services.AgentSkillInventoryReportRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
if strings.TrimSpace(req.AgentID) != "" && strings.TrimSpace(req.AgentID) != session.Agent.AgentID {
utils.Error(c, http.StatusForbidden, "Agent ID does not match session")
return
}
if err := h.skillService.SyncAgentSkills(session.Instance.ID, req); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Agent skill inventory reported successfully", nil)
}
func (h *AgentHandler) UploadSkillPackage(c *gin.Context) {
session, ok := h.authenticateAgentSession(c)
if !ok {
return
}
fileHeader, err := c.FormFile("file")
if err != nil {
utils.Error(c, http.StatusBadRequest, "file is required")
return
}
req := services.AgentSkillPackageUploadRequest{
AgentID: strings.TrimSpace(c.PostForm("agent_id")),
SkillID: strings.TrimSpace(c.PostForm("skill_id")),
SkillVersion: strings.TrimSpace(c.PostForm("skill_version")),
Identifier: strings.TrimSpace(c.PostForm("identifier")),
ContentMD5: strings.TrimSpace(c.PostForm("content_md5")),
Source: strings.TrimSpace(c.PostForm("source")),
}
if req.AgentID != "" && req.AgentID != session.Agent.AgentID {
utils.Error(c, http.StatusForbidden, "Agent ID does not match session")
return
}
item, err := h.skillService.UploadAgentSkillPackage(c.Request.Context(), session.Instance.ID, req, fileHeader)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "Agent skill package uploaded successfully", item)
}
func (h *AgentHandler) authenticateAgentSession(c *gin.Context) (*services.AgentSession, bool) {
sessionToken := extractBearerToken(c.GetHeader("Authorization"))
if sessionToken == "" {
utils.Error(c, http.StatusUnauthorized, "Agent session token is required")
return nil, false
}
session, err := h.agentService.AuthenticateSession(sessionToken)
if err != nil {
utils.HandleError(c, err)
return nil, false
}
return session, true
}
func extractBearerToken(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parts := strings.SplitN(raw, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
return ""
}
return strings.TrimSpace(parts[1])
}
@@ -0,0 +1,160 @@
package handlers
import (
"encoding/json"
"io"
"net/http"
"strings"
"clawreef/internal/aigateway"
"clawreef/internal/utils"
"github.com/gin-gonic/gin"
)
// AIGatewayHandler exposes AI gateway endpoints.
type AIGatewayHandler struct {
service aigateway.Service
}
// NewAIGatewayHandler creates a new AI gateway handler.
func NewAIGatewayHandler(service aigateway.Service) *AIGatewayHandler {
return &AIGatewayHandler{service: service}
}
// ListModels returns active models available to the current user.
func (h *AIGatewayHandler) ListModels(c *gin.Context) {
items, err := h.service.ListAvailableModels()
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Available gateway models retrieved successfully", gin.H{
"items": items,
})
}
// ChatCompletions proxies a governed chat completion request.
func (h *AIGatewayHandler) ChatCompletions(c *gin.Context) {
rawBody, err := io.ReadAll(c.Request.Body)
if err != nil {
utils.Error(c, http.StatusBadRequest, "Failed to read request body")
return
}
var req aigateway.ChatCompletionRequest
if err := json.Unmarshal(rawBody, &req); err != nil {
utils.ValidationError(c, err)
return
}
req.RawBody = rawBody
if req.SessionID == nil {
if sessionKey := strings.TrimSpace(c.GetHeader("x-openclaw-session-key")); sessionKey != "" {
req.SessionID = &sessionKey
}
}
if req.TraceID == nil {
if runID := strings.TrimSpace(c.GetHeader("x-openclaw-run-id")); runID != "" {
req.TraceID = &runID
}
}
userID, exists := c.Get("userID")
if !exists {
utils.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
if instanceID, exists := c.Get("instanceID"); exists {
switch value := instanceID.(type) {
case int:
if req.InstanceID == nil {
req.InstanceID = &value
} else if *req.InstanceID != value {
utils.Error(c, http.StatusForbidden, "Gateway token does not match requested instance")
return
}
}
}
if !setStringMetadata(c, &req.InstanceMode, "instanceMode") ||
!setStringMetadata(c, &req.RuntimeType, "runtimeType") ||
!setStringMetadata(c, &req.GatewayID, "gatewayID") ||
!setInt64Metadata(c, &req.RuntimePodID, "runtimePodID") {
utils.Error(c, http.StatusForbidden, "Gateway token metadata does not match request")
return
}
if req.Stream {
traceID, err := h.service.StreamChatCompletions(c.Request.Context(), userID.(int), req, c.Writer)
if traceID != "" {
c.Header("X-Trace-ID", traceID)
}
if err != nil {
if !c.Writer.Written() {
utils.HandleError(c, err)
}
}
return
}
response, traceID, err := h.service.ChatCompletions(c.Request.Context(), userID.(int), req)
if err != nil {
c.Header("X-Trace-ID", traceID)
utils.HandleError(c, err)
return
}
c.Header("X-Trace-ID", traceID)
for key, values := range response.Headers {
for _, value := range values {
c.Writer.Header().Add(key, value)
}
}
c.Status(response.StatusCode)
_, _ = c.Writer.Write(response.Body)
}
func setStringMetadata(c *gin.Context, field **string, key string) bool {
raw, exists := c.Get(key)
if !exists {
return true
}
value, ok := raw.(string)
if !ok {
return true
}
value = strings.TrimSpace(value)
if value == "" {
return true
}
if *field == nil {
*field = &value
return true
}
return strings.TrimSpace(**field) == value
}
func setInt64Metadata(c *gin.Context, field **int64, key string) bool {
raw, exists := c.Get(key)
if !exists {
return true
}
var value int64
switch typed := raw.(type) {
case int64:
value = typed
case int:
value = int64(typed)
default:
return true
}
if value <= 0 {
return true
}
if *field == nil {
*field = &value
return true
}
return **field == value
}
@@ -0,0 +1,92 @@
package handlers
import (
"net/http"
"clawreef/internal/services"
"clawreef/internal/utils"
"github.com/gin-gonic/gin"
)
// AIObservabilityHandler exposes admin reporting endpoints for audit and cost data.
type AIObservabilityHandler struct {
service services.AIObservabilityService
}
// AuditQueryRequest binds supported audit list filters.
type AuditQueryRequest struct {
Page int `form:"page,default=1"`
Limit int `form:"limit,default=100"`
Search string `form:"search"`
Status string `form:"status"`
Model string `form:"model"`
}
// CostQueryRequest binds supported cost query filters.
type CostQueryRequest struct {
Page int `form:"page,default=1"`
Limit int `form:"limit,default=100"`
Search string `form:"search"`
}
// NewAIObservabilityHandler creates a new observability handler.
func NewAIObservabilityHandler(service services.AIObservabilityService) *AIObservabilityHandler {
return &AIObservabilityHandler{service: service}
}
// ListAuditItems returns recent audit entries for AI model invocations.
func (h *AIObservabilityHandler) ListAuditItems(c *gin.Context) {
var req AuditQueryRequest
if err := c.ShouldBindQuery(&req); err != nil {
utils.ValidationError(c, err)
return
}
items, err := h.service.ListAuditItems(services.AuditQuery{
Page: req.Page,
Limit: req.Limit,
Search: req.Search,
Status: req.Status,
Model: req.Model,
})
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "AI audit items retrieved successfully", items)
}
// GetTraceDetail returns full trace detail for a model invocation trace.
func (h *AIObservabilityHandler) GetTraceDetail(c *gin.Context) {
traceID := c.Param("traceId")
detail, err := h.service.GetTraceDetail(traceID)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "AI trace detail retrieved successfully", detail)
}
// GetCostOverview returns token and money overview data for admin reporting.
func (h *AIObservabilityHandler) GetCostOverview(c *gin.Context) {
var req CostQueryRequest
if err := c.ShouldBindQuery(&req); err != nil {
utils.ValidationError(c, err)
return
}
overview, err := h.service.GetCostOverview(services.CostQuery{
Page: req.Page,
Limit: req.Limit,
Search: req.Search,
})
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "AI cost overview retrieved successfully", overview)
}
+140
View File
@@ -0,0 +1,140 @@
package handlers
import (
"net/http"
"clawreef/internal/services"
"clawreef/internal/utils"
"github.com/gin-gonic/gin"
)
// AuthHandler handles authentication-related requests
type AuthHandler struct {
authService services.AuthService
}
// NewAuthHandler creates a new auth handler
func NewAuthHandler(authService services.AuthService) *AuthHandler {
return &AuthHandler{authService: authService}
}
// RegisterRequest represents a registration request
type RegisterRequest struct {
Username string `json:"username" binding:"required,min=3,max=32,alphanum"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
}
// LoginRequest represents a login request
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// RefreshTokenRequest represents a refresh token request
type RefreshTokenRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
type ChangePasswordRequest struct {
CurrentPassword string `json:"current_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8"`
}
// Register handles user registration
func (h *AuthHandler) Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
user, err := h.authService.Register(req.Username, req.Email, req.Password)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "User registered successfully", user)
}
// Login handles user login
func (h *AuthHandler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
tokenPair, err := h.authService.Login(req.Username, req.Password)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Login successful", tokenPair)
}
// RefreshToken handles token refresh
func (h *AuthHandler) RefreshToken(c *gin.Context) {
var req RefreshTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
tokenPair, err := h.authService.RefreshToken(req.RefreshToken)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Token refreshed successfully", tokenPair)
}
// Logout handles user logout
func (h *AuthHandler) Logout(c *gin.Context) {
// In a stateless JWT system, logout is handled client-side
// by removing the token from storage
utils.Success(c, http.StatusOK, "Logout successful", nil)
}
// GetCurrentUser gets the current authenticated user
func (h *AuthHandler) GetCurrentUser(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
utils.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
user, err := h.authService.GetCurrentUser(userID.(int))
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "User retrieved successfully", user)
}
// ChangePassword changes the current user's password
func (h *AuthHandler) ChangePassword(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
utils.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
var req ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
if err := h.authService.ChangePassword(userID.(int), req.CurrentPassword, req.NewPassword); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Password changed successfully", nil)
}
@@ -0,0 +1,28 @@
package handlers
import (
"net/http"
"clawreef/internal/services"
"clawreef/internal/utils"
"github.com/gin-gonic/gin"
)
type ClusterResourceHandler struct {
clusterResourceService services.ClusterResourceService
}
func NewClusterResourceHandler(clusterResourceService services.ClusterResourceService) *ClusterResourceHandler {
return &ClusterResourceHandler{clusterResourceService: clusterResourceService}
}
func (h *ClusterResourceHandler) GetOverview(c *gin.Context) {
overview, err := h.clusterResourceService.GetOverview(c.Request.Context())
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Cluster resource overview retrieved successfully", overview)
}
@@ -0,0 +1,122 @@
package handlers
import (
"io"
"net"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// EgressProxyHandler provides a minimal forward proxy for ordinary HTTP/HTTPS traffic.
type EgressProxyHandler struct {
transport *http.Transport
}
// NewEgressProxyHandler creates a new egress proxy handler.
func NewEgressProxyHandler() *EgressProxyHandler {
return &EgressProxyHandler{
transport: &http.Transport{
Proxy: nil,
DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
ForceAttemptHTTP2: false,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
},
}
}
// Handle proxies ordinary HTTP or HTTPS CONNECT traffic.
func (h *EgressProxyHandler) Handle(c *gin.Context) {
if strings.EqualFold(c.Request.Method, http.MethodConnect) {
h.handleConnect(c)
return
}
if c.Request.URL == nil || c.Request.URL.Scheme == "" || c.Request.URL.Host == "" {
c.Status(http.StatusNotFound)
return
}
outReq := c.Request.Clone(c.Request.Context())
outReq.RequestURI = ""
removeHopHeaders(outReq.Header)
resp, err := h.transport.RoundTrip(outReq)
if err != nil {
c.String(http.StatusBadGateway, "proxy upstream error: %v", err)
return
}
defer resp.Body.Close()
removeHopHeaders(resp.Header)
copyHeaders(c.Writer.Header(), resp.Header)
c.Status(resp.StatusCode)
_, _ = io.Copy(c.Writer, resp.Body)
}
func (h *EgressProxyHandler) handleConnect(c *gin.Context) {
target := strings.TrimSpace(c.Request.Host)
if target == "" {
c.String(http.StatusBadRequest, "missing CONNECT target")
return
}
upstreamConn, err := net.DialTimeout("tcp", target, 30*time.Second)
if err != nil {
c.String(http.StatusBadGateway, "proxy connect error: %v", err)
return
}
hijacker, ok := c.Writer.(http.Hijacker)
if !ok {
_ = upstreamConn.Close()
c.String(http.StatusInternalServerError, "proxy hijacking not supported")
return
}
clientConn, _, err := hijacker.Hijack()
if err != nil {
_ = upstreamConn.Close()
return
}
_, _ = clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
go tunnelConns(upstreamConn, clientConn)
go tunnelConns(clientConn, upstreamConn)
}
func tunnelConns(dst net.Conn, src net.Conn) {
defer dst.Close()
defer src.Close()
_, _ = io.Copy(dst, src)
}
func copyHeaders(dst, src http.Header) {
for key, values := range src {
for _, value := range values {
dst.Add(key, value)
}
}
}
func removeHopHeaders(headers http.Header) {
for _, key := range []string{
"Connection",
"Proxy-Connection",
"Keep-Alive",
"Proxy-Authenticate",
"Proxy-Authorization",
"Te",
"Trailer",
"Transfer-Encoding",
"Upgrade",
} {
headers.Del(key)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,325 @@
package handlers
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"clawreef/internal/models"
"clawreef/internal/services"
"github.com/gin-gonic/gin"
)
func TestWorkspaceArchiveMaxMiB(t *testing.T) {
t.Setenv(workspaceArchiveMaxMiBEnv, "")
if got := workspaceArchiveMaxMiB(); got != defaultWorkspaceArchiveMaxMiB {
t.Fatalf("expected default archive limit %d MiB, got %d", defaultWorkspaceArchiveMaxMiB, got)
}
t.Setenv(workspaceArchiveMaxMiBEnv, "750")
if got := workspaceArchiveMaxMiB(); got != 750 {
t.Fatalf("expected env archive limit 750 MiB, got %d", got)
}
t.Setenv(workspaceArchiveMaxMiBEnv, "0")
if got := workspaceArchiveMaxMiB(); got != defaultWorkspaceArchiveMaxMiB {
t.Fatalf("expected invalid archive limit to fall back to %d MiB, got %d", defaultWorkspaceArchiveMaxMiB, got)
}
t.Setenv(workspaceArchiveMaxMiBEnv, "not-a-number")
if got := workspaceArchiveMaxMiB(); got != defaultWorkspaceArchiveMaxMiB {
t.Fatalf("expected unparsable archive limit to fall back to %d MiB, got %d", defaultWorkspaceArchiveMaxMiB, got)
}
}
func TestDesktopAccessUpstreamSkipsDirectProxyForRuntimeGateway(t *testing.T) {
t.Setenv(desktopDirectProxyEnv, "true")
gin.SetMode(gin.TestMode)
tests := []struct {
name string
instance *models.Instance
}{
{
name: "gateway runtime type",
instance: &models.Instance{
ID: 50,
UserID: 1,
Type: "openclaw",
RuntimeType: "gateway",
},
},
{
name: "lite instance mode",
instance: &models.Instance{
ID: 51,
UserID: 1,
Type: "openclaw",
InstanceMode: services.InstanceModeLite,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/instances/50/access", nil)
upstream, directEnabled := (&InstanceHandler{}).desktopAccessUpstream(c, tt.instance, 3001)
if upstream != "" || directEnabled {
t.Fatalf("desktopAccessUpstream() = upstream %q direct %t, want control-plane fallback", upstream, directEnabled)
}
})
}
}
func TestBuildUserSafeInstanceStatusHidesRuntimeSchedulingDetails(t *testing.T) {
podName := "runtime-openclaw-abc"
podNamespace := "clawmanager-system"
podIP := "10.42.0.12"
startedAt := time.Now().UTC()
workspacePath := "/workspaces/openclaw/user-45/instance-123"
instance := &models.Instance{
ID: 123,
Type: "openclaw",
RuntimeType: "gateway",
Status: "running",
WorkspacePath: &workspacePath,
WorkspaceUsageBytes: 123456,
}
status := &services.InstanceStatus{
InstanceID: 123,
Status: "running",
PodName: &podName,
PodNamespace: &podNamespace,
PodIP: &podIP,
PodStatus: "Running",
StartedAt: &startedAt,
}
payload := buildUserSafeInstanceStatus(instance, status)
for _, forbiddenKey := range []string{"pod_name", "pod_namespace", "pod_ip", "pod_status", "gateway_port", "capacity", "node_name"} {
if _, exists := payload[forbiddenKey]; exists {
t.Fatalf("user-safe status exposed %q: %#v", forbiddenKey, payload)
}
}
if payload["availability"] != "available" {
t.Fatalf("availability = %v, want available", payload["availability"])
}
if payload["agent_type"] != "openclaw" {
t.Fatalf("agent_type = %v, want openclaw", payload["agent_type"])
}
if payload["workspace_usage_bytes"] != int64(123456) {
t.Fatalf("workspace_usage_bytes = %v, want 123456", payload["workspace_usage_bytes"])
}
}
func TestShortExternalAccessProxyPath(t *testing.T) {
tests := []struct {
name string
requestPath string
want string
}{
{name: "entry without slash", requestPath: "/s/abc123", want: "/api/v1/instances/71/proxy/"},
{name: "entry with slash", requestPath: "/s/abc123/", want: "/api/v1/instances/71/proxy/"},
{name: "asset", requestPath: "/s/abc123/assets/index.js", want: "/api/v1/instances/71/proxy/assets/index.js"},
{name: "nested", requestPath: "/s/abc123/apps/openclaw/settings", want: "/api/v1/instances/71/proxy/apps/openclaw/settings"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shortExternalAccessProxyPath(tt.requestPath, "abc123", 71); got != tt.want {
t.Fatalf("shortExternalAccessProxyPath(%q) = %q, want %q", tt.requestPath, got, tt.want)
}
})
}
}
func TestShortExternalAccessEntryRedirectTarget(t *testing.T) {
tests := []struct {
name string
method string
requestPath string
code string
canonicalPath string
want string
}{
{
name: "html entry redirects to canonical proxy path",
method: http.MethodGet,
requestPath: "/s/sl_abc123/",
code: "sl_abc123",
canonicalPath: "/api/v1/instances/71/proxy/chat/",
want: "/api/v1/instances/71/proxy/chat/",
},
{
name: "entry without trailing slash redirects",
method: http.MethodGet,
requestPath: "/s/sl_abc123",
code: "sl_abc123",
canonicalPath: "/api/v1/instances/71/proxy/chat/",
want: "/api/v1/instances/71/proxy/chat/",
},
{
name: "asset path keeps short proxy handling",
method: http.MethodGet,
requestPath: "/s/sl_abc123/assets/index.js",
code: "sl_abc123",
canonicalPath: "/api/v1/instances/71/proxy/chat/",
want: "",
},
{
name: "post keeps password form handling",
method: http.MethodPost,
requestPath: "/s/sl_abc123/",
code: "sl_abc123",
canonicalPath: "/api/v1/instances/71/proxy/chat/",
want: "",
},
{
name: "target strips accidental token query",
method: http.MethodGet,
requestPath: "/s/sl_abc123/",
code: "sl_abc123",
canonicalPath: "/api/v1/instances/71/proxy/chat/?token=secret",
want: "/api/v1/instances/71/proxy/chat/",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shortExternalAccessEntryRedirectTarget(tt.method, tt.requestPath, tt.code, tt.canonicalPath); got != tt.want {
t.Fatalf("shortExternalAccessEntryRedirectTarget() = %q, want %q", got, tt.want)
}
})
}
}
func TestRenderShortLinkPasswordForm(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/s/sl_abc123/", nil)
renderShortLinkPasswordForm(c, "sl_abc123", "")
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
}
body := recorder.Body.String()
for _, want := range []string{
"Share link password",
`name="password"`,
`type="password"`,
`action="/s/sl_abc123/"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("password form missing %q in body:\n%s", want, body)
}
}
if contentType := recorder.Header().Get("Content-Type"); !strings.Contains(contentType, "text/html") {
t.Fatalf("content type = %q, want text/html", contentType)
}
}
func TestProxyAccessTokenPrefersCookieOverRuntimeQueryToken(t *testing.T) {
gin.SetMode(gin.TestMode)
accessService := services.NewInstanceAccessService()
defer accessService.Stop()
token, err := accessService.GenerateToken(1, 76, "hermes", "/api/v1/instances/76/proxy/chat/", "", 3000, time.Hour)
if err != nil {
t.Fatalf("GenerateToken returned error: %v", err)
}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/instances/76/proxy/api/ws?token=hermes-session-token", nil)
c.Request.AddCookie(&http.Cookie{Name: "instance_access_76", Value: token.Token})
handler := &InstanceHandler{accessService: accessService}
got, ok := handler.proxyAccessToken(c, 76)
if !ok {
t.Fatal("proxyAccessToken rejected valid cookie")
}
if got != token.Token {
t.Fatalf("proxyAccessToken = %q, want cookie token", got)
}
if recorder.Code != http.StatusOK {
t.Fatalf("unexpected response status %d", recorder.Code)
}
}
func TestProxyAccessTokenRejectsRuntimeQueryTokenWithoutCookie(t *testing.T) {
gin.SetMode(gin.TestMode)
accessService := services.NewInstanceAccessService()
defer accessService.Stop()
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/instances/76/proxy/api/ws?token=hermes-session-token", nil)
handler := &InstanceHandler{accessService: accessService}
if got, ok := handler.proxyAccessToken(c, 76); ok || got != "" {
t.Fatalf("proxyAccessToken = %q/%v, want rejected", got, ok)
}
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusUnauthorized)
}
}
func TestBuildLiteBatchCreateRequestsDefaultsToGatewayLite(t *testing.T) {
requests, handlerRequests, err := buildLiteBatchCreateRequests(BatchCreateLiteInstancesRequest{
NamePrefix: "batch-lite",
Count: 2,
})
if err != nil {
t.Fatalf("buildLiteBatchCreateRequests returned error: %v", err)
}
if len(requests) != 2 || len(handlerRequests) != 2 {
t.Fatalf("request count = %d/%d, want 2/2", len(requests), len(handlerRequests))
}
for idx, req := range requests {
if req.Name == "" || req.Mode != services.InstanceModeLite || req.InstanceMode != services.InstanceModeLite || req.RuntimeType != services.RuntimeBackendGateway {
t.Fatalf("request %d was not normalized to lite gateway: %#v", idx, req)
}
if req.Type != "openclaw" || req.OSType != "openclaw" || req.OSVersion != "latest" {
t.Fatalf("request %d defaults = type %q os %q version %q", idx, req.Type, req.OSType, req.OSVersion)
}
}
}
func TestBatchDeleteLiteInstancesRejectsProInstance(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/instances/batch/delete", strings.NewReader(`{"instance_ids":[42]}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Set("userID", 7)
c.Set("userRole", "user")
handler := &InstanceHandler{
instanceService: &fakeWorkspaceHandlerInstanceService{instances: map[int]*models.Instance{
42: {
ID: 42,
UserID: 7,
Name: "pro-openclaw",
RuntimeType: services.RuntimeBackendDesktop,
InstanceMode: services.InstanceModePro,
},
}},
}
handler.BatchDeleteLiteInstances(c)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body = %s", recorder.Code, http.StatusBadRequest, recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), "not a lite instance") {
t.Fatalf("response did not explain lite-only rejection: %s", recorder.Body.String())
}
}
@@ -0,0 +1,134 @@
package handlers
import (
"net/http"
"strconv"
"clawreef/internal/services"
"clawreef/internal/utils"
"github.com/gin-gonic/gin"
)
// LLMModelHandler handles admin model catalog requests.
type LLMModelHandler struct {
service services.LLMModelService
}
// UpsertLLMModelRequest defines editable fields for model catalog entries.
type UpsertLLMModelRequest struct {
ID int `json:"id,omitempty"`
DisplayName string `json:"display_name" binding:"required"`
Description *string `json:"description,omitempty"`
ProviderType string `json:"provider_type" binding:"required"`
ProtocolType string `json:"protocol_type,omitempty"`
BaseURL string `json:"base_url" binding:"required"`
ProviderModelName string `json:"provider_model_name" binding:"required"`
APIKey *string `json:"api_key,omitempty"`
APIKeySecretRef *string `json:"api_key_secret_ref,omitempty"`
IsSecure bool `json:"is_secure"`
IsActive bool `json:"is_active"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
Currency string `json:"currency,omitempty"`
}
// DiscoverLLMModelsRequest defines fields needed to fetch provider models.
type DiscoverLLMModelsRequest struct {
ProviderType string `json:"provider_type" binding:"required"`
ProtocolType string `json:"protocol_type,omitempty"`
BaseURL string `json:"base_url" binding:"required"`
APIKey *string `json:"api_key,omitempty"`
APIKeySecretRef *string `json:"api_key_secret_ref,omitempty"`
}
// NewLLMModelHandler creates a new model catalog handler.
func NewLLMModelHandler(service services.LLMModelService) *LLMModelHandler {
return &LLMModelHandler{service: service}
}
// ListModels returns all configured models for admin management.
func (h *LLMModelHandler) ListModels(c *gin.Context) {
items, err := h.service.ListModels()
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "LLM models retrieved successfully", gin.H{
"items": items,
})
}
// UpsertModel creates or updates a model catalog entry.
func (h *LLMModelHandler) UpsertModel(c *gin.Context) {
var req UpsertLLMModelRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
item, err := h.service.SaveModel(services.SaveLLMModelRequest{
ID: req.ID,
DisplayName: req.DisplayName,
Description: req.Description,
ProviderType: req.ProviderType,
ProtocolType: req.ProtocolType,
BaseURL: req.BaseURL,
ProviderModelName: req.ProviderModelName,
APIKey: req.APIKey,
APIKeySecretRef: req.APIKeySecretRef,
IsSecure: req.IsSecure,
IsActive: req.IsActive,
InputPrice: req.InputPrice,
OutputPrice: req.OutputPrice,
Currency: req.Currency,
})
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "LLM model saved successfully", item)
}
// DiscoverModels fetches available models from the configured provider.
func (h *LLMModelHandler) DiscoverModels(c *gin.Context) {
var req DiscoverLLMModelsRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
items, err := h.service.DiscoverProviderModels(services.DiscoverLLMModelsRequest{
ProviderType: req.ProviderType,
ProtocolType: req.ProtocolType,
BaseURL: req.BaseURL,
APIKey: req.APIKey,
APIKeySecretRef: req.APIKeySecretRef,
})
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "LLM provider models retrieved successfully", gin.H{
"items": items,
})
}
// DeleteModel removes a configured model.
func (h *LLMModelHandler) DeleteModel(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid model ID")
return
}
if err := h.service.DeleteModel(id); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "LLM model deleted successfully", nil)
}
@@ -0,0 +1,271 @@
package handlers
import (
"net/http"
"strconv"
"clawreef/internal/services"
"clawreef/internal/utils"
"github.com/gin-gonic/gin"
)
// OpenClawConfigHandler handles OpenClaw config center APIs.
type OpenClawConfigHandler struct {
service services.OpenClawConfigService
}
// NewOpenClawConfigHandler creates a new OpenClaw config handler.
func NewOpenClawConfigHandler(service services.OpenClawConfigService) *OpenClawConfigHandler {
return &OpenClawConfigHandler{service: service}
}
func (h *OpenClawConfigHandler) ListResources(c *gin.Context) {
userID, _ := c.Get("userID")
resourceType := c.Query("resource_type")
items, err := h.service.ListResources(userID.(int), resourceType)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "OpenClaw config resources retrieved successfully", items)
}
func (h *OpenClawConfigHandler) GetResource(c *gin.Context) {
userID, _ := c.Get("userID")
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid resource ID")
return
}
item, err := h.service.GetResource(userID.(int), id)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "OpenClaw config resource retrieved successfully", item)
}
func (h *OpenClawConfigHandler) CreateResource(c *gin.Context) {
userID, _ := c.Get("userID")
var req services.UpsertOpenClawConfigResourceRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
item, err := h.service.CreateResource(userID.(int), req)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "OpenClaw config resource created successfully", item)
}
func (h *OpenClawConfigHandler) UpdateResource(c *gin.Context) {
userID, _ := c.Get("userID")
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid resource ID")
return
}
var req services.UpsertOpenClawConfigResourceRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
item, err := h.service.UpdateResource(userID.(int), id, req)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "OpenClaw config resource updated successfully", item)
}
func (h *OpenClawConfigHandler) DeleteResource(c *gin.Context) {
userID, _ := c.Get("userID")
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid resource ID")
return
}
if err := h.service.DeleteResource(userID.(int), id); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "OpenClaw config resource deleted successfully", nil)
}
func (h *OpenClawConfigHandler) CloneResource(c *gin.Context) {
userID, _ := c.Get("userID")
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid resource ID")
return
}
item, err := h.service.CloneResource(userID.(int), id)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "OpenClaw config resource cloned successfully", item)
}
func (h *OpenClawConfigHandler) ValidateResource(c *gin.Context) {
var req services.UpsertOpenClawConfigResourceRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
if err := h.service.ValidateResource(req); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "OpenClaw config resource validated successfully", gin.H{"valid": true})
}
func (h *OpenClawConfigHandler) ListBundles(c *gin.Context) {
userID, _ := c.Get("userID")
items, err := h.service.ListBundles(userID.(int))
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "OpenClaw config bundles retrieved successfully", items)
}
func (h *OpenClawConfigHandler) GetBundle(c *gin.Context) {
userID, _ := c.Get("userID")
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid bundle ID")
return
}
item, err := h.service.GetBundle(userID.(int), id)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "OpenClaw config bundle retrieved successfully", item)
}
func (h *OpenClawConfigHandler) CreateBundle(c *gin.Context) {
userID, _ := c.Get("userID")
var req services.UpsertOpenClawConfigBundleRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
item, err := h.service.CreateBundle(userID.(int), req)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "OpenClaw config bundle created successfully", item)
}
func (h *OpenClawConfigHandler) UpdateBundle(c *gin.Context) {
userID, _ := c.Get("userID")
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid bundle ID")
return
}
var req services.UpsertOpenClawConfigBundleRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
item, err := h.service.UpdateBundle(userID.(int), id, req)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "OpenClaw config bundle updated successfully", item)
}
func (h *OpenClawConfigHandler) DeleteBundle(c *gin.Context) {
userID, _ := c.Get("userID")
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid bundle ID")
return
}
if err := h.service.DeleteBundle(userID.(int), id); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "OpenClaw config bundle deleted successfully", nil)
}
func (h *OpenClawConfigHandler) CloneBundle(c *gin.Context) {
userID, _ := c.Get("userID")
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid bundle ID")
return
}
item, err := h.service.CloneBundle(userID.(int), id)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "OpenClaw config bundle cloned successfully", item)
}
func (h *OpenClawConfigHandler) CompilePreview(c *gin.Context) {
userID, _ := c.Get("userID")
var req services.OpenClawConfigPlan
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
result, err := h.service.CompilePreview(userID.(int), req)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "OpenClaw config preview compiled successfully", result)
}
func (h *OpenClawConfigHandler) ListSnapshots(c *gin.Context) {
userID, _ := c.Get("userID")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
items, err := h.service.ListSnapshots(userID.(int), limit)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "OpenClaw injection snapshots retrieved successfully", items)
}
func (h *OpenClawConfigHandler) GetSnapshot(c *gin.Context) {
userID, _ := c.Get("userID")
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid snapshot ID")
return
}
item, err := h.service.GetSnapshot(userID.(int), id)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "OpenClaw injection snapshot retrieved successfully", item)
}
@@ -0,0 +1,142 @@
package handlers
import (
"net/http"
"clawreef/internal/services"
"clawreef/internal/utils"
"github.com/gin-gonic/gin"
)
// RiskRuleHandler handles admin risk rule requests.
type RiskRuleHandler struct {
service services.RiskRuleService
}
// UpsertRiskRuleRequest defines editable fields for risk rules.
type UpsertRiskRuleRequest struct {
RuleID string `json:"rule_id" binding:"required"`
DisplayName string `json:"display_name" binding:"required"`
Description *string `json:"description,omitempty"`
Pattern string `json:"pattern" binding:"required"`
Severity string `json:"severity" binding:"required"`
Action string `json:"action" binding:"required"`
IsEnabled bool `json:"is_enabled"`
SortOrder int `json:"sort_order"`
}
type TestRiskRuleRequest struct {
Text string `json:"text" binding:"required"`
Rule *UpsertRiskRuleRequest `json:"rule,omitempty"`
}
type BulkRiskRuleStatusRequest struct {
RuleIDs []string `json:"rule_ids" binding:"required"`
IsEnabled bool `json:"is_enabled"`
}
// NewRiskRuleHandler creates a new risk rule handler.
func NewRiskRuleHandler(service services.RiskRuleService) *RiskRuleHandler {
return &RiskRuleHandler{service: service}
}
// ListRules returns all risk rules for admin management.
func (h *RiskRuleHandler) ListRules(c *gin.Context) {
items, err := h.service.ListRules()
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Risk rules retrieved successfully", gin.H{
"items": items,
})
}
// UpsertRule creates or updates a risk rule.
func (h *RiskRuleHandler) UpsertRule(c *gin.Context) {
var req UpsertRiskRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
item, err := h.service.SaveRule(services.SaveRiskRuleRequest{
RuleID: req.RuleID,
DisplayName: req.DisplayName,
Description: req.Description,
Pattern: req.Pattern,
Severity: req.Severity,
Action: req.Action,
IsEnabled: req.IsEnabled,
SortOrder: req.SortOrder,
})
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Risk rule saved successfully", item)
}
// TestRules evaluates enabled rules or a draft rule against sample text.
func (h *RiskRuleHandler) TestRules(c *gin.Context) {
var req TestRiskRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
var draft *services.SaveRiskRuleRequest
if req.Rule != nil {
draft = &services.SaveRiskRuleRequest{
RuleID: req.Rule.RuleID,
DisplayName: req.Rule.DisplayName,
Description: req.Rule.Description,
Pattern: req.Rule.Pattern,
Severity: req.Rule.Severity,
Action: req.Rule.Action,
IsEnabled: req.Rule.IsEnabled,
SortOrder: req.Rule.SortOrder,
}
}
result, err := h.service.TestRules(services.TestRiskRulesRequest{
Text: req.Text,
Rule: draft,
})
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Risk rule test completed successfully", result)
}
// DeleteRule disables a risk rule.
func (h *RiskRuleHandler) DeleteRule(c *gin.Context) {
ruleID := c.Param("ruleId")
if err := h.service.DeleteRule(ruleID); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Risk rule disabled successfully", nil)
}
// BulkUpdateStatus enables or disables multiple risk rules.
func (h *RiskRuleHandler) BulkUpdateStatus(c *gin.Context) {
var req BulkRiskRuleStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
if err := h.service.BulkSetEnabled(req.RuleIDs, req.IsEnabled); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Risk rule status updated successfully", nil)
}
@@ -0,0 +1,410 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"clawreef/internal/config"
"clawreef/internal/models"
"clawreef/internal/repository"
"clawreef/internal/services"
"clawreef/internal/utils"
"github.com/gin-gonic/gin"
)
const runtimeAgentTokenHeader = "X-ClawManager-Agent-Token"
type runtimeEventPublisher interface {
Publish(ctx context.Context, eventType string, payload any) error
}
type RuntimeAgentHandler struct {
cfg config.RuntimePoolConfig
podRepo repository.RuntimePodRepository
bindingRepo repository.InstanceRuntimeBindingRepository
instanceRepo repository.InstanceRepository
events runtimeEventPublisher
}
type runtimeAgentPodIdentity struct {
PodID int64 `json:"pod_id"`
Namespace string `json:"namespace"`
PodName string `json:"pod_name"`
}
type runtimeAgentRegisterRequest struct {
RuntimeType string `json:"runtime_type" binding:"required"`
Namespace string `json:"namespace" binding:"required"`
PodName string `json:"pod_name" binding:"required"`
PodUID *string `json:"pod_uid,omitempty"`
PodIP *string `json:"pod_ip,omitempty"`
NodeName *string `json:"node_name,omitempty"`
DeploymentName string `json:"deployment_name" binding:"required"`
ImageRef string `json:"image_ref" binding:"required"`
AgentEndpoint *string `json:"agent_endpoint,omitempty"`
State string `json:"state"`
Capacity int `json:"capacity"`
UsedSlots int `json:"used_slots"`
Draining bool `json:"draining"`
Metrics json.RawMessage `json:"metrics,omitempty"`
ReportedAt *time.Time `json:"reported_at,omitempty"`
}
type runtimeAgentHeartbeatRequest struct {
runtimeAgentPodIdentity
State string `json:"state" binding:"required"`
UsedSlots int `json:"used_slots"`
Draining bool `json:"draining"`
ReportedAt *time.Time `json:"reported_at,omitempty"`
}
type runtimeAgentMetricsRequest struct {
runtimeAgentPodIdentity
CPUMillisUsed int64 `json:"cpu_millis_used"`
MemoryBytesUsed int64 `json:"memory_bytes_used"`
DiskBytesUsed int64 `json:"disk_bytes_used"`
NetworkRXBytes int64 `json:"network_rx_bytes"`
NetworkTXBytes int64 `json:"network_tx_bytes"`
Metrics json.RawMessage `json:"metrics,omitempty"`
ReportedAt *time.Time `json:"reported_at,omitempty"`
}
type runtimeAgentGatewaysRequest struct {
runtimeAgentPodIdentity
Gateways []runtimeAgentGatewayReport `json:"gateways" binding:"required"`
}
type runtimeAgentGatewayReport struct {
InstanceID int `json:"instance_id" binding:"required"`
GatewayID string `json:"gateway_id"`
GatewayPort int `json:"gateway_port"`
GatewayPID *int `json:"gateway_pid,omitempty"`
State string `json:"state" binding:"required"`
Generation int `json:"generation" binding:"required"`
ErrorMessage *string `json:"error_message,omitempty"`
HealthAt *time.Time `json:"health_at,omitempty"`
}
func NewRuntimeAgentHandler(cfg config.RuntimePoolConfig, podRepo repository.RuntimePodRepository, bindingRepo repository.InstanceRuntimeBindingRepository, instanceRepo repository.InstanceRepository, events runtimeEventPublisher) *RuntimeAgentHandler {
return &RuntimeAgentHandler{
cfg: cfg,
podRepo: podRepo,
bindingRepo: bindingRepo,
instanceRepo: instanceRepo,
events: events,
}
}
func (h *RuntimeAgentHandler) Register(c *gin.Context) {
if !h.requireAgentToken(c) {
return
}
var req runtimeAgentRegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
runtimeType, ok := services.NormalizeV2RuntimeType(req.RuntimeType)
if !ok {
utils.Error(c, http.StatusBadRequest, "unsupported runtime type")
return
}
state := strings.TrimSpace(req.State)
if state == "" {
state = "ready"
}
capacity := runtimePodCapacityFromReport(req.Capacity, h.cfg.MaxGatewaysPerPod)
lastSeen := time.Now().UTC()
if req.ReportedAt != nil && !req.ReportedAt.IsZero() {
lastSeen = req.ReportedAt.UTC()
}
var metricsJSON *string
if len(req.Metrics) > 0 {
if !json.Valid(req.Metrics) {
utils.Error(c, http.StatusBadRequest, "metrics must be valid JSON")
return
}
raw := string(req.Metrics)
metricsJSON = &raw
}
pod := &models.RuntimePod{
RuntimeType: runtimeType,
Namespace: strings.TrimSpace(req.Namespace),
PodName: strings.TrimSpace(req.PodName),
PodUID: trimStringPtr(req.PodUID),
PodIP: trimStringPtr(req.PodIP),
NodeName: trimStringPtr(req.NodeName),
DeploymentName: strings.TrimSpace(req.DeploymentName),
ImageRef: strings.TrimSpace(req.ImageRef),
AgentEndpoint: trimStringPtr(req.AgentEndpoint),
State: state,
Capacity: capacity,
UsedSlots: req.UsedSlots,
Draining: req.Draining,
MetricsJSON: metricsJSON,
LastSeenAt: &lastSeen,
CPUMillisUsed: 0,
MemoryBytesUsed: 0,
DiskBytesUsed: 0,
NetworkRXBytes: 0,
NetworkTXBytes: 0,
}
if err := h.podRepo.UpsertFromAgent(c.Request.Context(), pod); err != nil {
utils.HandleError(c, err)
return
}
h.publish(c.Request.Context(), "runtime_pod_state", map[string]any{
"pod_id": pod.ID,
"runtime_type": runtimeType,
"namespace": pod.Namespace,
"pod_name": pod.PodName,
"state": state,
"used_slots": req.UsedSlots,
"capacity": capacity,
"draining": req.Draining,
"last_seen_at": lastSeen,
})
utils.Success(c, http.StatusOK, "Runtime pod registered successfully", gin.H{"pod": pod})
}
func (h *RuntimeAgentHandler) Heartbeat(c *gin.Context) {
if !h.requireAgentToken(c) {
return
}
var req runtimeAgentHeartbeatRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
podID, ok := h.resolvePodID(c, req.runtimeAgentPodIdentity)
if !ok {
return
}
lastSeen := time.Now().UTC()
if req.ReportedAt != nil && !req.ReportedAt.IsZero() {
lastSeen = req.ReportedAt.UTC()
}
capacity := runtimePodCapacityFromReport(0, h.cfg.MaxGatewaysPerPod)
if err := h.podRepo.UpdateHeartbeat(c.Request.Context(), podID, strings.TrimSpace(req.State), req.UsedSlots, capacity, req.Draining, lastSeen); err != nil {
utils.HandleError(c, err)
return
}
h.publish(c.Request.Context(), "runtime_pod_state", map[string]any{
"pod_id": podID,
"state": strings.TrimSpace(req.State),
"used_slots": req.UsedSlots,
"capacity": capacity,
"draining": req.Draining,
"last_seen_at": lastSeen,
})
utils.Success(c, http.StatusOK, "Runtime pod heartbeat accepted", nil)
}
func (h *RuntimeAgentHandler) ReportMetrics(c *gin.Context) {
if !h.requireAgentToken(c) {
return
}
var req runtimeAgentMetricsRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
podID, ok := h.resolvePodID(c, req.runtimeAgentPodIdentity)
if !ok {
return
}
var metricsJSON *string
if len(req.Metrics) > 0 {
if !json.Valid(req.Metrics) {
utils.Error(c, http.StatusBadRequest, "metrics must be valid JSON")
return
}
raw := string(req.Metrics)
metricsJSON = &raw
}
lastSeen := time.Now().UTC()
if req.ReportedAt != nil && !req.ReportedAt.IsZero() {
lastSeen = req.ReportedAt.UTC()
}
update := repository.RuntimePodMetricsUpdate{
CPUMillisUsed: req.CPUMillisUsed,
MemoryBytesUsed: req.MemoryBytesUsed,
DiskBytesUsed: req.DiskBytesUsed,
NetworkRXBytes: req.NetworkRXBytes,
NetworkTXBytes: req.NetworkTXBytes,
MetricsJSON: metricsJSON,
LastSeenAt: &lastSeen,
}
if err := h.podRepo.UpdateMetrics(c.Request.Context(), podID, update); err != nil {
utils.HandleError(c, err)
return
}
payload := map[string]any{
"pod_id": podID,
"cpu_millis_used": req.CPUMillisUsed,
"memory_bytes_used": req.MemoryBytesUsed,
"disk_bytes_used": req.DiskBytesUsed,
"network_rx_bytes": req.NetworkRXBytes,
"network_tx_bytes": req.NetworkTXBytes,
"last_seen_at": lastSeen,
}
if metricsJSON != nil {
payload["metrics"] = json.RawMessage(*metricsJSON)
}
h.publish(c.Request.Context(), "runtime_pod_metrics", payload)
utils.Success(c, http.StatusOK, "Runtime pod metrics accepted", nil)
}
func (h *RuntimeAgentHandler) ReportGateways(c *gin.Context) {
if !h.requireAgentToken(c) {
return
}
var req runtimeAgentGatewaysRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
podID, ok := h.resolvePodID(c, req.runtimeAgentPodIdentity)
if !ok {
return
}
for _, gateway := range req.Gateways {
binding, err := h.bindingRepo.GetByInstanceID(c.Request.Context(), gateway.InstanceID)
if err != nil {
utils.HandleError(c, err)
return
}
if binding == nil || binding.RuntimePodID != podID || binding.Generation != gateway.Generation {
continue
}
state := strings.ToLower(strings.TrimSpace(gateway.State))
switch state {
case "running", "ready", "healthy":
if err := h.bindingRepo.UpdateRunning(c.Request.Context(), gateway.InstanceID, gateway.Generation, strings.TrimSpace(gateway.GatewayID), gateway.GatewayPort, gateway.GatewayPID); err != nil {
utils.HandleError(c, err)
return
}
default:
if err := h.bindingRepo.UpdateState(c.Request.Context(), gateway.InstanceID, gateway.Generation, state, gateway.ErrorMessage); err != nil {
utils.HandleError(c, err)
return
}
}
if err := h.syncInstanceRuntimeState(c.Request.Context(), gateway, state); err != nil {
utils.HandleError(c, err)
return
}
}
h.publish(c.Request.Context(), "runtime_pod_gateways_reported", map[string]any{
"pod_id": podID,
"gateway_count": len(req.Gateways),
})
utils.Success(c, http.StatusOK, "Runtime gateway report accepted", nil)
}
func (h *RuntimeAgentHandler) syncInstanceRuntimeState(ctx context.Context, gateway runtimeAgentGatewayReport, state string) error {
if h.instanceRepo == nil {
return nil
}
instanceState, message := instanceRuntimeStateFromGatewayReport(state, gateway.ErrorMessage)
return h.instanceRepo.UpdateRuntimeState(ctx, gateway.InstanceID, instanceState, gateway.Generation, message)
}
func instanceRuntimeStateFromGatewayReport(state string, errorMessage *string) (string, *string) {
normalized := strings.ToLower(strings.TrimSpace(state))
switch normalized {
case "running", "ready", "healthy":
return "running", nil
case "error", "failed", "failure", "errored":
if errorMessage != nil && strings.TrimSpace(*errorMessage) != "" {
msg := strings.TrimSpace(*errorMessage)
return "error", &msg
}
msg := "runtime gateway reported " + normalized
return "error", &msg
case "stopped", "deleted":
return "stopped", nil
default:
msg := "runtime gateway starting"
if errorMessage != nil && strings.TrimSpace(*errorMessage) != "" {
msg = strings.TrimSpace(*errorMessage)
}
return "creating", &msg
}
}
func (h *RuntimeAgentHandler) ReportSkills(c *gin.Context) {
if !h.requireAgentToken(c) {
return
}
var payload map[string]any
if err := c.ShouldBindJSON(&payload); err != nil {
utils.ValidationError(c, err)
return
}
h.publish(c.Request.Context(), "runtime_agent_skills_reported", payload)
utils.Success(c, http.StatusOK, "Runtime agent skills report accepted", nil)
}
func (h *RuntimeAgentHandler) requireAgentToken(c *gin.Context) bool {
if strings.TrimSpace(h.cfg.AgentReportToken) == "" || c.GetHeader(runtimeAgentTokenHeader) != h.cfg.AgentReportToken {
utils.Error(c, http.StatusUnauthorized, "invalid runtime agent token")
return false
}
return true
}
func (h *RuntimeAgentHandler) resolvePodID(c *gin.Context, identity runtimeAgentPodIdentity) (int64, bool) {
if identity.PodID > 0 {
return identity.PodID, true
}
namespace := strings.TrimSpace(identity.Namespace)
podName := strings.TrimSpace(identity.PodName)
if namespace == "" || podName == "" {
utils.Error(c, http.StatusBadRequest, "pod_id or namespace and pod_name are required")
return 0, false
}
pod, err := h.podRepo.GetByNamespaceName(c.Request.Context(), namespace, podName)
if err != nil {
utils.HandleError(c, err)
return 0, false
}
if pod == nil {
utils.Error(c, http.StatusNotFound, "runtime pod not found")
return 0, false
}
return pod.ID, true
}
func (h *RuntimeAgentHandler) publish(ctx context.Context, eventType string, payload any) {
if h.events == nil {
return
}
_ = h.events.Publish(ctx, eventType, payload)
}
func runtimePodCapacityFromReport(reported, configured int) int {
if configured > 0 {
return configured
}
capacity := reported
if capacity <= 0 {
capacity = services.RuntimePodCapacity
}
return capacity
}
func trimStringPtr(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}
@@ -0,0 +1,479 @@
package handlers
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"clawreef/internal/config"
"clawreef/internal/models"
"clawreef/internal/repository"
"github.com/gin-gonic/gin"
)
func TestRuntimeAgentHandlerRejectsInvalidToken(t *testing.T) {
gin.SetMode(gin.TestMode)
podRepo := &runtimeAgentHandlerPodRepo{}
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{AgentReportToken: "secret"}, podRepo, &runtimeAgentHandlerBindingRepo{}, nil, &runtimeAgentHandlerEvents{})
router := gin.New()
router.POST("/api/v1/runtime-agent/metrics/report", handler.ReportMetrics)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/runtime-agent/metrics/report", bytes.NewBufferString(`{"pod_id":9}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, body = %s, want 401", rec.Code, rec.Body.String())
}
if podRepo.updateMetricsCalls != 0 {
t.Fatalf("UpdateMetrics called %d times, want 0", podRepo.updateMetricsCalls)
}
}
func TestRuntimeAgentHandlerRegisterUsesConfiguredCapacity(t *testing.T) {
gin.SetMode(gin.TestMode)
podRepo := &runtimeAgentHandlerPodRepo{}
events := &runtimeAgentHandlerEvents{}
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{
AgentReportToken: "secret",
MaxGatewaysPerPod: 33,
}, podRepo, &runtimeAgentHandlerBindingRepo{}, nil, events)
router := gin.New()
router.POST("/api/v1/runtime-agent/register", handler.Register)
body := `{
"runtime_type": "openclaw",
"namespace": "clawmanager-system",
"pod_name": "openclaw-runtime-test",
"deployment_name": "openclaw-runtime",
"image_ref": "registry/openclaw:v1",
"agent_endpoint": "http://10.0.0.1:19090",
"state": "ready",
"capacity": 10,
"used_slots": 7
}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/runtime-agent/register", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-ClawManager-Agent-Token", "secret")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s, want 200", rec.Code, rec.Body.String())
}
pod := podRepo.podsByID[1]
if pod == nil {
t.Fatal("runtime pod was not persisted")
}
if pod.Capacity != 33 {
t.Fatalf("stored capacity = %d, want configured capacity 33", pod.Capacity)
}
if events.lastType != "runtime_pod_state" {
t.Fatalf("event type = %q, want runtime_pod_state", events.lastType)
}
payload, ok := events.lastPayload.(map[string]any)
if !ok {
t.Fatalf("event payload = %#v, want map", events.lastPayload)
}
if payload["capacity"] != 33 {
t.Fatalf("event capacity = %#v, want configured capacity 33", payload["capacity"])
}
}
func TestRuntimeAgentHandlerHeartbeatUsesConfiguredCapacity(t *testing.T) {
gin.SetMode(gin.TestMode)
podRepo := &runtimeAgentHandlerPodRepo{}
events := &runtimeAgentHandlerEvents{}
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{
AgentReportToken: "secret",
MaxGatewaysPerPod: 44,
}, podRepo, &runtimeAgentHandlerBindingRepo{}, nil, events)
router := gin.New()
router.POST("/api/v1/runtime-agent/heartbeat", handler.Heartbeat)
body := `{
"pod_id": 9,
"state": "ready",
"used_slots": 8,
"draining": false
}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/runtime-agent/heartbeat", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-ClawManager-Agent-Token", "secret")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s, want 200", rec.Code, rec.Body.String())
}
if podRepo.updatedPodID != 9 {
t.Fatalf("updated pod id = %d, want 9", podRepo.updatedPodID)
}
if podRepo.lastHeartbeatCapacity != 44 {
t.Fatalf("heartbeat capacity = %d, want configured capacity 44", podRepo.lastHeartbeatCapacity)
}
payload, ok := events.lastPayload.(map[string]any)
if !ok {
t.Fatalf("event payload = %#v, want map", events.lastPayload)
}
if payload["capacity"] != 44 {
t.Fatalf("event capacity = %#v, want configured capacity 44", payload["capacity"])
}
}
func TestRuntimeAgentHandlerMetricsReportUpdatesPodAndPublishesEvent(t *testing.T) {
gin.SetMode(gin.TestMode)
podRepo := &runtimeAgentHandlerPodRepo{}
events := &runtimeAgentHandlerEvents{}
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{AgentReportToken: "secret"}, podRepo, &runtimeAgentHandlerBindingRepo{}, nil, events)
router := gin.New()
router.POST("/api/v1/runtime-agent/metrics/report", handler.ReportMetrics)
body := `{
"pod_id": 9,
"cpu_millis_used": 2400,
"memory_bytes_used": 4096,
"disk_bytes_used": 8192,
"network_rx_bytes": 12,
"network_tx_bytes": 34,
"metrics": {"load": 0.42}
}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/runtime-agent/metrics/report", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-ClawManager-Agent-Token", "secret")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s, want 200", rec.Code, rec.Body.String())
}
if podRepo.updatedPodID != 9 {
t.Fatalf("updated pod id = %d, want 9", podRepo.updatedPodID)
}
if podRepo.lastMetrics.CPUMillisUsed != 2400 || podRepo.lastMetrics.MemoryBytesUsed != 4096 || podRepo.lastMetrics.DiskBytesUsed != 8192 {
t.Fatalf("metrics = %#v, want cpu/memory/disk values from request", podRepo.lastMetrics)
}
if podRepo.lastMetrics.MetricsJSON == nil || *podRepo.lastMetrics.MetricsJSON == "" {
t.Fatalf("metrics json was not persisted")
}
if events.lastType != "runtime_pod_metrics" {
t.Fatalf("event type = %q, want runtime_pod_metrics", events.lastType)
}
payload, ok := events.lastPayload.(map[string]any)
if !ok {
t.Fatalf("event payload = %#v, want map", events.lastPayload)
}
if payload["pod_id"] != int64(9) {
t.Fatalf("event pod_id = %#v, want 9", payload["pod_id"])
}
}
func TestRuntimeAgentHandlerGatewayReportOnlyUpdatesCurrentPodBinding(t *testing.T) {
gin.SetMode(gin.TestMode)
bindingRepo := &runtimeAgentHandlerBindingRepo{
bindings: map[int]*models.InstanceRuntimeBinding{
10: {InstanceID: 10, RuntimePodID: 9, Generation: 2},
11: {InstanceID: 11, RuntimePodID: 99, Generation: 2},
12: {InstanceID: 12, RuntimePodID: 9, Generation: 3},
},
}
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{AgentReportToken: "secret"}, &runtimeAgentHandlerPodRepo{}, bindingRepo, nil, &runtimeAgentHandlerEvents{})
router := gin.New()
router.POST("/api/v1/runtime-agent/gateways/report", handler.ReportGateways)
body := `{
"pod_id": 9,
"gateways": [
{"instance_id":10,"gateway_id":"gw-10","gateway_port":20010,"state":"running","generation":2},
{"instance_id":11,"gateway_id":"gw-11","gateway_port":20011,"state":"running","generation":2},
{"instance_id":12,"gateway_id":"gw-12","gateway_port":20012,"state":"running","generation":2}
]
}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/runtime-agent/gateways/report", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-ClawManager-Agent-Token", "secret")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s, want 200", rec.Code, rec.Body.String())
}
if bindingRepo.updateRunningCalls != 1 {
t.Fatalf("UpdateRunning calls = %d, want 1", bindingRepo.updateRunningCalls)
}
}
func TestRuntimeAgentHandlerGatewayReportSyncsInstanceRuntimeState(t *testing.T) {
gin.SetMode(gin.TestMode)
message := "gateway port 20000 is not listening"
bindingRepo := &runtimeAgentHandlerBindingRepo{
bindings: map[int]*models.InstanceRuntimeBinding{
10: {InstanceID: 10, RuntimePodID: 9, Generation: 2},
11: {InstanceID: 11, RuntimePodID: 9, Generation: 2},
12: {InstanceID: 12, RuntimePodID: 9, Generation: 2},
},
}
instanceRepo := &runtimeAgentHandlerInstanceRepo{}
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{AgentReportToken: "secret"}, &runtimeAgentHandlerPodRepo{}, bindingRepo, instanceRepo, &runtimeAgentHandlerEvents{})
router := gin.New()
router.POST("/api/v1/runtime-agent/gateways/report", handler.ReportGateways)
body := `{
"pod_id": 9,
"gateways": [
{"instance_id":10,"gateway_id":"gw-10","gateway_port":20010,"state":"healthy","generation":2},
{"instance_id":11,"gateway_id":"gw-11","gateway_port":20011,"state":"error","generation":2,"error_message":"` + message + `"},
{"instance_id":12,"gateway_id":"gw-12","gateway_port":20012,"state":"ready","generation":2}
]
}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/runtime-agent/gateways/report", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-ClawManager-Agent-Token", "secret")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s, want 200", rec.Code, rec.Body.String())
}
if got := instanceRepo.statusByID[10]; got != "running" {
t.Fatalf("healthy gateway synced instance status = %q, want running", got)
}
if got := instanceRepo.statusByID[11]; got != "error" {
t.Fatalf("error gateway synced instance status = %q, want error", got)
}
if got := instanceRepo.statusByID[12]; got != "running" {
t.Fatalf("ready gateway synced instance status = %q, want running", got)
}
if instanceRepo.messageByID[11] == nil || *instanceRepo.messageByID[11] != message {
t.Fatalf("error message = %#v, want %q", instanceRepo.messageByID[11], message)
}
}
type runtimeAgentHandlerPodRepo struct {
updatedPodID int64
lastMetrics repository.RuntimePodMetricsUpdate
lastHeartbeatCapacity int
updateMetricsCalls int
podsByID map[int64]*models.RuntimePod
}
func (r *runtimeAgentHandlerPodRepo) UpsertFromAgent(ctx context.Context, pod *models.RuntimePod) error {
if r.podsByID == nil {
r.podsByID = map[int64]*models.RuntimePod{}
}
if pod.ID == 0 {
pod.ID = int64(len(r.podsByID) + 1)
}
cp := *pod
r.podsByID[pod.ID] = &cp
return nil
}
func (r *runtimeAgentHandlerPodRepo) GetByID(ctx context.Context, id int64) (*models.RuntimePod, error) {
if r.podsByID == nil {
return nil, nil
}
return r.podsByID[id], nil
}
func (r *runtimeAgentHandlerPodRepo) GetByNamespaceName(ctx context.Context, namespace, podName string) (*models.RuntimePod, error) {
for _, pod := range r.podsByID {
if pod.Namespace == namespace && pod.PodName == podName {
return pod, nil
}
}
return nil, nil
}
func (r *runtimeAgentHandlerPodRepo) List(ctx context.Context, runtimeType string) ([]models.RuntimePod, error) {
return nil, nil
}
func (r *runtimeAgentHandlerPodRepo) ListSchedulable(ctx context.Context, runtimeType string) ([]models.RuntimePod, error) {
return nil, nil
}
func (r *runtimeAgentHandlerPodRepo) TryClaimSlot(ctx context.Context, podID int64) (bool, error) {
return false, nil
}
func (r *runtimeAgentHandlerPodRepo) ReleaseSlot(ctx context.Context, podID int64) error {
return nil
}
func (r *runtimeAgentHandlerPodRepo) MarkState(ctx context.Context, podID int64, state string, draining bool) error {
return nil
}
func (r *runtimeAgentHandlerPodRepo) UpdateHeartbeat(ctx context.Context, podID int64, state string, usedSlots int, capacity int, draining bool, lastSeenAt time.Time) error {
r.updatedPodID = podID
r.lastHeartbeatCapacity = capacity
return nil
}
func (r *runtimeAgentHandlerPodRepo) MarkUnseenUnhealthy(ctx context.Context, cutoff time.Time) error {
return nil
}
func (r *runtimeAgentHandlerPodRepo) UpdateMetrics(ctx context.Context, podID int64, metrics repository.RuntimePodMetricsUpdate) error {
r.updatedPodID = podID
r.lastMetrics = metrics
r.updateMetricsCalls++
return nil
}
type runtimeAgentHandlerBindingRepo struct {
updateRunningCalls int
updateStateCalls int
bindings map[int]*models.InstanceRuntimeBinding
}
func (r *runtimeAgentHandlerBindingRepo) Create(ctx context.Context, binding *models.InstanceRuntimeBinding) error {
return nil
}
func (r *runtimeAgentHandlerBindingRepo) GetByInstanceID(ctx context.Context, instanceID int) (*models.InstanceRuntimeBinding, error) {
return r.bindings[instanceID], nil
}
func (r *runtimeAgentHandlerBindingRepo) GetRunningByInstanceID(ctx context.Context, instanceID int) (*models.InstanceRuntimeBinding, error) {
return nil, nil
}
func (r *runtimeAgentHandlerBindingRepo) ListByRuntimePodID(ctx context.Context, runtimePodID int64) ([]models.InstanceRuntimeBinding, error) {
return nil, nil
}
func (r *runtimeAgentHandlerBindingRepo) ListByRuntimePodIDs(ctx context.Context, runtimePodIDs []int64) ([]models.InstanceRuntimeBinding, error) {
return nil, nil
}
func (r *runtimeAgentHandlerBindingRepo) UpdateRunning(ctx context.Context, instanceID int, generation int, gatewayID string, port int, pid *int) error {
r.updateRunningCalls++
return nil
}
func (r *runtimeAgentHandlerBindingRepo) UpdateState(ctx context.Context, instanceID int, generation int, state string, message *string) error {
r.updateStateCalls++
return nil
}
func (r *runtimeAgentHandlerBindingRepo) DeleteByInstanceID(ctx context.Context, instanceID int) error {
return nil
}
func (r *runtimeAgentHandlerBindingRepo) DeleteByInstanceIDAndReleaseSlot(ctx context.Context, instanceID int, runtimePodID int64) error {
return nil
}
type runtimeAgentHandlerInstanceRepo struct {
statusByID map[int]string
generationByID map[int]int
messageByID map[int]*string
}
func (r *runtimeAgentHandlerInstanceRepo) Create(instance *models.Instance) error {
return nil
}
func (r *runtimeAgentHandlerInstanceRepo) GetByID(id int) (*models.Instance, error) {
return nil, nil
}
func (r *runtimeAgentHandlerInstanceRepo) GetByAccessToken(accessToken string) (*models.Instance, error) {
return nil, nil
}
func (r *runtimeAgentHandlerInstanceRepo) GetByAgentBootstrapToken(bootstrapToken string) (*models.Instance, error) {
return nil, nil
}
func (r *runtimeAgentHandlerInstanceRepo) GetAll(offset, limit int) ([]models.Instance, error) {
return nil, nil
}
func (r *runtimeAgentHandlerInstanceRepo) CountAll() (int, error) {
return 0, nil
}
func (r *runtimeAgentHandlerInstanceRepo) GetByUserID(userID int, offset, limit int) ([]models.Instance, error) {
return nil, nil
}
func (r *runtimeAgentHandlerInstanceRepo) CountByUserID(userID int) (int, error) {
return 0, nil
}
func (r *runtimeAgentHandlerInstanceRepo) CountActiveByMode(ctx context.Context, mode string) (int, error) {
return 0, nil
}
func (r *runtimeAgentHandlerInstanceRepo) ExistsByUserIDAndName(userID int, name string) (bool, error) {
return false, nil
}
func (r *runtimeAgentHandlerInstanceRepo) GetAllRunning() ([]models.Instance, error) {
return nil, nil
}
func (r *runtimeAgentHandlerInstanceRepo) GetV2DesiredRunning(ctx context.Context, limit int) ([]models.Instance, error) {
return nil, nil
}
func (r *runtimeAgentHandlerInstanceRepo) GetV2Creating(ctx context.Context, limit int) ([]models.Instance, error) {
return nil, nil
}
func (r *runtimeAgentHandlerInstanceRepo) UpdateRuntimeState(ctx context.Context, id int, status string, generation int, message *string) error {
if r.statusByID == nil {
r.statusByID = map[int]string{}
}
if r.generationByID == nil {
r.generationByID = map[int]int{}
}
if r.messageByID == nil {
r.messageByID = map[int]*string{}
}
r.statusByID[id] = status
r.generationByID[id] = generation
r.messageByID[id] = message
return nil
}
func (r *runtimeAgentHandlerInstanceRepo) SetWorkspacePath(ctx context.Context, id int, workspacePath string) error {
return nil
}
func (r *runtimeAgentHandlerInstanceRepo) UpdateWorkspaceUsage(ctx context.Context, id int, usageBytes int64) error {
return nil
}
func (r *runtimeAgentHandlerInstanceRepo) Update(instance *models.Instance) error {
return nil
}
func (r *runtimeAgentHandlerInstanceRepo) Delete(id int) error {
return nil
}
type runtimeAgentHandlerEvents struct {
lastType string
lastPayload any
}
func (e *runtimeAgentHandlerEvents) Publish(ctx context.Context, eventType string, payload any) error {
e.lastType = eventType
e.lastPayload = payload
return nil
}
@@ -0,0 +1,270 @@
package handlers
import (
"context"
"log"
"net/http"
"strconv"
"strings"
"time"
"clawreef/internal/models"
"clawreef/internal/repository"
"clawreef/internal/services"
"clawreef/internal/utils"
"github.com/gin-gonic/gin"
)
type RuntimePoolHandler struct {
podRepo repository.RuntimePodRepository
bindingRepo repository.InstanceRuntimeBindingRepository
rolloutRepo repository.RuntimeRolloutRepository
scheduler *services.RuntimeScheduler
events runtimeEventPublisher
}
const (
runtimePodListFallbackHeartbeatTimeout = 10 * time.Second
runtimePodListStaleWindowMultiplier = 3
)
type startRuntimeRolloutRequest struct {
RuntimeType string `json:"runtime_type" binding:"required"`
TargetImageRef string `json:"target_image_ref" binding:"required"`
BatchSize int `json:"batch_size"`
MaxUnavailable int `json:"max_unavailable"`
}
type runtimePoolPodListItem struct {
models.RuntimePod
AgentReported bool `json:"agent_reported"`
}
func NewRuntimePoolHandler(
podRepo repository.RuntimePodRepository,
bindingRepo repository.InstanceRuntimeBindingRepository,
rolloutRepo repository.RuntimeRolloutRepository,
scheduler *services.RuntimeScheduler,
events runtimeEventPublisher,
) *RuntimePoolHandler {
return &RuntimePoolHandler{
podRepo: podRepo,
bindingRepo: bindingRepo,
rolloutRepo: rolloutRepo,
scheduler: scheduler,
events: events,
}
}
func (h *RuntimePoolHandler) ListPods(c *gin.Context) {
runtimeType := strings.TrimSpace(c.Query("runtime_type"))
if runtimeType != "" {
normalized, ok := services.NormalizeV2RuntimeType(runtimeType)
if !ok {
utils.Error(c, http.StatusBadRequest, "unsupported runtime type")
return
}
runtimeType = normalized
}
pods, err := h.podRepo.List(c.Request.Context(), runtimeType)
if err != nil {
utils.HandleError(c, err)
return
}
currentPods := filterCurrentRuntimePods(pods, time.Now().UTC(), h.runtimePodListHeartbeatTimeout())
items := runtimePoolPodListItems(currentPods, true)
if h.scheduler != nil {
deploymentPods, err := h.scheduler.RuntimeDeploymentPods(c.Request.Context(), runtimeType)
if err != nil {
log.Printf("runtime pool list deployment pods failed: %v", err)
} else {
items = mergeRuntimePoolDeploymentPods(items, deploymentPods)
}
}
utils.Success(c, http.StatusOK, "Runtime pods retrieved successfully", gin.H{"pods": items})
}
func (h *RuntimePoolHandler) GetPodGateways(c *gin.Context) {
podID, ok := parseRuntimePodID(c)
if !ok {
return
}
bindings, err := h.bindingRepo.ListByRuntimePodID(c.Request.Context(), podID)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Runtime pod gateways retrieved successfully", gin.H{"gateways": bindings})
}
func (h *RuntimePoolHandler) DrainPod(c *gin.Context) {
podID, ok := parseRuntimePodID(c)
if !ok {
return
}
if h.scheduler != nil {
if err := h.scheduler.DrainPod(c.Request.Context(), podID); err != nil {
utils.HandleError(c, err)
return
}
} else if err := h.podRepo.MarkState(c.Request.Context(), podID, "draining", true); err != nil {
utils.HandleError(c, err)
return
}
h.publish(c.Request.Context(), "runtime_pod_state", map[string]any{
"pod_id": podID,
"state": "draining",
"draining": true,
})
utils.Success(c, http.StatusOK, "Runtime pod drain started", nil)
}
func (h *RuntimePoolHandler) StartRollout(c *gin.Context) {
var req startRuntimeRolloutRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
runtimeType, ok := services.NormalizeV2RuntimeType(req.RuntimeType)
if !ok {
utils.Error(c, http.StatusBadRequest, "unsupported runtime type")
return
}
targetImage := strings.TrimSpace(req.TargetImageRef)
if targetImage == "" {
utils.Error(c, http.StatusBadRequest, "target image ref is required")
return
}
batchSize := req.BatchSize
if batchSize <= 0 {
batchSize = 1
}
maxUnavailable := req.MaxUnavailable
if maxUnavailable <= 0 {
maxUnavailable = 1
}
startedBy := currentUserIDPtr(c)
rollout := &models.RuntimeRollout{
RuntimeType: runtimeType,
TargetImageRef: targetImage,
Status: "pending",
BatchSize: batchSize,
MaxUnavailable: maxUnavailable,
StartedBy: startedBy,
}
if err := h.rolloutRepo.Create(c.Request.Context(), rollout); err != nil {
utils.HandleError(c, err)
return
}
if h.scheduler != nil {
if err := h.scheduler.StartRollout(c.Request.Context(), rollout.ID); err != nil {
utils.HandleError(c, err)
return
}
}
h.publish(c.Request.Context(), "runtime_rollout", map[string]any{
"rollout_id": rollout.ID,
"runtime_type": rollout.RuntimeType,
"target_image_ref": rollout.TargetImageRef,
"status": rollout.Status,
"batch_size": rollout.BatchSize,
"max_unavailable": rollout.MaxUnavailable,
"started_by": rollout.StartedBy,
})
utils.Success(c, http.StatusCreated, "Runtime rollout created successfully", gin.H{"rollout": rollout})
}
func (h *RuntimePoolHandler) publish(ctx context.Context, eventType string, payload any) {
if h.events == nil {
return
}
_ = h.events.Publish(ctx, eventType, payload)
}
func (h *RuntimePoolHandler) runtimePodListHeartbeatTimeout() time.Duration {
if h != nil && h.scheduler != nil {
if timeout := h.scheduler.HeartbeatTimeout(); timeout > 0 {
return timeout
}
}
return runtimePodListFallbackHeartbeatTimeout
}
func filterCurrentRuntimePods(pods []models.RuntimePod, now time.Time, heartbeatTimeout time.Duration) []models.RuntimePod {
if heartbeatTimeout <= 0 {
return pods
}
cutoff := now.UTC().Add(-runtimePodListStaleWindowMultiplier * heartbeatTimeout)
current := pods[:0]
for _, pod := range pods {
if pod.LastSeenAt != nil && pod.LastSeenAt.UTC().Before(cutoff) {
continue
}
current = append(current, pod)
}
return current
}
func runtimePoolPodListItems(pods []models.RuntimePod, agentReported bool) []runtimePoolPodListItem {
items := make([]runtimePoolPodListItem, 0, len(pods))
for _, pod := range pods {
items = append(items, runtimePoolPodListItem{
RuntimePod: pod,
AgentReported: agentReported,
})
}
return items
}
func mergeRuntimePoolDeploymentPods(items []runtimePoolPodListItem, deploymentPods []models.RuntimePod) []runtimePoolPodListItem {
seen := map[string]struct{}{}
for _, item := range items {
seen[runtimePoolPodKey(item.Namespace, item.PodName)] = struct{}{}
}
for _, pod := range deploymentPods {
key := runtimePoolPodKey(pod.Namespace, pod.PodName)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
items = append(items, runtimePoolPodListItem{
RuntimePod: pod,
AgentReported: false,
})
}
return items
}
func runtimePoolPodKey(namespace, podName string) string {
namespace = strings.TrimSpace(namespace)
podName = strings.TrimSpace(podName)
if namespace == "" || podName == "" {
return ""
}
return namespace + "/" + podName
}
func parseRuntimePodID(c *gin.Context) (int64, bool) {
podID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || podID <= 0 {
utils.Error(c, http.StatusBadRequest, "invalid runtime pod ID")
return 0, false
}
return podID, true
}
func currentUserIDPtr(c *gin.Context) *int {
raw, ok := c.Get("userID")
if !ok {
return nil
}
userID, ok := raw.(int)
if !ok {
return nil
}
return &userID
}
@@ -0,0 +1,532 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"clawreef/internal/middleware"
"clawreef/internal/models"
"clawreef/internal/repository"
"clawreef/internal/services"
"clawreef/internal/services/k8s"
"github.com/gin-gonic/gin"
)
func TestRuntimePoolHandlerRejectsNonAdmin(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewRuntimePoolHandler(&runtimePoolHandlerPodRepo{}, &runtimePoolHandlerBindingRepo{}, &runtimePoolHandlerRolloutRepo{}, nil, &runtimePoolHandlerEvents{})
router := runtimePoolHandlerRouter(20, "user", handler)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/runtime-pods", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, body = %s, want 403", rec.Code, rec.Body.String())
}
}
func TestRuntimePoolHandlerListPodsReturnsMetricsForAdmin(t *testing.T) {
gin.SetMode(gin.TestMode)
now := time.Now().UTC()
podIP := "10.42.0.31"
nodeName := "node-a"
handler := NewRuntimePoolHandler(&runtimePoolHandlerPodRepo{pods: []models.RuntimePod{{
ID: 9,
RuntimeType: "openclaw",
PodName: "openclaw-runtime-abcde",
PodIP: &podIP,
NodeName: &nodeName,
State: "ready",
UsedSlots: 37,
Capacity: 100,
Draining: false,
CPUMillisUsed: 13600,
MemoryBytesUsed: 42949672960,
DiskBytesUsed: 214748364800,
NetworkRXBytes: 9223372,
NetworkTXBytes: 19223372,
LastSeenAt: &now,
}}}, &runtimePoolHandlerBindingRepo{}, &runtimePoolHandlerRolloutRepo{}, nil, &runtimePoolHandlerEvents{})
router := runtimePoolHandlerRouter(1, "admin", handler)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/runtime-pods", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s, want 200", rec.Code, rec.Body.String())
}
var resp struct {
Data struct {
Pods []struct {
ID int64 `json:"id"`
RuntimeType string `json:"runtime_type"`
PodName string `json:"pod_name"`
CPUMillisUsed int64 `json:"cpu_millis_used"`
MemoryBytesUsed int64 `json:"memory_bytes_used"`
DiskBytesUsed int64 `json:"disk_bytes_used"`
NetworkRXBytes int64 `json:"network_rx_bytes"`
NetworkTXBytes int64 `json:"network_tx_bytes"`
LastSeenAt string `json:"last_seen_at"`
} `json:"pods"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data.Pods) != 1 {
t.Fatalf("pods length = %d, want 1", len(resp.Data.Pods))
}
pod := resp.Data.Pods[0]
if pod.ID != 9 || pod.RuntimeType != "openclaw" || pod.PodName != "openclaw-runtime-abcde" {
t.Fatalf("pod identity = %#v, want runtime pod data", pod)
}
if pod.CPUMillisUsed != 13600 || pod.MemoryBytesUsed != 42949672960 || pod.DiskBytesUsed != 214748364800 || pod.NetworkRXBytes != 9223372 || pod.NetworkTXBytes != 19223372 {
t.Fatalf("pod metrics = %#v, want persisted metrics", pod)
}
}
func TestRuntimePoolHandlerListPodsOmitsStaleRuntimePods(t *testing.T) {
gin.SetMode(gin.TestMode)
recentSeen := time.Now().UTC()
staleSeen := recentSeen.Add(-5 * time.Minute)
handler := NewRuntimePoolHandler(&runtimePoolHandlerPodRepo{pods: []models.RuntimePod{
{ID: 1, RuntimeType: "openclaw", PodName: "openclaw-runtime-live", State: "ready", LastSeenAt: &recentSeen},
{ID: 2, RuntimeType: "openclaw", PodName: "openclaw-runtime-deleted", State: "unhealthy", LastSeenAt: &staleSeen},
{ID: 3, RuntimeType: "hermes", PodName: "hermes-runtime-recent-unhealthy", State: "unhealthy", LastSeenAt: &recentSeen},
}}, &runtimePoolHandlerBindingRepo{}, &runtimePoolHandlerRolloutRepo{}, nil, &runtimePoolHandlerEvents{})
router := runtimePoolHandlerRouter(1, "admin", handler)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/runtime-pods", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s, want 200", rec.Code, rec.Body.String())
}
var resp struct {
Data struct {
Pods []struct {
ID int64 `json:"id"`
PodName string `json:"pod_name"`
} `json:"pods"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data.Pods) != 2 {
t.Fatalf("pods length = %d, want 2: %#v", len(resp.Data.Pods), resp.Data.Pods)
}
for _, pod := range resp.Data.Pods {
if pod.ID == 2 || pod.PodName == "openclaw-runtime-deleted" {
t.Fatalf("stale deleted pod was returned: %#v", pod)
}
}
}
func TestRuntimePoolHandlerListPodsIncludesUnreportedDeploymentPods(t *testing.T) {
gin.SetMode(gin.TestMode)
deployments := &runtimePoolHandlerDeploymentService{
pods: []k8s.RuntimeDeploymentPod{{
RuntimeType: "openclaw",
Namespace: "clawmanager-system",
DeploymentName: "openclaw-runtime",
PodName: "openclaw-runtime-current",
ImageRef: "registry/openclaw-lite:final2",
State: "ready",
}},
}
scheduler := services.NewRuntimeScheduler(
nil,
&runtimePoolHandlerPodRepo{},
nil,
&runtimePoolHandlerRolloutRepo{},
&runtimePoolHandlerAgentClient{},
services.NewRuntimeEventService(nil),
nil,
deployments,
time.Second,
services.WithRuntimeSchedulerNamespace("clawmanager-system"),
services.WithRuntimeSchedulerMaxGatewaysPerPod(33),
)
handler := NewRuntimePoolHandler(&runtimePoolHandlerPodRepo{}, &runtimePoolHandlerBindingRepo{}, &runtimePoolHandlerRolloutRepo{}, scheduler, &runtimePoolHandlerEvents{})
router := runtimePoolHandlerRouter(1, "admin", handler)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/runtime-pods?runtime_type=openclaw", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s, want 200", rec.Code, rec.Body.String())
}
var resp struct {
Data struct {
Pods []struct {
ID int64 `json:"id"`
RuntimeType string `json:"runtime_type"`
PodName string `json:"pod_name"`
ImageRef string `json:"image_ref"`
State string `json:"state"`
Capacity int `json:"capacity"`
AgentReported bool `json:"agent_reported"`
DeploymentName string `json:"deployment_name"`
} `json:"pods"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data.Pods) != 1 {
t.Fatalf("pods length = %d, want 1: %#v", len(resp.Data.Pods), resp.Data.Pods)
}
pod := resp.Data.Pods[0]
if pod.ID >= 0 || pod.AgentReported {
t.Fatalf("fallback pod identity = id:%d agent_reported:%t, want synthetic unreported pod", pod.ID, pod.AgentReported)
}
if pod.RuntimeType != "openclaw" || pod.PodName != "openclaw-runtime-current" || pod.DeploymentName != "openclaw-runtime" {
t.Fatalf("fallback pod metadata = %#v, want OpenClaw deployment pod", pod)
}
if pod.ImageRef != "registry/openclaw-lite:final2" {
t.Fatalf("fallback pod image = %q, want registry/openclaw-lite:final2", pod.ImageRef)
}
if pod.State != "unhealthy" {
t.Fatalf("fallback pod state = %q, want unhealthy while agent is not reporting", pod.State)
}
if pod.Capacity != 33 {
t.Fatalf("fallback pod capacity = %d, want configured capacity 33", pod.Capacity)
}
}
func TestRuntimePoolHandlerStartRolloutStoresRequesterAndPublishesEvent(t *testing.T) {
gin.SetMode(gin.TestMode)
rolloutRepo := &runtimePoolHandlerRolloutRepo{}
events := &runtimePoolHandlerEvents{}
handler := NewRuntimePoolHandler(&runtimePoolHandlerPodRepo{}, &runtimePoolHandlerBindingRepo{}, rolloutRepo, nil, events)
router := runtimePoolHandlerRouter(7, "admin", handler)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/runtime-rollouts", bytes.NewBufferString(`{
"runtime_type": "hermes",
"target_image_ref": "ghcr.io/example/hermes:v2",
"batch_size": 2,
"max_unavailable": 1
}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, body = %s, want 201", rec.Code, rec.Body.String())
}
if rolloutRepo.created == nil {
t.Fatalf("rollout was not created")
}
if rolloutRepo.created.StartedBy == nil || *rolloutRepo.created.StartedBy != 7 {
t.Fatalf("started_by = %#v, want 7", rolloutRepo.created.StartedBy)
}
if events.lastType != "runtime_rollout" {
t.Fatalf("event type = %q, want runtime_rollout", events.lastType)
}
}
func TestRuntimePoolHandlerStartRolloutRunsSchedulerImmediately(t *testing.T) {
gin.SetMode(gin.TestMode)
rolloutRepo := &runtimePoolHandlerRolloutRepo{}
podRepo := &runtimePoolHandlerPodRepo{pods: []models.RuntimePod{{
ID: 21,
RuntimeType: "hermes",
Namespace: "clawmanager-system",
PodName: "hermes-runtime-old",
DeploymentName: "hermes-runtime",
ImageRef: "registry/hermes:v1",
State: "ready",
Capacity: 100,
UsedSlots: 7,
}}}
deployments := &runtimePoolHandlerDeploymentService{}
scheduler := services.NewRuntimeScheduler(
nil,
podRepo,
nil,
rolloutRepo,
&runtimePoolHandlerAgentClient{},
services.NewRuntimeEventService(nil),
nil,
deployments,
time.Second,
)
handler := NewRuntimePoolHandler(podRepo, &runtimePoolHandlerBindingRepo{}, rolloutRepo, scheduler, &runtimePoolHandlerEvents{})
router := runtimePoolHandlerRouter(7, "admin", handler)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/runtime-rollouts", bytes.NewBufferString(`{
"runtime_type": "hermes",
"target_image_ref": "registry/hermes:v2",
"batch_size": 1,
"max_unavailable": 1
}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, body = %s, want 201", rec.Code, rec.Body.String())
}
if got := len(deployments.rollouts); got != 1 {
t.Fatalf("deployment rollouts = %d, want 1", got)
}
rollout := deployments.rollouts[0]
if rollout.namespace != "clawmanager-system" || rollout.name != "hermes-runtime" || rollout.image != "registry/hermes:v2" {
t.Fatalf("deployment rollout = %+v, want hermes-runtime registry/hermes:v2", rollout)
}
if podRepo.markedPodID != 21 || podRepo.markedState != "draining" || !podRepo.markedDraining {
t.Fatalf("pod mark = id:%d state:%q draining:%t, want drain pod 21", podRepo.markedPodID, podRepo.markedState, podRepo.markedDraining)
}
}
func runtimePoolHandlerRouter(userID int, role string, handler *RuntimePoolHandler) *gin.Engine {
userRepo := &runtimePoolHandlerUserRepo{users: map[int]*models.User{
userID: {ID: userID, Username: "tester", Role: role},
}}
router := gin.New()
admin := router.Group("/api/v1/admin")
admin.Use(func(c *gin.Context) {
c.Set("userID", userID)
c.Next()
})
admin.Use(middleware.SetUserInfo(userRepo))
admin.Use(middleware.NewAdminAuth(userRepo))
{
admin.GET("/runtime-pods", handler.ListPods)
admin.POST("/runtime-rollouts", handler.StartRollout)
}
return router
}
type runtimePoolHandlerUserRepo struct {
users map[int]*models.User
}
func (r *runtimePoolHandlerUserRepo) Create(user *models.User) error { return nil }
func (r *runtimePoolHandlerUserRepo) GetByID(id int) (*models.User, error) {
return r.users[id], nil
}
func (r *runtimePoolHandlerUserRepo) GetByUsername(username string) (*models.User, error) {
return nil, nil
}
func (r *runtimePoolHandlerUserRepo) GetByEmail(email string) (*models.User, error) {
return nil, nil
}
func (r *runtimePoolHandlerUserRepo) Update(user *models.User) error { return nil }
func (r *runtimePoolHandlerUserRepo) Delete(id int) error { return nil }
func (r *runtimePoolHandlerUserRepo) List(offset, limit int) ([]models.User, error) {
return nil, nil
}
func (r *runtimePoolHandlerUserRepo) Count() (int, error) { return 0, nil }
type runtimePoolHandlerPodRepo struct {
pods []models.RuntimePod
markedPodID int64
markedState string
markedDraining bool
}
func (r *runtimePoolHandlerPodRepo) UpsertFromAgent(ctx context.Context, pod *models.RuntimePod) error {
return nil
}
func (r *runtimePoolHandlerPodRepo) GetByID(ctx context.Context, id int64) (*models.RuntimePod, error) {
for _, pod := range r.pods {
if pod.ID == id {
cp := pod
return &cp, nil
}
}
return nil, nil
}
func (r *runtimePoolHandlerPodRepo) GetByNamespaceName(ctx context.Context, namespace, podName string) (*models.RuntimePod, error) {
return nil, nil
}
func (r *runtimePoolHandlerPodRepo) List(ctx context.Context, runtimeType string) ([]models.RuntimePod, error) {
if runtimeType == "" {
return append([]models.RuntimePod(nil), r.pods...), nil
}
var pods []models.RuntimePod
for _, pod := range r.pods {
if pod.RuntimeType == runtimeType {
pods = append(pods, pod)
}
}
return pods, nil
}
func (r *runtimePoolHandlerPodRepo) ListSchedulable(ctx context.Context, runtimeType string) ([]models.RuntimePod, error) {
return nil, nil
}
func (r *runtimePoolHandlerPodRepo) TryClaimSlot(ctx context.Context, podID int64) (bool, error) {
return false, nil
}
func (r *runtimePoolHandlerPodRepo) ReleaseSlot(ctx context.Context, podID int64) error {
return nil
}
func (r *runtimePoolHandlerPodRepo) MarkState(ctx context.Context, podID int64, state string, draining bool) error {
r.markedPodID = podID
r.markedState = state
r.markedDraining = draining
return nil
}
func (r *runtimePoolHandlerPodRepo) UpdateHeartbeat(ctx context.Context, podID int64, state string, usedSlots int, capacity int, draining bool, lastSeenAt time.Time) error {
return nil
}
func (r *runtimePoolHandlerPodRepo) MarkUnseenUnhealthy(ctx context.Context, cutoff time.Time) error {
return nil
}
func (r *runtimePoolHandlerPodRepo) UpdateMetrics(ctx context.Context, podID int64, metrics repository.RuntimePodMetricsUpdate) error {
return nil
}
type runtimePoolHandlerBindingRepo struct {
bindings []models.InstanceRuntimeBinding
}
func (r *runtimePoolHandlerBindingRepo) Create(ctx context.Context, binding *models.InstanceRuntimeBinding) error {
return nil
}
func (r *runtimePoolHandlerBindingRepo) GetByInstanceID(ctx context.Context, instanceID int) (*models.InstanceRuntimeBinding, error) {
return nil, nil
}
func (r *runtimePoolHandlerBindingRepo) GetRunningByInstanceID(ctx context.Context, instanceID int) (*models.InstanceRuntimeBinding, error) {
return nil, nil
}
func (r *runtimePoolHandlerBindingRepo) ListByRuntimePodID(ctx context.Context, runtimePodID int64) ([]models.InstanceRuntimeBinding, error) {
var bindings []models.InstanceRuntimeBinding
for _, binding := range r.bindings {
if binding.RuntimePodID == runtimePodID {
bindings = append(bindings, binding)
}
}
return bindings, nil
}
func (r *runtimePoolHandlerBindingRepo) ListByRuntimePodIDs(ctx context.Context, runtimePodIDs []int64) ([]models.InstanceRuntimeBinding, error) {
return nil, nil
}
func (r *runtimePoolHandlerBindingRepo) UpdateRunning(ctx context.Context, instanceID int, generation int, gatewayID string, port int, pid *int) error {
return nil
}
func (r *runtimePoolHandlerBindingRepo) UpdateState(ctx context.Context, instanceID int, generation int, state string, message *string) error {
return nil
}
func (r *runtimePoolHandlerBindingRepo) DeleteByInstanceID(ctx context.Context, instanceID int) error {
return nil
}
func (r *runtimePoolHandlerBindingRepo) DeleteByInstanceIDAndReleaseSlot(ctx context.Context, instanceID int, runtimePodID int64) error {
return nil
}
type runtimePoolHandlerRolloutRepo struct {
created *models.RuntimeRollout
byID map[int64]*models.RuntimeRollout
}
func (r *runtimePoolHandlerRolloutRepo) Create(ctx context.Context, rollout *models.RuntimeRollout) error {
rollout.ID = 12
cp := *rollout
r.created = &cp
if r.byID == nil {
r.byID = map[int64]*models.RuntimeRollout{}
}
r.byID[rollout.ID] = &cp
return nil
}
func (r *runtimePoolHandlerRolloutRepo) GetByID(ctx context.Context, id int64) (*models.RuntimeRollout, error) {
if r.byID == nil {
return nil, nil
}
return r.byID[id], nil
}
func (r *runtimePoolHandlerRolloutRepo) ListActive(ctx context.Context, runtimeType string) ([]models.RuntimeRollout, error) {
return nil, nil
}
func (r *runtimePoolHandlerRolloutRepo) UpdateStatus(ctx context.Context, id int64, status string, startedAt *time.Time, finishedAt *time.Time, message *string) error {
return nil
}
type runtimePoolHandlerEvents struct {
lastType string
lastPayload any
}
func (e *runtimePoolHandlerEvents) Publish(ctx context.Context, eventType string, payload any) error {
e.lastType = eventType
e.lastPayload = payload
return nil
}
type runtimePoolHandlerDeploymentService struct {
rollouts []runtimePoolHandlerDeploymentRollout
pods []k8s.RuntimeDeploymentPod
}
type runtimePoolHandlerDeploymentRollout struct {
namespace string
name string
image string
maxUnavailable int
maxSurge int
}
func (s *runtimePoolHandlerDeploymentService) Ensure(ctx context.Context, spec k8s.RuntimeDeploymentSpec) error {
return nil
}
func (s *runtimePoolHandlerDeploymentService) Scale(ctx context.Context, namespace, name string, replicas int32) error {
return nil
}
func (s *runtimePoolHandlerDeploymentService) RolloutImage(ctx context.Context, namespace, name, image string, maxUnavailable, maxSurge int) error {
s.rollouts = append(s.rollouts, runtimePoolHandlerDeploymentRollout{
namespace: namespace,
name: name,
image: image,
maxUnavailable: maxUnavailable,
maxSurge: maxSurge,
})
return nil
}
func (s *runtimePoolHandlerDeploymentService) ListPods(ctx context.Context, namespace, runtimeType string) ([]k8s.RuntimeDeploymentPod, error) {
var pods []k8s.RuntimeDeploymentPod
for _, pod := range s.pods {
if namespace != "" && pod.Namespace != namespace {
continue
}
if runtimeType != "" && pod.RuntimeType != runtimeType {
continue
}
pods = append(pods, pod)
}
return pods, nil
}
type runtimePoolHandlerAgentClient struct{}
func (c *runtimePoolHandlerAgentClient) Health(ctx context.Context, endpoint string) error {
return nil
}
func (c *runtimePoolHandlerAgentClient) CreateGateway(ctx context.Context, endpoint string, req services.RuntimeAgentCreateGatewayRequest) (*services.RuntimeAgentCreateGatewayResponse, error) {
return nil, nil
}
func (c *runtimePoolHandlerAgentClient) DeleteGateway(ctx context.Context, endpoint, gatewayID string) error {
return nil
}
func (c *runtimePoolHandlerAgentClient) Drain(ctx context.Context, endpoint string) error { return nil }
@@ -0,0 +1,104 @@
package handlers
import (
"net/http"
"strconv"
"clawreef/internal/services"
"clawreef/internal/utils"
"github.com/gin-gonic/gin"
)
type SecurityHandler struct {
service services.SecurityScanService
}
func NewSecurityHandler(service services.SecurityScanService) *SecurityHandler {
return &SecurityHandler{service: service}
}
func (h *SecurityHandler) GetConfig(c *gin.Context) {
item, err := h.service.GetConfig()
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Security scan config retrieved successfully", item)
}
func (h *SecurityHandler) SaveConfig(c *gin.Context) {
var req services.SecurityScanConfigPayload
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
userID, _ := c.Get("userID")
item, err := h.service.SaveConfig(userID.(int), req)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Security scan config saved successfully", item)
}
func (h *SecurityHandler) StartScan(c *gin.Context) {
var req services.StartSecurityScanRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
userID, _ := c.Get("userID")
item, err := h.service.StartScan(userID.(int), req)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "Security scan started successfully", item)
}
func (h *SecurityHandler) ListJobs(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
items, err := h.service.ListJobs(limit)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Security scan jobs retrieved successfully", items)
}
func (h *SecurityHandler) GetJob(c *gin.Context) {
jobID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid job ID")
return
}
item, err := h.service.GetJob(jobID)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Security scan job retrieved successfully", item)
}
func (h *SecurityHandler) RescanSkill(c *gin.Context) {
skillID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
return
}
var req struct {
ScanMode string `json:"scan_mode"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
userID, _ := c.Get("userID")
item, err := h.service.RescanSkill(userID.(int), skillID, req.ScanMode)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "Skill rescan started successfully", item)
}
+250
View File
@@ -0,0 +1,250 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"clawreef/internal/services"
"clawreef/internal/utils"
"github.com/gin-gonic/gin"
)
type SkillHandler struct {
service services.SkillService
instanceService services.InstanceService
}
func NewSkillHandler(service services.SkillService, instanceService services.InstanceService) *SkillHandler {
return &SkillHandler{service: service, instanceService: instanceService}
}
func (h *SkillHandler) ImportSkills(c *gin.Context) {
userID, _ := c.Get("userID")
fileHeader, err := c.FormFile("file")
if err != nil {
utils.Error(c, http.StatusBadRequest, "file is required")
return
}
items, err := h.service.ImportArchive(c.Request.Context(), userID.(int), fileHeader)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "Skills imported successfully", items)
}
func (h *SkillHandler) ListSkills(c *gin.Context) {
userID, _ := c.Get("userID")
items, err := h.service.ListSkills(userID.(int))
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Skills retrieved successfully", items)
}
func (h *SkillHandler) ListAllSkills(c *gin.Context) {
items, err := h.service.ListAllSkills()
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "All skills retrieved successfully", items)
}
func (h *SkillHandler) GetSkill(c *gin.Context) {
userID, _ := c.Get("userID")
skillID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
return
}
item, err := h.service.GetSkill(userID.(int), skillID)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Skill retrieved successfully", item)
}
func (h *SkillHandler) UpdateSkill(c *gin.Context) {
userID, _ := c.Get("userID")
skillID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
return
}
var req services.UpdateSkillRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
item, err := h.service.UpdateSkill(userID.(int), skillID, req)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Skill updated successfully", item)
}
func (h *SkillHandler) DeleteSkill(c *gin.Context) {
userID, _ := c.Get("userID")
skillID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
return
}
if err := h.service.DeleteSkill(userID.(int), skillID); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Skill deleted successfully", nil)
}
func (h *SkillHandler) DownloadSkill(c *gin.Context) {
userID, _ := c.Get("userID")
skillID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
return
}
content, fileName, err := h.service.DownloadSkill(userID.(int), skillID)
if err != nil {
utils.HandleError(c, err)
return
}
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
c.Data(http.StatusOK, "application/zip", content)
}
func (h *SkillHandler) DownloadSkillVersionForAgent(c *gin.Context) {
content, fileName, err := h.service.DownloadSkillVersionByExternalID(c.Param("skillVersion"))
if err != nil {
utils.HandleError(c, err)
return
}
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
c.Data(http.StatusOK, "application/octet-stream", content)
}
func (h *SkillHandler) ListVersions(c *gin.Context) {
userID, _ := c.Get("userID")
skillID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
return
}
items, err := h.service.ListVersions(userID.(int), skillID)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Skill versions retrieved successfully", items)
}
func (h *SkillHandler) ListScanResults(c *gin.Context) {
userID, _ := c.Get("userID")
skillID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
return
}
items, err := h.service.ListScanResults(userID.(int), skillID)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Skill scan results retrieved successfully", items)
}
func (h *SkillHandler) ListAvailableInstanceSkills(c *gin.Context) {
instanceID, ok := h.authorizeOwnedInstance(c)
if !ok {
return
}
userID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
items, err := h.service.ListAvailableSkillsForInstance(instanceID, userID.(int), fmt.Sprint(userRole))
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Available instance skills retrieved successfully", items)
}
func (h *SkillHandler) ListInstanceSkills(c *gin.Context) {
instanceID, ok := h.authorizeOwnedInstance(c)
if !ok {
return
}
items, err := h.service.ListInstanceSkills(instanceID)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Instance skills retrieved successfully", items)
}
func (h *SkillHandler) AttachSkillToInstance(c *gin.Context) {
instanceID, ok := h.authorizeOwnedInstance(c)
if !ok {
return
}
var req services.AttachSkillToInstanceRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
userID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
item, err := h.service.AttachSkillToInstanceForActor(instanceID, req.SkillID, userID.(int), fmt.Sprint(userRole))
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "Skill attached to instance successfully", item)
}
func (h *SkillHandler) RemoveSkillFromInstance(c *gin.Context) {
instanceID, ok := h.authorizeOwnedInstance(c)
if !ok {
return
}
skillID, err := strconv.Atoi(c.Param("skillId"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
return
}
if err := h.service.RemoveSkillFromInstance(instanceID, skillID); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Skill removed from instance successfully", nil)
}
func (h *SkillHandler) authorizeOwnedInstance(c *gin.Context) (int, bool) {
instanceID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid instance ID")
return 0, false
}
instance, err := h.instanceService.GetByID(instanceID)
if err != nil {
utils.HandleError(c, err)
return 0, false
}
if instance == nil {
utils.Error(c, http.StatusNotFound, "Instance not found")
return 0, false
}
userID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
if userRole != "admin" && instance.UserID != userID.(int) {
utils.Error(c, http.StatusForbidden, "Access denied")
return 0, false
}
return instanceID, true
}
@@ -0,0 +1,82 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"clawreef/internal/models"
"clawreef/internal/services"
"clawreef/internal/utils"
"github.com/gin-gonic/gin"
)
type SystemSettingsHandler struct {
systemImageSettingService services.SystemImageSettingService
}
type UpsertSystemImageSettingRequest struct {
ID int `json:"id,omitempty"`
InstanceType string `json:"instance_type" binding:"required"`
RuntimeType string `json:"runtime_type" binding:"omitempty,oneof=desktop gateway shell"`
DisplayName string `json:"display_name"`
Image string `json:"image" binding:"required"`
}
func NewSystemSettingsHandler(systemImageSettingService services.SystemImageSettingService) *SystemSettingsHandler {
return &SystemSettingsHandler{systemImageSettingService: systemImageSettingService}
}
func (h *SystemSettingsHandler) ListSystemImageSettings(c *gin.Context) {
settings, err := h.systemImageSettingService.List()
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "System image settings retrieved successfully", gin.H{
"items": settings,
})
}
func (h *SystemSettingsHandler) UpsertSystemImageSetting(c *gin.Context) {
var req UpsertSystemImageSettingRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
setting := &models.SystemImageSetting{
ID: req.ID,
InstanceType: strings.TrimSpace(req.InstanceType),
RuntimeType: strings.TrimSpace(req.RuntimeType),
DisplayName: strings.TrimSpace(req.DisplayName),
Image: strings.TrimSpace(req.Image),
}
saved, err := h.systemImageSettingService.Save(setting)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "System image setting saved successfully", saved)
}
func (h *SystemSettingsHandler) DeleteSystemImageSetting(c *gin.Context) {
target := strings.TrimSpace(c.Param("instanceType"))
if id, err := strconv.Atoi(target); err == nil {
if err := h.systemImageSettingService.DeleteByID(id); err != nil {
utils.HandleError(c, err)
return
}
} else {
if err := h.systemImageSettingService.DisableType(target); err != nil {
utils.HandleError(c, err)
return
}
}
utils.Success(c, http.StatusOK, "System image setting deleted successfully", nil)
}
+291
View File
@@ -0,0 +1,291 @@
package handlers
import (
"io"
"net/http"
"strconv"
"strings"
"clawreef/internal/services"
"clawreef/internal/utils"
"github.com/gin-gonic/gin"
)
type TeamHandler struct {
teamService services.TeamService
}
func NewTeamHandler(teamService services.TeamService) *TeamHandler {
return &TeamHandler{teamService: teamService}
}
func (h *TeamHandler) CreateTeam(c *gin.Context) {
userID, _ := c.Get("userID")
var req services.CreateTeamRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
team, err := h.teamService.CreateTeam(userID.(int), req)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "Team created successfully", team)
}
func (h *TeamHandler) ListTeams(c *gin.Context) {
userID, _ := c.Get("userID")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
if page <= 0 {
page = 1
}
if limit <= 0 {
limit = 20
}
teams, err := h.teamService.ListTeams(userID.(int), (page-1)*limit, limit)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Teams retrieved successfully", gin.H{
"teams": teams.Teams,
"total": teams.Total,
"page": page,
"limit": limit,
})
}
func (h *TeamHandler) GetTeam(c *gin.Context) {
userID, _ := c.Get("userID")
teamID, ok := parseTeamID(c)
if !ok {
return
}
team, err := h.teamService.GetTeam(userID.(int), teamID)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Team retrieved successfully", team)
}
func (h *TeamHandler) ListTasks(c *gin.Context) {
userID, _ := c.Get("userID")
teamID, ok := parseTeamID(c)
if !ok {
return
}
tasks, err := h.teamService.ListTeamTasks(
userID.(int),
teamID,
parseOptionalIntQuery(c, "before_id"),
parseOptionalIntQuery(c, "limit"),
)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Team tasks retrieved successfully", tasks)
}
func (h *TeamHandler) ListEvents(c *gin.Context) {
userID, _ := c.Get("userID")
teamID, ok := parseTeamID(c)
if !ok {
return
}
events, err := h.teamService.ListTeamEvents(
userID.(int),
teamID,
parseOptionalIntQuery(c, "before_id"),
parseOptionalIntQuery(c, "limit"),
)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Team events retrieved successfully", events)
}
func (h *TeamHandler) ListWorkspaceFiles(c *gin.Context) {
userID, _ := c.Get("userID")
teamID, ok := parseTeamID(c)
if !ok {
return
}
payload, err := h.teamService.ListWorkspaceFiles(c.Request.Context(), userID.(int), teamID, c.Query("path"))
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Team workspace files retrieved successfully", payload)
}
func (h *TeamHandler) PreviewWorkspaceFile(c *gin.Context) {
userID, _ := c.Get("userID")
teamID, ok := parseTeamID(c)
if !ok {
return
}
payload, err := h.teamService.PreviewWorkspaceFile(c.Request.Context(), userID.(int), teamID, c.Query("path"))
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Team workspace file preview retrieved successfully", payload)
}
func (h *TeamHandler) DownloadWorkspaceFile(c *gin.Context) {
userID, _ := c.Get("userID")
teamID, ok := parseTeamID(c)
if !ok {
return
}
payload, err := h.teamService.DownloadWorkspaceFile(c.Request.Context(), userID.(int), teamID, c.Query("path"))
if err != nil {
utils.HandleError(c, err)
return
}
c.Header("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(payload.Name, `"`, "")+`"`)
c.Data(http.StatusOK, payload.ContentType, payload.Data)
}
func (h *TeamHandler) CreateWorkspaceFolder(c *gin.Context) {
userID, _ := c.Get("userID")
teamID, ok := parseTeamID(c)
if !ok {
return
}
var req services.TeamWorkspaceFolderRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
if err := h.teamService.CreateWorkspaceFolder(c.Request.Context(), userID.(int), teamID, req); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "Team workspace folder created successfully", nil)
}
func (h *TeamHandler) RenameWorkspaceEntry(c *gin.Context) {
userID, _ := c.Get("userID")
teamID, ok := parseTeamID(c)
if !ok {
return
}
var req services.TeamWorkspaceRenameRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
if err := h.teamService.RenameWorkspaceEntry(c.Request.Context(), userID.(int), teamID, req); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Team workspace entry renamed successfully", nil)
}
func (h *TeamHandler) DeleteWorkspaceEntry(c *gin.Context) {
userID, _ := c.Get("userID")
teamID, ok := parseTeamID(c)
if !ok {
return
}
if err := h.teamService.DeleteWorkspaceEntry(c.Request.Context(), userID.(int), teamID, c.Query("path")); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Team workspace entry deleted successfully", nil)
}
func (h *TeamHandler) UploadWorkspaceFiles(c *gin.Context) {
userID, _ := c.Get("userID")
teamID, ok := parseTeamID(c)
if !ok {
return
}
form, err := c.MultipartForm()
if err != nil {
utils.ValidationError(c, err)
return
}
files := form.File["files"]
relativePaths := form.Value["relative_paths"]
if err := h.teamService.UploadWorkspaceFiles(c.Request.Context(), userID.(int), teamID, c.PostForm("path"), files, relativePaths); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "Team workspace files uploaded successfully", nil)
}
func (h *TeamHandler) DispatchTask(c *gin.Context) {
userID, _ := c.Get("userID")
teamID, ok := parseTeamID(c)
if !ok {
return
}
var req services.DispatchTeamTaskRequest
if err := c.ShouldBindJSON(&req); err != nil && err != io.EOF {
utils.ValidationError(c, err)
return
}
task, err := h.teamService.DispatchTask(userID.(int), teamID, req)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "Team task dispatched successfully", task)
}
func (h *TeamHandler) DeleteTeam(c *gin.Context) {
userID, _ := c.Get("userID")
teamID, ok := parseTeamID(c)
if !ok {
return
}
if err := h.teamService.DeleteTeam(userID.(int), teamID); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Team deleted successfully", gin.H{"id": teamID})
}
func (h *TeamHandler) DeleteMember(c *gin.Context) {
userID, _ := c.Get("userID")
teamID, ok := parseTeamID(c)
if !ok {
return
}
memberID := c.Param("memberID")
if err := h.teamService.DeleteMember(userID.(int), teamID, memberID); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Team member deleted successfully", gin.H{"member_id": memberID})
}
func parseTeamID(c *gin.Context) (int, bool) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid Team ID")
return 0, false
}
return id, true
}
func parseOptionalIntQuery(c *gin.Context, key string) int {
raw := c.Query(key)
if raw == "" {
return 0
}
value, err := strconv.Atoi(raw)
if err != nil || value < 0 {
return 0
}
return value
}
+587
View File
@@ -0,0 +1,587 @@
package handlers
import (
"encoding/csv"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"clawreef/internal/models"
"clawreef/internal/services"
"clawreef/internal/utils"
"github.com/gin-gonic/gin"
)
// UserHandler handles user management requests
type UserHandler struct {
userService services.UserService
quotaService services.QuotaService
}
// NewUserHandler creates a new user handler
func NewUserHandler(userService services.UserService, quotaService services.QuotaService) *UserHandler {
return &UserHandler{
userService: userService,
quotaService: quotaService,
}
}
// ListUsersRequest represents a list users request
type ListUsersRequest struct {
Page int `form:"page,default=1"`
Limit int `form:"limit,default=20"`
}
// UpdateUserRequest represents an update user request
type UpdateUserRequest struct {
Email string `json:"email" binding:"omitempty,email"`
IsActive *bool `json:"is_active" binding:"omitempty"`
}
// UpdateRoleRequest represents an update role request
type UpdateRoleRequest struct {
Role string `json:"role" binding:"required,oneof=admin user"`
}
// UpdateQuotaRequest represents an update quota request
type UpdateQuotaRequest struct {
MaxInstances int `json:"max_instances" binding:"min=0"`
MaxCPUCores float64 `json:"max_cpu_cores" binding:"min=0"`
MaxMemoryGB int `json:"max_memory_gb" binding:"min=0"`
MaxStorageGB int `json:"max_storage_gb" binding:"min=0"`
MaxGPUCount int `json:"max_gpu_count" binding:"min=0"`
}
// CreateUserRequest represents a create user request (admin only)
type CreateUserRequest struct {
Username string `json:"username" binding:"required,min=3,max=32,alphanum"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"omitempty,min=8"`
Role string `json:"role" binding:"required,oneof=admin user"`
}
type importUserResult struct {
Line int `json:"line"`
Username string `json:"username"`
Error string `json:"error"`
}
type importedUserCredential struct {
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
MaxInstances int `json:"max_instances"`
MaxCPUCores float64 `json:"max_cpu_cores"`
MaxMemoryGB int `json:"max_memory_gb"`
MaxStorageGB int `json:"max_storage_gb"`
MaxGPUCount int `json:"max_gpu_count"`
InitialPassword string `json:"initial_password"`
}
var importUsernamePattern = regexp.MustCompile(`^[a-zA-Z0-9]+$`)
// ListUsers lists all users (admin only)
func (h *UserHandler) ListUsers(c *gin.Context) {
var req ListUsersRequest
if err := c.ShouldBindQuery(&req); err != nil {
utils.ValidationError(c, err)
return
}
// Calculate offset
offset := (req.Page - 1) * req.Limit
users, err := h.userService.ListUsers(offset, req.Limit)
if err != nil {
utils.HandleError(c, err)
return
}
// Get total count
total, err := h.userService.CountUsers()
if err != nil {
utils.HandleError(c, err)
return
}
response := map[string]interface{}{
"users": users,
"total": total,
"page": req.Page,
"limit": req.Limit,
}
utils.Success(c, http.StatusOK, "Users retrieved successfully", response)
}
// CreateUser creates a new user (admin only)
func (h *UserHandler) CreateUser(c *gin.Context) {
var req CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
user, err := h.userService.CreateUser(req.Username, req.Email, req.Password, req.Role)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "User created successfully", user)
}
// ImportUsers imports users from a CSV file (admin only)
func (h *UserHandler) ImportUsers(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
utils.Error(c, http.StatusBadRequest, "User import file is required")
return
}
if !strings.HasSuffix(strings.ToLower(file.Filename), ".csv") {
utils.Error(c, http.StatusBadRequest, "Only CSV files are supported")
return
}
src, err := file.Open()
if err != nil {
utils.Error(c, http.StatusBadRequest, "Failed to open import file")
return
}
defer src.Close()
reader := csv.NewReader(src)
reader.TrimLeadingSpace = true
reader.FieldsPerRecord = -1
rows, err := reader.ReadAll()
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid CSV format")
return
}
if len(rows) == 0 {
utils.Error(c, http.StatusBadRequest, "Import file is empty")
return
}
results := make([]importUserResult, 0)
createdUsers := make([]importedUserCredential, 0)
headerMap := buildImportHeaderMap(rows[0])
if len(headerMap) == 0 {
utils.Error(c, http.StatusBadRequest, "Import file must include the required CSV headers")
return
}
requiredHeaders := []string{"username", "role", "maxinstances", "maxcpucores", "maxmemorygb", "maxstoragegb"}
for _, header := range requiredHeaders {
if _, ok := headerMap[header]; !ok {
utils.Error(c, http.StatusBadRequest, fmt.Sprintf("%s column is required", headerLabel(header)))
return
}
}
for i := 1; i < len(rows); i++ {
lineNumber := i + 1
fields := normalizeImportFields(rows[i])
if len(fields) == 0 {
continue
}
username := importFieldValue(fields, headerMap, "username")
email := importFieldValue(fields, headerMap, "email")
role := importFieldValue(fields, headerMap, "role")
password := importFieldValue(fields, headerMap, "password")
maxInstances, parseErr := parseImportInt(fields, headerMap, "maxinstances", true)
if parseErr != "" {
results = append(results, importUserResult{Line: lineNumber, Username: username, Error: parseErr})
continue
}
maxCPUCores, parseErr := parseImportFloat(fields, headerMap, "maxcpucores", true)
if parseErr != "" {
results = append(results, importUserResult{Line: lineNumber, Username: username, Error: parseErr})
continue
}
maxMemoryGB, parseErr := parseImportInt(fields, headerMap, "maxmemorygb", true)
if parseErr != "" {
results = append(results, importUserResult{Line: lineNumber, Username: username, Error: parseErr})
continue
}
maxStorageGB, parseErr := parseImportInt(fields, headerMap, "maxstoragegb", true)
if parseErr != "" {
results = append(results, importUserResult{Line: lineNumber, Username: username, Error: parseErr})
continue
}
maxGPUCount, parseErr := parseImportInt(fields, headerMap, "maxgpucount", false)
if parseErr != "" {
results = append(results, importUserResult{Line: lineNumber, Username: username, Error: parseErr})
continue
}
if email == "" && username != "" {
email = fmt.Sprintf("%s@import.clawmanager.local", strings.ToLower(username))
}
if validationErr := validateImportedUser(username, email, password, role); validationErr != "" {
results = append(results, importUserResult{
Line: lineNumber,
Username: username,
Error: validationErr,
})
continue
}
if password == "" {
password = servicesDefaultPasswordForRole(role)
}
user, createErr := h.userService.CreateUser(username, email, password, role)
if createErr != nil {
results = append(results, importUserResult{
Line: lineNumber,
Username: username,
Error: createErr.Error(),
})
continue
}
if quotaErr := h.quotaService.UpdateUserQuota(user.ID, &models.UserQuota{
MaxInstances: maxInstances,
MaxCPUCores: maxCPUCores,
MaxMemoryGB: maxMemoryGB,
MaxStorageGB: maxStorageGB,
MaxGPUCount: maxGPUCount,
}); quotaErr != nil {
results = append(results, importUserResult{
Line: lineNumber,
Username: username,
Error: quotaErr.Error(),
})
continue
}
createdUsers = append(createdUsers, importedUserCredential{
Username: user.Username,
Email: user.Email,
Role: user.Role,
MaxInstances: maxInstances,
MaxCPUCores: maxCPUCores,
MaxMemoryGB: maxMemoryGB,
MaxStorageGB: maxStorageGB,
MaxGPUCount: maxGPUCount,
InitialPassword: password,
})
}
utils.Success(c, http.StatusCreated, "Users imported successfully", gin.H{
"created_count": len(createdUsers),
"failed_count": len(results),
"created_users": createdUsers,
"errors": results,
})
}
func normalizeImportFields(record []string) []string {
fields := make([]string, 0, len(record))
for _, field := range record {
trimmed := strings.TrimSpace(field)
if trimmed == "" {
fields = append(fields, "")
continue
}
fields = append(fields, trimmed)
}
return fields
}
func buildImportHeaderMap(record []string) map[string]int {
headers := map[string]int{}
for index, raw := range record {
key := normalizeImportHeader(raw)
switch key {
case "username", "email", "role", "password", "maxinstances", "maxcpucores", "maxmemorygb", "maxstoragegb", "maxgpucount":
headers[key] = index
}
}
return headers
}
func normalizeImportHeader(raw string) string {
key := strings.ToLower(strings.TrimSpace(raw))
replacer := strings.NewReplacer(" ", "", "_", "", "(", "", ")", "", "-", "")
return replacer.Replace(key)
}
func importFieldValue(fields []string, headerMap map[string]int, key string) string {
index, ok := headerMap[key]
if !ok || index >= len(fields) {
return ""
}
return strings.TrimSpace(fields[index])
}
func validateImportedUser(username, email, password, role string) string {
if len(username) < 3 || len(username) > 32 {
return "Username must be between 3 and 32 characters"
}
if !importUsernamePattern.MatchString(username) {
return "Username must be alphanumeric"
}
if email == "" || !strings.Contains(email, "@") {
return "Email must be a valid email"
}
if password != "" && len(password) < 8 {
return "Password must be at least 8 characters"
}
if role != "admin" && role != "user" {
return "Role must be admin or user"
}
return ""
}
func parseImportInt(fields []string, headerMap map[string]int, key string, required bool) (int, string) {
value := importFieldValue(fields, headerMap, key)
if value == "" {
if required {
return 0, fmt.Sprintf("%s is required", headerLabel(key))
}
return 0, ""
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 0 {
return 0, fmt.Sprintf("%s must be a non-negative integer", headerLabel(key))
}
return parsed, ""
}
func parseImportFloat(fields []string, headerMap map[string]int, key string, required bool) (float64, string) {
value := importFieldValue(fields, headerMap, key)
if value == "" {
if required {
return 0, fmt.Sprintf("%s is required", headerLabel(key))
}
return 0, ""
}
parsed, err := strconv.ParseFloat(value, 64)
if err != nil || parsed < 0 {
return 0, fmt.Sprintf("%s must be a non-negative number", headerLabel(key))
}
return parsed, ""
}
func headerLabel(key string) string {
switch key {
case "username":
return "Username"
case "role":
return "Role"
case "maxinstances":
return "Max Instances"
case "maxcpucores":
return "Max CPU Cores"
case "maxmemorygb":
return "Max Memory (GB)"
case "maxstoragegb":
return "Max Storage (GB)"
case "maxgpucount":
return "Max GPU Count"
default:
return key
}
}
func servicesDefaultPasswordForRole(role string) string {
return services.DefaultPasswordForRole(role)
}
// GetUser gets a user by ID
func (h *UserHandler) GetUser(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid user ID")
return
}
// Get current user ID from context
currentUserID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
// Only admin or the user themselves can view user details
if userRole != "admin" && currentUserID.(int) != id {
utils.Error(c, http.StatusForbidden, "Access denied")
return
}
user, err := h.userService.GetUserByID(id)
if err != nil {
utils.HandleError(c, err)
return
}
if user == nil {
utils.Error(c, http.StatusNotFound, "User not found")
return
}
utils.Success(c, http.StatusOK, "User retrieved successfully", user)
}
// UpdateUser updates a user
func (h *UserHandler) UpdateUser(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid user ID")
return
}
var req UpdateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
// Get current user ID from context
currentUserID, _ := c.Get("userID")
// Users can only update their own profile
if currentUserID.(int) != id {
utils.Error(c, http.StatusForbidden, "Can only update your own profile")
return
}
user := &models.User{
ID: id,
Email: req.Email,
IsActive: true,
}
if req.IsActive != nil {
user.IsActive = *req.IsActive
}
if err := h.userService.UpdateUser(user); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "User updated successfully", user)
}
// DeleteUser deletes a user (admin only)
func (h *UserHandler) DeleteUser(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid user ID")
return
}
// Prevent admin from deleting themselves
currentUserID, _ := c.Get("userID")
if currentUserID.(int) == id {
utils.Error(c, http.StatusBadRequest, "Cannot delete yourself")
return
}
if err := h.userService.DeleteUser(id); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "User deleted successfully", nil)
}
// UpdateRole updates a user's role (admin only)
func (h *UserHandler) UpdateRole(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid user ID")
return
}
var req UpdateRoleRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
// Prevent admin from changing their own role
currentUserID, _ := c.Get("userID")
if currentUserID.(int) == id {
utils.Error(c, http.StatusBadRequest, "Cannot change your own role")
return
}
if err := h.userService.UpdateUserRole(id, req.Role); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "User role updated successfully", nil)
}
// GetUserQuota gets a user's quota
func (h *UserHandler) GetUserQuota(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid user ID")
return
}
// Get current user ID from context
currentUserID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
// Only admin or the user themselves can view quota
if userRole != "admin" && currentUserID.(int) != id {
utils.Error(c, http.StatusForbidden, "Access denied")
return
}
quota, err := h.quotaService.GetUserQuota(id)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Quota retrieved successfully", quota)
}
// UpdateUserQuota updates a user's quota (admin only)
func (h *UserHandler) UpdateUserQuota(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid user ID")
return
}
var req UpdateQuotaRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
quota := &models.UserQuota{
MaxInstances: req.MaxInstances,
MaxCPUCores: req.MaxCPUCores,
MaxMemoryGB: req.MaxMemoryGB,
MaxStorageGB: req.MaxStorageGB,
MaxGPUCount: req.MaxGPUCount,
}
if err := h.quotaService.UpdateUserQuota(id, quota); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Quota updated successfully", quota)
}
@@ -0,0 +1,15 @@
package handlers
import "testing"
func TestValidateImportedUserAllowsEmptyPassword(t *testing.T) {
if err := validateImportedUser("alice", "alice@example.com", "", "user"); err != "" {
t.Fatalf("expected empty import password to be allowed, got %q", err)
}
}
func TestValidateImportedUserRejectsShortExplicitPassword(t *testing.T) {
if err := validateImportedUser("alice", "alice@example.com", "user123", "user"); err != "Password must be at least 8 characters" {
t.Fatalf("expected short explicit password error, got %q", err)
}
}
@@ -0,0 +1,51 @@
package handlers
import (
"net/http"
"clawreef/internal/services"
"github.com/gin-gonic/gin"
)
// WebSocketHandler handles WebSocket connections
type WebSocketHandler struct {
hub *services.Hub
}
// NewWebSocketHandler creates a new WebSocket handler
func NewWebSocketHandler(hub *services.Hub) *WebSocketHandler {
return &WebSocketHandler{
hub: hub,
}
}
// HandleWebSocket handles WebSocket upgrade requests
func (h *WebSocketHandler) HandleWebSocket(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
roleRaw, _ := c.Get("userRole")
role, _ := roleRaw.(string)
topic := services.WebSocketTopicUser
if c.Query("topic") == string(services.WebSocketTopicRuntimeAdmin) {
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
topic = services.WebSocketTopicRuntimeAdmin
}
// Upgrade HTTP connection to WebSocket
services.ServeWSWithOptions(h.hub, c.Writer, c.Request, userID.(int), role, topic)
}
// GetConnectionCount returns the number of active WebSocket connections
func (h *WebSocketHandler) GetConnectionCount(c *gin.Context) {
count := h.hub.GetClientCount()
c.JSON(http.StatusOK, gin.H{
"count": count,
})
}
@@ -0,0 +1,336 @@
package handlers
import (
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
"time"
"unicode"
"clawreef/internal/models"
"clawreef/internal/repository"
"clawreef/internal/services"
"clawreef/internal/utils"
"github.com/gin-gonic/gin"
)
type WorkspaceFileHandler struct {
instanceService services.InstanceService
fileService services.WorkspaceFileService
runtimeWorkspaceFileService services.WorkspaceFileService
skillRepo repository.SkillRepository
}
type createWorkspaceFolderRequest struct {
Path string `json:"path" binding:"required"`
}
type renameWorkspaceEntryRequest struct {
OldPath string `json:"old_path" binding:"required"`
NewPath string `json:"new_path" binding:"required"`
}
func NewWorkspaceFileHandler(instanceService services.InstanceService, fileService services.WorkspaceFileService, runtimeWorkspaceFileService ...services.WorkspaceFileService) *WorkspaceFileHandler {
runtimeFileService := fileService
if len(runtimeWorkspaceFileService) > 0 && runtimeWorkspaceFileService[0] != nil {
runtimeFileService = runtimeWorkspaceFileService[0]
}
return &WorkspaceFileHandler{
instanceService: instanceService,
fileService: fileService,
runtimeWorkspaceFileService: runtimeFileService,
}
}
func (h *WorkspaceFileHandler) SetSkillRepository(skillRepo repository.SkillRepository) {
h.skillRepo = skillRepo
}
func (h *WorkspaceFileHandler) List(c *gin.Context) {
_, service, scope, ok := h.workspaceScope(c)
if !ok {
return
}
entries, err := service.List(c.Request.Context(), scope, c.Query("path"))
if err != nil {
handleWorkspaceFileError(c, err)
return
}
utils.Success(c, http.StatusOK, "Workspace files retrieved successfully", gin.H{"entries": entries})
}
func (h *WorkspaceFileHandler) Preview(c *gin.Context) {
_, service, scope, ok := h.workspaceScope(c)
if !ok {
return
}
if strings.EqualFold(strings.TrimSpace(c.Query("raw")), "1") {
file, contentType, size, err := service.OpenPreview(c.Request.Context(), scope, c.Query("path"))
if err != nil {
handleWorkspaceFileError(c, err)
return
}
defer file.Close()
filename := safeWorkspaceDownloadName(filepath.Base(c.Query("path")))
streamWorkspaceFile(c, file, filename, contentType, "inline", size)
return
}
preview, err := service.Preview(c.Request.Context(), scope, c.Query("path"))
if err != nil {
handleWorkspaceFileError(c, err)
return
}
utils.Success(c, http.StatusOK, "Workspace preview retrieved successfully", gin.H{"preview": preview})
}
func (h *WorkspaceFileHandler) Download(c *gin.Context) {
_, service, scope, ok := h.workspaceScope(c)
if !ok {
return
}
file, filename, size, err := service.OpenDownload(c.Request.Context(), scope, c.Query("path"))
if err != nil {
handleWorkspaceFileError(c, err)
return
}
defer file.Close()
contentType := mime.TypeByExtension(strings.ToLower(filepath.Ext(filename)))
if contentType == "" {
contentType = "application/octet-stream"
}
streamWorkspaceFile(c, file, filename, contentType, "attachment", size)
}
func (h *WorkspaceFileHandler) Upload(c *gin.Context) {
_, service, scope, ok := h.workspaceScope(c)
if !ok {
return
}
maxBytes := services.WorkspaceUploadMaxBytes()
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes+(1<<20))
fileHeader, err := c.FormFile("file")
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
utils.Error(c, http.StatusRequestEntityTooLarge, "workspace upload exceeds maximum size")
return
}
utils.Error(c, http.StatusBadRequest, "file is required")
return
}
if fileHeader.Size > maxBytes {
utils.Error(c, http.StatusRequestEntityTooLarge, "workspace upload exceeds maximum size")
return
}
file, err := fileHeader.Open()
if err != nil {
handleWorkspaceFileError(c, err)
return
}
defer file.Close()
entry, err := service.Upload(c.Request.Context(), scope, c.Query("path"), fileHeader.Filename, file, fileHeader.Size)
if err != nil {
handleWorkspaceFileError(c, err)
return
}
utils.Success(c, http.StatusCreated, "Workspace file uploaded successfully", entry)
}
func (h *WorkspaceFileHandler) Mkdir(c *gin.Context) {
_, service, scope, ok := h.workspaceScope(c)
if !ok {
return
}
var req createWorkspaceFolderRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
entry, err := service.Mkdir(c.Request.Context(), scope, req.Path)
if err != nil {
handleWorkspaceFileError(c, err)
return
}
utils.Success(c, http.StatusCreated, "Workspace folder created successfully", entry)
}
func (h *WorkspaceFileHandler) Rename(c *gin.Context) {
_, service, scope, ok := h.workspaceScope(c)
if !ok {
return
}
var req renameWorkspaceEntryRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
entry, err := service.Rename(c.Request.Context(), scope, req.OldPath, req.NewPath)
if err != nil {
handleWorkspaceFileError(c, err)
return
}
utils.Success(c, http.StatusOK, "Workspace entry renamed successfully", entry)
}
func (h *WorkspaceFileHandler) Delete(c *gin.Context) {
instance, service, scope, ok := h.workspaceScope(c)
if !ok {
return
}
path := c.Query("path")
if err := service.Delete(c.Request.Context(), scope, path); err != nil {
handleWorkspaceFileError(c, err)
return
}
if err := h.syncSkillDeletionFromWorkspacePath(instance.ID, path); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Workspace entry deleted successfully", nil)
}
func (h *WorkspaceFileHandler) syncSkillDeletionFromWorkspacePath(instanceID int, path string) error {
if h.skillRepo == nil {
return nil
}
return h.skillRepo.MarkInstanceSkillsRemovedByWorkspacePath(instanceID, path, time.Now().UTC())
}
func (h *WorkspaceFileHandler) workspaceScope(c *gin.Context) (*models.Instance, services.WorkspaceFileService, services.WorkspaceFileScope, bool) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid instance ID")
return nil, nil, services.WorkspaceFileScope{}, false
}
instance, err := h.instanceService.GetByID(id)
if err != nil {
utils.HandleError(c, err)
return nil, nil, services.WorkspaceFileScope{}, false
}
if instance == nil {
utils.Error(c, http.StatusNotFound, "Instance not found")
return nil, nil, services.WorkspaceFileScope{}, false
}
userIDRaw, ok := c.Get("userID")
if !ok {
utils.Error(c, http.StatusUnauthorized, "Unauthorized")
return nil, nil, services.WorkspaceFileScope{}, false
}
userID, ok := userIDRaw.(int)
if !ok {
utils.Error(c, http.StatusUnauthorized, "Unauthorized")
return nil, nil, services.WorkspaceFileScope{}, false
}
roleRaw, _ := c.Get("userRole")
userRole, _ := roleRaw.(string)
if userRole != "admin" && instance.UserID != userID {
utils.Error(c, http.StatusForbidden, "Access denied")
return nil, nil, services.WorkspaceFileScope{}, false
}
if isDesktopWorkspaceInstance(instance) {
return instance, h.runtimeWorkspaceFileService, services.WorkspaceFileScope{
InstanceID: instance.ID,
UserID: instance.UserID,
WorkspacePath: "/config",
}, true
}
if instance.WorkspacePath == nil || strings.TrimSpace(*instance.WorkspacePath) == "" {
utils.Error(c, http.StatusNotFound, "Workspace not found")
return nil, nil, services.WorkspaceFileScope{}, false
}
return instance, h.fileService, services.WorkspaceFileScope{
InstanceID: instance.ID,
UserID: instance.UserID,
WorkspacePath: *instance.WorkspacePath,
}, true
}
func isDesktopWorkspaceInstance(instance *models.Instance) bool {
if instance == nil {
return false
}
return strings.EqualFold(strings.TrimSpace(instance.InstanceMode), services.InstanceModePro) ||
strings.EqualFold(strings.TrimSpace(instance.RuntimeType), services.RuntimeBackendDesktop)
}
func streamWorkspaceFile(c *gin.Context, file io.ReadSeeker, filename, contentType, disposition string, size int64) {
safeName := safeWorkspaceDownloadName(filename)
c.Header("Content-Type", contentType)
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Content-Disposition", workspaceContentDisposition(disposition, safeName))
if size >= 0 {
c.Header("Content-Length", strconv.FormatInt(size, 10))
}
if _, err := io.Copy(c.Writer, file); err != nil && !c.Writer.Written() {
utils.HandleError(c, err)
}
}
func workspaceContentDisposition(disposition, filename string) string {
disposition = strings.TrimSpace(strings.ToLower(disposition))
if disposition != "inline" {
disposition = "attachment"
}
escaped := strings.ReplaceAll(filename, `\`, `\\`)
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
return fmt.Sprintf("%s; filename=\"%s\"; filename*=UTF-8''%s", disposition, escaped, url.PathEscape(filename))
}
func safeWorkspaceDownloadName(name string) string {
name = strings.TrimSpace(strings.ReplaceAll(name, "\\", "/"))
name = filepath.Base(name)
if name == "." || name == "/" || name == "" {
name = "download"
}
name = strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
switch r {
case '/', '\\', ':', '*', '?', '"', '<', '>', '|':
return '-'
default:
return r
}
}, name)
name = strings.TrimSpace(name)
if name == "" || name == "." || name == ".." {
return "download"
}
return name
}
func handleWorkspaceFileError(c *gin.Context, err error) {
switch {
case errors.Is(err, services.ErrWorkspacePathNotFound):
utils.Error(c, http.StatusNotFound, err.Error())
case errors.Is(err, services.ErrWorkspacePreviewTooLarge), errors.Is(err, services.ErrWorkspaceUploadTooLarge):
utils.Error(c, http.StatusRequestEntityTooLarge, err.Error())
case errors.Is(err, services.ErrWorkspacePathEscape):
utils.Error(c, http.StatusForbidden, "Access denied")
case errors.Is(err, services.ErrWorkspacePathInvalid),
errors.Is(err, services.ErrWorkspaceDirectoryExpected),
errors.Is(err, services.ErrWorkspaceFileExpected),
errors.Is(err, services.ErrWorkspaceRootOperation),
errors.Is(err, services.ErrWorkspaceFileNameInvalid),
errors.Is(err, services.ErrWorkspaceEntryExists):
utils.Error(c, http.StatusBadRequest, err.Error())
default:
utils.HandleError(c, err)
}
}
@@ -0,0 +1,242 @@
package handlers
import (
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"clawreef/internal/models"
"clawreef/internal/services"
"github.com/gin-gonic/gin"
)
type fakeWorkspaceHandlerInstanceService struct {
instances map[int]*models.Instance
}
func (s *fakeWorkspaceHandlerInstanceService) Create(userID int, req services.CreateInstanceRequest) (*models.Instance, error) {
return nil, nil
}
func (s *fakeWorkspaceHandlerInstanceService) ValidateCreateRequests(userID int, requests []services.CreateInstanceRequest) error {
return nil
}
func (s *fakeWorkspaceHandlerInstanceService) GetByID(id int) (*models.Instance, error) {
return s.instances[id], nil
}
func (s *fakeWorkspaceHandlerInstanceService) GetByUserID(userID int, offset, limit int) ([]models.Instance, int, error) {
return nil, 0, nil
}
func (s *fakeWorkspaceHandlerInstanceService) GetAllInstances(offset, limit int) ([]models.Instance, int, error) {
return nil, 0, nil
}
func (s *fakeWorkspaceHandlerInstanceService) Start(instanceID int) error { return nil }
func (s *fakeWorkspaceHandlerInstanceService) Stop(instanceID int) error { return nil }
func (s *fakeWorkspaceHandlerInstanceService) Restart(instanceID int) error { return nil }
func (s *fakeWorkspaceHandlerInstanceService) Delete(instanceID int) error { return nil }
func (s *fakeWorkspaceHandlerInstanceService) Update(instanceID int, req services.UpdateInstanceRequest) error {
return nil
}
func (s *fakeWorkspaceHandlerInstanceService) GetInstanceStatus(instanceID int) (*services.InstanceStatus, error) {
return nil, nil
}
func (s *fakeWorkspaceHandlerInstanceService) ForceSyncInstance(instanceID int) error { return nil }
type fakeWorkspaceFileService struct {
lastScope services.WorkspaceFileScope
listCalls int
file *os.File
filename string
size int64
}
func (s *fakeWorkspaceFileService) List(ctx context.Context, scope services.WorkspaceFileScope, relativePath string) ([]services.WorkspaceEntry, error) {
s.lastScope = scope
s.listCalls++
return []services.WorkspaceEntry{{Name: "readme.md", Path: "readme.md", ModifiedAt: time.Unix(1, 0), Previewable: true, Downloadable: true}}, nil
}
func (s *fakeWorkspaceFileService) Preview(ctx context.Context, scope services.WorkspaceFileScope, relativePath string) (*services.WorkspacePreview, error) {
s.lastScope = scope
return &services.WorkspacePreview{Kind: "text", Text: "ok"}, nil
}
func (s *fakeWorkspaceFileService) OpenPreview(ctx context.Context, scope services.WorkspaceFileScope, relativePath string) (*os.File, string, int64, error) {
s.lastScope = scope
return s.file, "text/plain; charset=utf-8", s.size, nil
}
func (s *fakeWorkspaceFileService) OpenDownload(ctx context.Context, scope services.WorkspaceFileScope, relativePath string) (*os.File, string, int64, error) {
s.lastScope = scope
return s.file, s.filename, s.size, nil
}
func (s *fakeWorkspaceFileService) Upload(ctx context.Context, scope services.WorkspaceFileScope, relativeDir string, filename string, reader io.Reader, size int64) (*services.WorkspaceEntry, error) {
s.lastScope = scope
return &services.WorkspaceEntry{Name: filename, Path: filename, Downloadable: true}, nil
}
func (s *fakeWorkspaceFileService) Mkdir(ctx context.Context, scope services.WorkspaceFileScope, relativePath string) (*services.WorkspaceEntry, error) {
s.lastScope = scope
return &services.WorkspaceEntry{Name: "docs", Path: relativePath, IsDir: true}, nil
}
func (s *fakeWorkspaceFileService) Rename(ctx context.Context, scope services.WorkspaceFileScope, oldPath, newPath string) (*services.WorkspaceEntry, error) {
s.lastScope = scope
return &services.WorkspaceEntry{Name: "renamed", Path: newPath}, nil
}
func (s *fakeWorkspaceFileService) Delete(ctx context.Context, scope services.WorkspaceFileScope, relativePath string) error {
s.lastScope = scope
return nil
}
func TestWorkspaceFileHandlerRejectsNonOwnerUser(t *testing.T) {
gin.SetMode(gin.TestMode)
workspacePath := t.TempDir()
instanceService := &fakeWorkspaceHandlerInstanceService{instances: map[int]*models.Instance{
77: {ID: 77, UserID: 20, WorkspacePath: &workspacePath},
}}
fileService := &fakeWorkspaceFileService{}
handler := NewWorkspaceFileHandler(instanceService, fileService)
router := workspaceFileTestRouter(10, "user")
router.GET("/api/v1/instances/:id/workspace/files", handler.List)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/instances/77/workspace/files", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, body = %s, want 403", rec.Code, rec.Body.String())
}
if fileService.listCalls != 0 {
t.Fatalf("workspace file service called %d times, want 0", fileService.listCalls)
}
}
func TestWorkspaceFileHandlerAdminUsesInstanceOwnerScope(t *testing.T) {
gin.SetMode(gin.TestMode)
workspacePath := t.TempDir()
instanceService := &fakeWorkspaceHandlerInstanceService{instances: map[int]*models.Instance{
77: {ID: 77, UserID: 20, WorkspacePath: &workspacePath},
}}
fileService := &fakeWorkspaceFileService{}
handler := NewWorkspaceFileHandler(instanceService, fileService)
router := workspaceFileTestRouter(10, "admin")
router.GET("/api/v1/instances/:id/workspace/files", handler.List)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/instances/77/workspace/files?path=", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s, want 200", rec.Code, rec.Body.String())
}
if fileService.lastScope.InstanceID != 77 || fileService.lastScope.UserID != 20 || fileService.lastScope.WorkspacePath != workspacePath {
t.Fatalf("scope = %#v, want instance owner scope", fileService.lastScope)
}
}
func TestWorkspaceFileHandlerRequiresWorkspacePath(t *testing.T) {
gin.SetMode(gin.TestMode)
instanceService := &fakeWorkspaceHandlerInstanceService{instances: map[int]*models.Instance{
77: {ID: 77, UserID: 10},
}}
fileService := &fakeWorkspaceFileService{}
handler := NewWorkspaceFileHandler(instanceService, fileService)
router := workspaceFileTestRouter(10, "user")
router.GET("/api/v1/instances/:id/workspace/files", handler.List)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/instances/77/workspace/files", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, body = %s, want 404", rec.Code, rec.Body.String())
}
if fileService.listCalls != 0 {
t.Fatalf("workspace file service called %d times, want 0", fileService.listCalls)
}
}
func TestWorkspaceFileHandlerProDesktopUsesRuntimeConfigWorkspace(t *testing.T) {
gin.SetMode(gin.TestMode)
instanceService := &fakeWorkspaceHandlerInstanceService{instances: map[int]*models.Instance{
77: {
ID: 77,
UserID: 10,
RuntimeType: services.RuntimeBackendDesktop,
InstanceMode: services.InstanceModePro,
},
}}
localFileService := &fakeWorkspaceFileService{}
runtimeFileService := &fakeWorkspaceFileService{}
handler := NewWorkspaceFileHandler(instanceService, localFileService, runtimeFileService)
router := workspaceFileTestRouter(10, "user")
router.GET("/api/v1/instances/:id/workspace/files", handler.List)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/instances/77/workspace/files?path=", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s, want 200", rec.Code, rec.Body.String())
}
if localFileService.listCalls != 0 {
t.Fatalf("local workspace service called %d times, want 0", localFileService.listCalls)
}
if runtimeFileService.listCalls != 1 {
t.Fatalf("runtime workspace service called %d times, want 1", runtimeFileService.listCalls)
}
if runtimeFileService.lastScope.WorkspacePath != "/config" {
t.Fatalf("runtime workspace path = %q, want /config", runtimeFileService.lastScope.WorkspacePath)
}
}
func TestWorkspaceFileHandlerDownloadUsesSafeContentDisposition(t *testing.T) {
gin.SetMode(gin.TestMode)
workspacePath := t.TempDir()
downloadFile, err := os.CreateTemp(t.TempDir(), "download-*")
if err != nil {
t.Fatal(err)
}
if _, err := downloadFile.WriteString("body"); err != nil {
t.Fatal(err)
}
if _, err := downloadFile.Seek(0, io.SeekStart); err != nil {
t.Fatal(err)
}
instanceService := &fakeWorkspaceHandlerInstanceService{instances: map[int]*models.Instance{
77: {ID: 77, UserID: 10, WorkspacePath: &workspacePath},
}}
fileService := &fakeWorkspaceFileService{file: downloadFile, filename: `bad"name/ignored.txt`, size: int64(len("body"))}
handler := NewWorkspaceFileHandler(instanceService, fileService)
router := workspaceFileTestRouter(10, "user")
router.GET("/api/v1/instances/:id/workspace/download", handler.Download)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/instances/77/workspace/download?path=report.txt", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s, want 200", rec.Code, rec.Body.String())
}
disposition := rec.Header().Get("Content-Disposition")
if !strings.Contains(disposition, "attachment;") || strings.Contains(disposition, `"name/`) || strings.Contains(disposition, "\r") || strings.Contains(disposition, "\n") {
t.Fatalf("Content-Disposition = %q, want safe attachment filename", disposition)
}
}
func workspaceFileTestRouter(userID int, role string) *gin.Engine {
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("userID", userID)
c.Set("userRole", role)
c.Next()
})
return router
}
@@ -0,0 +1,166 @@
package middleware
import (
"errors"
"net/http"
"os"
"strings"
"clawreef/internal/repository"
"clawreef/internal/utils"
"github.com/gin-gonic/gin"
)
// Auth middleware validates JWT token
func Auth() gin.HandlerFunc {
return func(c *gin.Context) {
tokenString, ok := extractToken(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": "Authorization header required",
})
c.Abort()
return
}
claims, err := validateUserAccessToken(tokenString)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": "Invalid or expired token",
})
c.Abort()
return
}
// Check token type
if claims.TokenType != "access" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": "Invalid token type",
})
c.Abort()
return
}
// Set user ID in context
c.Set("userID", claims.UserID)
c.Next()
}
}
// GatewayAuth accepts either a normal user access JWT or an instance lifecycle gateway token.
func GatewayAuth(instanceRepo repository.InstanceRepository, bindingRepos ...repository.InstanceRuntimeBindingRepository) gin.HandlerFunc {
var bindingRepo repository.InstanceRuntimeBindingRepository
if len(bindingRepos) > 0 {
bindingRepo = bindingRepos[0]
}
return func(c *gin.Context) {
tokenString, ok := extractToken(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": "Authorization header required",
})
c.Abort()
return
}
if claims, err := validateUserAccessToken(tokenString); err == nil {
c.Set("userID", claims.UserID)
c.Set("gatewayAuthType", "user")
c.Next()
return
}
instance, err := instanceRepo.GetByAccessToken(tokenString)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": "Invalid gateway token",
})
c.Abort()
return
}
if instance == nil || instance.AccessToken == nil || strings.TrimSpace(*instance.AccessToken) == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": "Invalid gateway token",
})
c.Abort()
return
}
c.Set("userID", instance.UserID)
c.Set("instanceID", instance.ID)
c.Set("instanceMode", gatewayInstanceMode(instance.InstanceMode, instance.RuntimeType))
c.Set("runtimeType", strings.TrimSpace(instance.RuntimeType))
if bindingRepo != nil {
if binding, err := bindingRepo.GetRunningByInstanceID(c.Request.Context(), instance.ID); err == nil && binding != nil {
c.Set("gatewayID", strings.TrimSpace(binding.GatewayID))
c.Set("runtimePodID", binding.RuntimePodID)
}
}
c.Set("gatewayAuthType", "instance")
c.Next()
}
}
func gatewayInstanceMode(instanceMode, runtimeType string) string {
mode := strings.ToLower(strings.TrimSpace(instanceMode))
if mode == "lite" || mode == "pro" {
return mode
}
switch strings.ToLower(strings.TrimSpace(runtimeType)) {
case "gateway":
return "lite"
case "desktop", "shell":
return "pro"
default:
return mode
}
}
func extractToken(c *gin.Context) (string, bool) {
authHeader := c.GetHeader("Authorization")
if authHeader != "" {
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
return "", false
}
return parts[1], true
}
// Browsers cannot set custom Authorization headers for native WebSocket
// handshakes, so allow `?token=` specifically for upgrade requests.
if strings.EqualFold(c.GetHeader("Upgrade"), "websocket") {
token := strings.TrimSpace(c.Query("token"))
if token != "" {
return token, true
}
}
return "", false
}
func getJWTSecret() string {
// Get from environment variable, fallback to default
// Must match the secret used in config.go
if secret := os.Getenv("JWT_SECRET"); secret != "" {
return secret
}
return "clawreef-dev-secret-key-change-in-production"
}
func validateUserAccessToken(tokenString string) (*utils.TokenClaims, error) {
jwtSecret := getJWTSecret()
claims, err := utils.ValidateToken(tokenString, jwtSecret)
if err != nil {
return nil, err
}
if claims.TokenType != "access" {
return nil, errors.New("invalid token type")
}
return claims, nil
}

Some files were not shown because too many files have changed in this diff Show More