feat(security): credentialed exec + HTTP RBAC + API key cache (#197)

- Secure CLI credential injection via AES-256-GCM encrypted env vars
- API key management with fine-grained RBAC scopes
- resolveAuth/requireAuth middleware across all 25+ HTTP handlers
- In-memory API key cache with TTL, negative caching, pubsub invalidation
- Sandbox-first execution (fails if unavailable, no silent fallback)
- Credential scrubbing, constant-time token comparison, Admin-only CLI creds
- SQL migration 000020: secure_cli_binaries + api_keys tables
- 14 unit tests for cache and RBAC with race detector

Closes #197
This commit is contained in:
Goon authored and GitHub committed 2026-03-15 20:13:18 +07:00
1 parent 23eb42accb
commit 75c570e951
94 files changed
+6332 -286

No files matched your search

+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
+14 -6
View File
@@ -342,15 +342,16 @@ Quality gates validate agent output before it reaches users. Configured in agent
### Security
- **Rate limiting** — Token bucket per user/IP, configurable RPM
- **API key management** — Multi-key auth with RBAC scopes (`admin`, `read`, `write`, `approvals`, `pairing`), SHA-256 hashed storage, optional expiry, revocation
- **Prompt injection detection** — 6-pattern regex scanner (detection-only, never blocks)
- **Credential scrubbing** — Auto-redact API keys, tokens, passwords from tool outputs
- **Shell deny patterns** — Blocks `curl|sh`, reverse shells, `eval $()`, `base64|sh`
- **SSRF protection** — DNS pinning, blocked private IPs, blocked hosts
- **AES-256-GCM** — Encrypted API keys in database
- **AES-256-GCM** — Encrypted provider API keys in database
- **Browser pairing** — Token-free browser auth with admin-approved pairing codes
### Web Dashboard
- Agent management, traces & spans viewer, skills, teams, MCP servers, pairing approval, memory management (CRUD + search + chunking), knowledge graph (table + force-directed visualization), and pending messages dashboard
- Agent management, traces & spans viewer, skills, teams, MCP servers, pairing approval, memory management (CRUD + search + chunking), knowledge graph (table + force-directed visualization), pending messages dashboard, API key management, and interactive API documentation (Swagger UI)
## Quick Start
@@ -695,9 +696,14 @@ goclaw pairing revoke Revoke a pairing
## API
See [API Reference](api-reference.md) for HTTP endpoints, Custom Tools, and MCP Integration.
Interactive API documentation is available at `/docs` (Swagger UI) when the gateway is running. The OpenAPI 3.0 spec is served at `/v1/openapi.json`.
See [WebSocket Protocol](websocket-protocol.md) for the real-time RPC protocol (v3).
| Documentation | Description |
|---------------|-------------|
| [HTTP REST API](docs/18-http-api.md) | 130+ HTTP endpoints — chat completions, agents, skills, providers, MCP, memory, knowledge graph, channels, traces, usage, storage, API keys |
| [WebSocket RPC](docs/19-websocket-rpc.md) | 64+ RPC methods — chat, agents, config, sessions, cron, teams, pairing, delegations, approvals |
| [API Keys & Auth](docs/20-api-keys-auth.md) | Authentication model, RBAC scopes, API key management, security design |
| [Gateway Protocol](docs/04-gateway-protocol.md) | WebSocket wire protocol (v3), frame format, connection lifecycle |
## Docker Compose
@@ -894,12 +900,13 @@ Requires `GOCLAW_TSNET_AUTH_KEY` in your `.env` file. Tailscale state is persist
## Security
- **Transport**: WebSocket CORS validation, 512KB message limit, 1MB HTTP body limit, timing-safe token auth
- **API key management**: Multi-key auth with 5 RBAC scopes, SHA-256 hashed storage, optional expiry, revocation, show-once pattern. See [API Keys & Auth](docs/20-api-keys-auth.md)
- **Rate limiting**: Token bucket per user/IP, configurable RPM
- **Prompt injection**: Input guard with 6 pattern detection (detection-only, never blocks)
- **Shell security**: Deny patterns for `curl|sh`, `wget|sh`, reverse shells, `eval`, `base64|sh`
- **Network**: SSRF protection with blocked hosts + private IP + DNS pinning
- **File system**: Path traversal prevention, workspace restriction
- **Encryption**: AES-256-GCM for API keys in database
- **Encryption**: AES-256-GCM for provider API keys in database
- **Browser pairing**: Token-free browser auth with admin approval (pairing codes, auto-reconnect)
- **Tailscale**: Optional VPN mesh listener for secure remote access (build-tag gated)
@@ -943,7 +950,8 @@ GOCLAW_OPENROUTER_API_KEY=sk-or-xxx go test -v ./tests/integration/ -timeout 120
- **Cron scheduling** — `at`, `every`, and cron expression scheduling. Tested in production.
- **Docker sandbox** — Isolated code execution in containers. Tested in production.
- **Text-to-Speech** — OpenAI, ElevenLabs, Edge, MiniMax providers. Tested in production.
- **HTTP API** — `/v1/chat/completions`, `/v1/agents`, `/v1/skills`, etc. Tested in production.
- **HTTP API** — `/v1/chat/completions`, `/v1/agents`, `/v1/skills`, etc. Tested in production. Interactive Swagger UI at `/docs`.
- **API key management** — Multi-key auth with RBAC scopes, SHA-256 hashed storage, show-once pattern, optional expiry, revocation. HTTP + WebSocket CRUD. Web UI for management.
- **Hooks system** — Event-driven hooks with command evaluators (shell exit code) and agent evaluators (delegate to reviewer). Blocking gates with auto-retry and recursion-safe evaluation.
- **Media tools** — `create_image` (DashScope, MiniMax), `create_audio` (OpenAI, ElevenLabs, MiniMax, Suno), `create_video` (MiniMax, Veo), `read_document` (Gemini File API), `read_image`, `read_audio`, `read_video`. Persistent media storage with lazy-loaded MediaRef.
- **Additional provider modes** — Claude CLI (Anthropic via stdio + MCP bridge), Codex (OpenAI gpt-5.3-codex via OAuth).
+20 -1
View File
@@ -720,7 +720,7 @@ func runGateway() {
if mcpMgr != nil {
mcpToolLister = mcpMgr
}
agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH, delegationsH, builtinToolsH, pendingMessagesH, teamEventsH := wireHTTP(pgStores, cfg.Gateway.Token, msgBus, toolsReg, providerRegistry, permPE.IsOwner, gatewayAddr, mcpToolLister)
agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH, delegationsH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH := wireHTTP(pgStores, cfg.Gateway.Token, msgBus, toolsReg, providerRegistry, permPE.IsOwner, gatewayAddr, mcpToolLister)
if agentsH != nil {
server.SetAgentsHandler(agentsH)
}
@@ -763,6 +763,10 @@ func runGateway() {
server.SetPendingMessagesHandler(pendingMessagesH)
}
if secureCLIH != nil {
server.SetSecureCLIHandler(secureCLIH)
}
// Activity audit log API
if pgStores.Activity != nil {
server.SetActivityHandler(httpapi.NewActivityHandler(pgStores.Activity, cfg.Gateway.Token))
@@ -773,6 +777,16 @@ func runGateway() {
server.SetUsageHandler(httpapi.NewUsageHandler(pgStores.Snapshots, pgStores.DB, cfg.Gateway.Token))
}
// API key management
// API documentation (OpenAPI spec + Swagger UI at /docs)
server.SetDocsHandler(httpapi.NewDocsHandler(cfg.Gateway.Token))
if pgStores != nil && pgStores.APIKeys != nil {
server.SetAPIKeysHandler(httpapi.NewAPIKeysHandler(pgStores.APIKeys, cfg.Gateway.Token, msgBus))
server.SetAPIKeyStore(pgStores.APIKeys)
httpapi.InitAPIKeyCache(pgStores.APIKeys, msgBus)
}
// Memory management API (wired directly, only needs MemoryStore + token)
if pgStores != nil && pgStores.Memory != nil {
server.SetMemoryHandler(httpapi.NewMemoryHandler(pgStores.Memory, cfg.Gateway.Token))
@@ -1040,6 +1054,11 @@ func runGateway() {
// Pass DB so summary cards still work when quota is disabled (queries traces directly).
methods.NewQuotaMethods(quotaChecker, pgStores.DB).Register(server.Router())
// API key management RPC
if pgStores.APIKeys != nil {
methods.NewAPIKeysMethods(pgStores.APIKeys).Register(server.Router())
}
// Reload quota config on config changes via pub/sub.
if quotaChecker != nil {
msgBus.Subscribe("quota-config-reload", func(evt bus.Event) {
+7 -2
View File
@@ -10,7 +10,7 @@ import (
)
// wireHTTP creates HTTP handlers (agents + skills + traces + MCP + custom tools + channel instances + providers + delegations + builtin tools + pending messages).
func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.CustomToolsHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.DelegationsHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler, *httpapi.TeamEventsHandler) {
func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.CustomToolsHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.DelegationsHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler, *httpapi.TeamEventsHandler, *httpapi.SecureCLIHandler) {
var agentsH *httpapi.AgentsHandler
var skillsH *httpapi.SkillsHandler
var tracesH *httpapi.TracesHandler
@@ -21,6 +21,7 @@ func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsR
var delegationsH *httpapi.DelegationsHandler
var builtinToolsH *httpapi.BuiltinToolsHandler
var pendingMessagesH *httpapi.PendingMessagesHandler
var secureCLIH *httpapi.SecureCLIHandler
if stores != nil && stores.Agents != nil {
var summoner *httpapi.AgentSummoner
@@ -78,5 +79,9 @@ func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsR
pendingMessagesH = httpapi.NewPendingMessagesHandler(stores.PendingMessages, stores.Agents, token, providerReg)
}
return agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH, delegationsH, builtinToolsH, pendingMessagesH, teamEventsH
if stores != nil && stores.SecureCLI != nil {
secureCLIH = httpapi.NewSecureCLIHandler(stores.SecureCLI, token, msgBus)
}
return agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH, delegationsH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH
}
+10
View File
@@ -84,6 +84,15 @@ func wireExtras(
slog.Info("media tools registered", "tools", "read_document,read_audio,read_video,create_video")
}
// 1e. Wire secure CLI store into exec tool for credentialed exec
if stores.SecureCLI != nil {
if execTool, ok := toolsReg.Get("exec"); ok {
if et, ok := execTool.(*tools.ExecTool); ok {
et.SetSecureCLIStore(stores.SecureCLI)
}
}
}
// 2. User seeding callback: seeds per-user context files on first chat
var ensureUserFiles agent.EnsureUserFilesFunc
if stores.Agents != nil {
@@ -145,6 +154,7 @@ func wireExtras(
DynamicLoader: dynamicLoader,
AgentLinkStore: stores.AgentLinks,
TeamStore: stores.Teams,
SecureCLIStore: stores.SecureCLI,
BuiltinToolStore: stores.BuiltinTools,
MCPStore: stores.MCP,
MCPPool: mcpPool,
+33
View File
@@ -210,6 +210,39 @@ Filesystem and shell tools read their workspace from `ToolWorkspaceFromCtx(ctx)`
The `exec` tool allows the LLM to run shell commands, with multiple defense layers.
### Credentialed CLI Tools
**Direct Exec Mode** allows secure credential injection for CLI tools without exposing credentials via shell. Credentials are auto-injected as environment variables directly into the child process (no shell involved).
**How it works:**
1. **Credential lookup** — Administrator configures binary → encrypted env vars in `secure_cli_binaries` table
2. **Shell operator detection** — Blocks unsafe command chaining (`;`, `|`, `&&`, `||`, `>`, `<`, `$()`, backticks)
3. **Path verification** — Binary is resolved to absolute path and matched against configured path
4. **Per-binary deny check** — Optional regex patterns block specific arguments (e.g., `auth\s+`, `ssh-key`)
5. **Direct exec** — Command runs as `exec.CommandContext(binary, args...)` with credentials in env
**Available presets:** `gh`, `gcloud`, `aws`, `kubectl`, `terraform`
**Security layers:**
- **No shell** — Direct exec prevents shell command injection
- **Path verification** — Binary spoofing (e.g., `./gh` in workspace) is blocked
- **Per-binary deny** — Admins can block sensitive operations per CLI
- **Output scrubbing** — Credential values registered for automatic redaction
**Configuration (JSON in `secure_cli_binaries` table):**
```json
{
"binary_name": "gh",
"encrypted_env": {"GH_TOKEN": "ghp_..."},
"deny_args": ["auth\\s+", "ssh-key"],
"deny_verbose": ["--verbose", "-v"],
"timeout_seconds": 30,
"tips": "GitHub CLI. Available: gh api, gh repo, gh issue, etc."
}
```
---
### Deny Patterns
| Category | Blocked Patterns |
+22
View File
@@ -87,6 +87,28 @@ type PathDenyable interface {
All four filesystem tools (`read_file`, `write_file`, `list_files`, `edit`) implement `PathDenyable`. The agent loop calls `DenyPaths(".goclaw")` at startup to prevent agents from accessing internal data directories. `list_files` additionally filters denied directories from output entirely -- the agent does not see denied paths in directory listings.
#### Credentialed Exec Security
**Direct Exec Mode** for credentialed CLI tools implements defense-in-depth with 4 independent layers:
| Layer | Mechanism | Protects Against |
|-------|-----------|------------------|
| **No shell** | `exec.CommandContext(binary, args...)` (never `sh -c`) | Shell command injection, credential leakage via env var expansion |
| **Path verify** | `exec.LookPath()` + config match check | Binary spoofing (e.g., `./gh` in workspace) |
| **Deny patterns** | Per-binary regex deny lists on arguments + verbose flags | Sensitive operations per CLI (e.g., `auth`, `ssh-key`) |
| **Output scrub** | Credential values registered for dynamic scrubbing | Credentials in stdout/stderr |
**Edge case mitigations** (13 scenarios analyzed):
- Shell operators in command string → Blocked by early regex scan
- Argument injection via spaces → Protected by shell-word parsing (not shell evaluation)
- Binary PATH manipulation → Absolute path required + config match
- Symlink attacks → Verified by `exec.LookPath()` + config match
- Env var exfiltration → Command runs without shell, env vars never expand
- Output parsing tricks → Dynamic scrubbing catches all registered credential values
- Timeout abuse → Configurable per-binary timeout with context deadline
- Sandbox escape → Docker container isolation if sandbox enabled
- Verbose flag leakage → Separate deny_verbose list blocks verbose/debug output
### Layer 4: Output Security
| Mechanism | Detail |
+48
View File
@@ -8,6 +8,54 @@ All notable changes to GoClaw Gateway are documented here. Format follows [Keep
### Added
#### Credentialed Exec — Secure CLI Credential Injection
- **New feature**: Direct Exec Mode for CLI tools with auto-injected credentials (GitHub, Google Cloud, AWS, Kubernetes, Terraform)
- **Security model**: No shell involved — credentials injected directly into process env; 4-layer defense (no shell, path verify, deny patterns, output scrub)
- **Presets**: 5 built-in binary configurations (gh, gcloud, aws, kubectl, terraform)
- **Database**: Migration 000019 adds `secure_cli_binaries` table for credential storage (encrypted with AES-256-GCM)
- **Tool integration**: ExecTool routes credentialed binaries to `executeCredentialed()` path, bypassing shell
- **HTTP API endpoints**:
- `GET /v1/cli-credentials` — List all credentials
- `POST /v1/cli-credentials` — Create credential
- `GET /v1/cli-credentials/{id}` — Retrieve credential
- `PUT /v1/cli-credentials/{id}` — Update credential
- `DELETE /v1/cli-credentials/{id}` — Delete credential
- `GET /v1/cli-credentials/presets` — Get preset templates
- `POST /v1/cli-credentials/{id}/test` — Dry run with test command
- **Web UI**: Credential manager with preset selector, environment variable editor, dry run tester
- **Files added**:
- `internal/tools/credentialed_exec.go` — Direct exec, shell operator detection, path verification
- `internal/tools/credential_context.go` — Context injection helpers
- `internal/store/secure_cli_store.go` — Store interface
- `internal/store/pg/secure_cli.go` — PostgreSQL implementation
- `internal/http/secure_cli.go` — HTTP endpoints
- `migrations/000019_secure_cli_binaries.up.sql` — Database schema
#### API Key Management
- **Multi-key auth**: Multiple API keys with `goclaw_` prefix, SHA-256 hashed storage, show-once pattern
- **RBAC scopes**: `operator.admin`, `operator.read`, `operator.write`, `operator.approvals`, `operator.pairing`
- **HTTP + WS**: Full CRUD via `/v1/api-keys` and `api_keys.*` RPC methods
- **Web UI**: Create dialog with scope checkboxes, expiry options, revoke confirmation
- **Migration**: `000020_api_keys` — `api_keys` table with partial index on active key hashes
- **Backward compatible**: Existing gateway token continues to work as admin
#### Interactive API Documentation
- **Swagger UI** at `/docs` with embedded OpenAPI 3.0 spec at `/v1/openapi.json`
- **Coverage**: 130+ HTTP endpoints across 18 tag groups
- **Sidebar link**: API Docs entry in System group (opens in new tab)
### Documentation
- Added `18-http-api.md` — Complete HTTP REST API reference (all endpoints, auth, error codes)
- Added `19-websocket-rpc.md` — Complete WebSocket RPC method catalog (64+ methods, permission matrix)
- Added `20-api-keys-auth.md` — API key authentication, RBAC scopes, security model, usage examples
---
## [ACP Provider Release]
### Added
#### ACP Provider (Agent Client Protocol)
- **New provider**: ACP provider enables orchestration of external coding agents (Claude Code, Codex CLI, Gemini CLI) as JSON-RPC 2.0 subprocesses over stdio
- **ProcessPool**: Manages subprocess lifecycle with idle TTL reaping and automatic crash recovery
+584
View File
@@ -0,0 +1,584 @@
# 18 — HTTP REST API
GoClaw exposes a comprehensive HTTP REST API alongside the WebSocket RPC protocol. All endpoints are served from the same gateway server and share authentication, rate limiting, and i18n infrastructure.
Interactive documentation is available at `/docs` (Swagger UI) and the raw OpenAPI 3.0 spec at `/v1/openapi.json`.
---
## 1. Authentication
All HTTP endpoints (except `/health`) require authentication via Bearer token in the `Authorization` header:
```
Authorization: Bearer <token>
```
Two token types are accepted:
| Type | Format | Scope |
|------|--------|-------|
| Gateway token | Configured in `config.json` | Full admin access |
| API key | `goclaw_` + 32 hex chars | Scoped by key permissions |
API keys are hashed with SHA-256 before lookup — the raw key is never stored. See [20 — API Keys & Auth](20-api-keys-auth.md) for details.
> Some endpoints accept the token as a query parameter `?token=<token>` for use in `<img>` and `<audio>` tags (e.g., `/v1/files/`, `/v1/media/`).
### Common Headers
| Header | Purpose |
|--------|---------|
| `Authorization` | Bearer token for authentication |
| `X-GoClaw-User-Id` | External user ID for multi-tenant context |
| `X-GoClaw-Agent-Id` | Agent identifier for scoped operations |
| `Accept-Language` | Locale (`en`, `vi`, `zh`) for i18n error messages |
| `Content-Type` | `application/json` for request bodies |
---
## 2. Chat Completions
OpenAI-compatible chat API for programmatic access to agents.
### `POST /v1/chat/completions`
```json
{
"model": "goclaw:agent-id-or-key",
"messages": [
{"role": "user", "content": "Hello"}
],
"stream": false,
"user": "optional-user-id"
}
```
**Response** (non-streaming):
```json
{
"id": "chatcmpl-...",
"object": "chat.completion",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "..."},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30}
}
```
**Streaming:** Set `"stream": true` to receive Server-Sent Events (SSE) with `data: {...}` chunks, terminated by `data: [DONE]`.
**Rate limiting:** Per-IP when `rate_limit_rpm` is configured.
---
## 3. OpenResponses Protocol
### `POST /v1/responses`
Alternative response-based protocol (compatible with OpenAI Responses API). Accepts the same auth and returns structured response objects.
---
## 4. Agents
CRUD operations for agent management. Requires `X-GoClaw-User-Id` header for multi-tenant context.
| Method | Path | Description | Auth |
|--------|------|-------------|------|
| `GET` | `/v1/agents` | List agents accessible by user | Bearer |
| `POST` | `/v1/agents` | Create new agent | Bearer |
| `GET` | `/v1/agents/{id}` | Get agent by ID or key | Bearer |
| `PUT` | `/v1/agents/{id}` | Update agent (owner only) | Bearer |
| `DELETE` | `/v1/agents/{id}` | Delete agent (owner only) | Bearer |
### Shares
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/agents/{id}/shares` | List agent shares |
| `POST` | `/v1/agents/{id}/shares` | Share agent with user |
| `DELETE` | `/v1/agents/{id}/shares/{userID}` | Revoke share |
### Agent Actions
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/v1/agents/{id}/regenerate` | Regenerate agent config with custom prompt |
| `POST` | `/v1/agents/{id}/resummon` | Retry initial LLM summoning |
### Predefined Agent Instances
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/agents/{id}/instances` | List user instances |
| `GET` | `/v1/agents/{id}/instances/{userID}/files` | Get user context files |
| `PUT` | `/v1/agents/{id}/instances/{userID}/files/{fileName}` | Update user file (USER.md only) |
| `PATCH` | `/v1/agents/{id}/instances/{userID}/metadata` | Update instance metadata |
### Wake (External Trigger)
```
POST /v1/agents/{id}/wake
```
```json
{
"message": "Process new data",
"session_key": "optional-session",
"user_id": "optional-user",
"metadata": {}
}
```
Response: `{content, run_id, usage?}`. Used by orchestrators (n8n, Paperclip) to trigger agent runs.
---
## 5. Skills
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/skills` | List all skills |
| `POST` | `/v1/skills/upload` | Upload ZIP with SKILL.md (20 MB limit) |
| `GET` | `/v1/skills/{id}` | Get skill details |
| `PUT` | `/v1/skills/{id}` | Update skill metadata |
| `DELETE` | `/v1/skills/{id}` | Delete skill (not system skills) |
| `POST` | `/v1/skills/{id}/toggle` | Enable/disable skill |
### Skill Grants
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/v1/skills/{id}/grants/agent` | Grant skill to agent |
| `DELETE` | `/v1/skills/{id}/grants/agent/{agentID}` | Revoke from agent |
| `POST` | `/v1/skills/{id}/grants/user` | Grant skill to user |
| `DELETE` | `/v1/skills/{id}/grants/user/{userID}` | Revoke from user |
| `GET` | `/v1/agents/{agentID}/skills` | List skills with grant status |
### Skill Files & Dependencies
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/skills/{id}/versions` | List available versions |
| `GET` | `/v1/skills/{id}/files` | List files in skill |
| `GET` | `/v1/skills/{id}/files/{path...}` | Read file content |
| `POST` | `/v1/skills/rescan-deps` | Rescan runtime dependencies |
| `POST` | `/v1/skills/install-deps` | Install all missing deps |
| `POST` | `/v1/skills/install-dep` | Install single dependency |
| `GET` | `/v1/skills/runtimes` | Check runtime availability |
---
## 6. Providers
LLM provider management. API keys are encrypted with AES-256-GCM in the database and masked in responses.
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/providers` | List providers (keys masked) |
| `POST` | `/v1/providers` | Create provider |
| `GET` | `/v1/providers/{id}` | Get provider |
| `PUT` | `/v1/providers/{id}` | Update provider |
| `DELETE` | `/v1/providers/{id}` | Delete provider |
### Provider Verification & Models
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/v1/providers/{id}/verify` | Test provider+model with minimal LLM call |
| `GET` | `/v1/providers/{id}/models` | Proxy to upstream provider model list |
| `GET` | `/v1/providers/claude-cli/auth-status` | Check Claude CLI login status |
**Supported types:** `anthropic_native`, `openai_compat`, `chatgpt_oauth`, `gemini_native`, `dashscope`, `bailian`, `minimax`, `claude_cli`, `acp`
---
## 7. Sessions
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/sessions` | List sessions (paginated) |
| `GET` | `/v1/sessions/{key}` | Get session with messages |
| `DELETE` | `/v1/sessions/{key}` | Delete session |
| `POST` | `/v1/sessions/{key}/reset` | Clear session messages |
| `PATCH` | `/v1/sessions/{key}` | Update label, model, metadata |
---
## 8. MCP Servers
Model Context Protocol server management.
### Server CRUD
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/mcp/servers` | List servers with agent grant counts |
| `POST` | `/v1/mcp/servers` | Create MCP server |
| `GET` | `/v1/mcp/servers/{id}` | Get server details |
| `PUT` | `/v1/mcp/servers/{id}` | Update server |
| `DELETE` | `/v1/mcp/servers/{id}` | Delete server |
| `POST` | `/v1/mcp/servers/test` | Test connection (no save) |
| `GET` | `/v1/mcp/servers/{id}/tools` | List runtime-discovered tools |
### Agent Grants
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/mcp/servers/{id}/grants` | List grants for server |
| `POST` | `/v1/mcp/servers/{id}/grants/agent` | Grant to agent |
| `DELETE` | `/v1/mcp/servers/{id}/grants/agent/{agentID}` | Revoke from agent |
| `GET` | `/v1/mcp/grants/agent/{agentID}` | List agent's server grants |
### User Grants
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/v1/mcp/servers/{id}/grants/user` | Grant to user |
| `DELETE` | `/v1/mcp/servers/{id}/grants/user/{userID}` | Revoke from user |
### Access Requests
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/v1/mcp/requests` | Create access request |
| `GET` | `/v1/mcp/requests` | List pending requests |
| `POST` | `/v1/mcp/requests/{id}/review` | Approve/deny request |
Grants support `tool_allow` and `tool_deny` JSON arrays for fine-grained tool filtering.
---
## 9. Tools
### Built-in Tools
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/tools/builtin` | List all built-in tools |
| `GET` | `/v1/tools/builtin/{name}` | Get tool definition |
| `PUT` | `/v1/tools/builtin/{name}` | Update enabled/settings |
### Custom Tools
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/tools/custom` | List custom tools (paginated) |
| `POST` | `/v1/tools/custom` | Create custom tool |
| `GET` | `/v1/tools/custom/{id}` | Get tool details |
| `PUT` | `/v1/tools/custom/{id}` | Update tool |
| `DELETE` | `/v1/tools/custom/{id}` | Delete tool |
### Direct Invocation
```
POST /v1/tools/invoke
```
```json
{
"tool": "web_fetch",
"action": "fetch",
"args": {"url": "https://example.com"},
"dryRun": false,
"agentId": "optional",
"channel": "optional",
"chatId": "optional",
"peerKind": "direct"
}
```
Set `"dryRun": true` to return tool schema without execution.
---
## 10. Memory
Per-agent vector memory using pgvector.
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/memory/documents` | List all documents globally |
| `GET` | `/v1/agents/{agentID}/memory/documents` | List documents for agent |
| `GET` | `/v1/agents/{agentID}/memory/documents/{path...}` | Get document details |
| `PUT` | `/v1/agents/{agentID}/memory/documents/{path...}` | Put/update document |
| `DELETE` | `/v1/agents/{agentID}/memory/documents/{path...}` | Delete document |
| `GET` | `/v1/agents/{agentID}/memory/chunks` | List chunks for document |
| `POST` | `/v1/agents/{agentID}/memory/index` | Index single document |
| `POST` | `/v1/agents/{agentID}/memory/index-all` | Index all documents |
| `POST` | `/v1/agents/{agentID}/memory/search` | Semantic search |
Optional query parameter `?user_id=` for per-user scoping.
---
## 11. Knowledge Graph
Per-agent entity-relation graph.
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/agents/{agentID}/kg/entities` | List/search entities (BM25) |
| `GET` | `/v1/agents/{agentID}/kg/entities/{entityID}` | Get entity with relations |
| `POST` | `/v1/agents/{agentID}/kg/entities` | Upsert entity |
| `DELETE` | `/v1/agents/{agentID}/kg/entities/{entityID}` | Delete entity |
| `POST` | `/v1/agents/{agentID}/kg/traverse` | Traverse graph (max depth 3) |
| `POST` | `/v1/agents/{agentID}/kg/extract` | LLM-powered entity extraction |
| `GET` | `/v1/agents/{agentID}/kg/stats` | Knowledge graph statistics |
| `GET` | `/v1/agents/{agentID}/kg/graph` | Full graph for visualization |
---
## 12. Channels
### Channel Instances
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/channels/instances` | List instances (paginated) |
| `POST` | `/v1/channels/instances` | Create instance |
| `GET` | `/v1/channels/instances/{id}` | Get instance |
| `PUT` | `/v1/channels/instances/{id}` | Update instance |
| `DELETE` | `/v1/channels/instances/{id}` | Delete instance (not default) |
### Contacts
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/contacts` | List contacts (paginated) |
| `GET` | `/v1/contacts/resolve?ids=...` | Resolve contacts by IDs (max 100) |
### Group Writers
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/channels/instances/{id}/writers/groups` | List group file writers |
| `GET` | `/v1/channels/instances/{id}/writers` | List writers for group |
| `POST` | `/v1/channels/instances/{id}/writers` | Add writer to group |
| `DELETE` | `/v1/channels/instances/{id}/writers/{userId}` | Remove writer |
**Supported channels:** `telegram`, `discord`, `slack`, `whatsapp`, `zalo_oa`, `zalo_personal`, `feishu`
Credentials are masked in HTTP responses.
---
## 13. Pending Messages
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/pending-messages` | List all groups with titles |
| `GET` | `/v1/pending-messages/messages` | List messages by channel+key |
| `DELETE` | `/v1/pending-messages` | Delete message group |
| `POST` | `/v1/pending-messages/compact` | LLM-based summarization (async, 202) |
Compaction runs in the background. Falls back to hard delete if no LLM provider is available.
---
## 14. Traces
LLM call tracing and cost analysis.
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/traces` | List traces (paginated, filterable) |
| `GET` | `/v1/traces/{traceID}` | Get trace with spans |
| `GET` | `/v1/traces/{traceID}/export` | Export trace tree (gzipped JSON) |
| `GET` | `/v1/costs/summary` | Cost summary by agent/time range |
**Filters:** `agent_id`, `user_id`, `session_key`, `status`, `channel`
---
## 15. Usage & Analytics
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/usage/timeseries` | Time-series usage points |
| `GET` | `/v1/usage/breakdown` | Breakdown by provider/model/channel |
| `GET` | `/v1/usage/summary` | Summary with period comparison |
**Query params:** `from`, `to` (RFC 3339), `agent_id`, `provider`, `model`, `channel`, `group_by`
**Periods:** `24h`, `today`, `7d`, `30d`
---
## 16. Activity & Audit
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/activity` | List activity audit logs (filterable) |
---
## 17. Storage
Workspace file management.
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/storage/files` | List files with depth limiting |
| `GET` | `/v1/storage/files/{path...}` | Read file (JSON or raw) |
| `DELETE` | `/v1/storage/files/{path...}` | Delete file/directory |
| `GET` | `/v1/storage/size` | Stream storage size (SSE, cached 60 min) |
Use `?raw=true` to serve native MIME type. Protected directories `skills/` and `skills-store/` cannot be deleted. Path traversal and symlink attacks are blocked.
---
## 18. Media
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/v1/media/upload` | Upload file (multipart, 50 MB limit) |
| `GET` | `/v1/media/{id}` | Serve media by ID with caching |
---
## 19. Files
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/files/{path...}` | Serve workspace file by path |
Auth via Bearer token or `?token=` query param (for `<img>` tags). MIME type auto-detected. Path traversal blocked.
---
## 20. API Keys
Admin-only endpoints for managing gateway API keys. See [20 — API Keys & Auth](20-api-keys-auth.md) for the full authentication and authorization model.
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/api-keys` | List all API keys (masked) |
| `POST` | `/v1/api-keys` | Create API key (returns raw key once) |
| `DELETE` | `/v1/api-keys/{id}` | Revoke API key |
### Create Request
```json
{
"name": "ci-deploy",
"scopes": ["operator.read", "operator.write"],
"expires_in": 2592000
}
```
### Create Response
```json
{
"id": "01961234-...",
"name": "ci-deploy",
"prefix": "goclaw_a1b2c3d4",
"key": "goclaw_a1b2c3d4e5f6...full-key",
"scopes": ["operator.read", "operator.write"],
"expires_at": "2026-04-14T12:00:00Z",
"created_at": "2026-03-15T12:00:00Z"
}
```
> The `key` field is only returned in the create response. Subsequent list/get calls show only the `prefix`.
---
## 21. OAuth
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/auth/openai/status` | Check OpenAI auth status |
| `POST` | `/v1/auth/openai/start` | Start OAuth flow |
| `POST` | `/v1/auth/openai/callback` | Manual callback handler |
| `POST` | `/v1/auth/openai/logout` | Revoke token |
---
## 22. System
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/health` | Health check (no auth) |
| `GET` | `/v1/openapi.json` | OpenAPI 3.0 spec |
| `GET` | `/docs` | Swagger UI |
### Health Response
```json
{
"status": "ok",
"protocol": 3
}
```
---
## 23. MCP Bridge
Exposes GoClaw tools to Claude CLI via streamable HTTP at `/mcp/bridge`. Only listens on localhost. Protected by gateway token with HMAC-signed context headers.
| Header | Purpose |
|--------|---------|
| `X-Agent-ID` | Agent context for tool execution |
| `X-User-ID` | User context |
| `X-Channel` | Channel routing |
| `X-Chat-ID` | Chat routing |
| `X-Peer-Kind` | `direct` or `group` |
| `X-Bridge-Sig` | HMAC signature over all context fields |
---
## Error Responses
All endpoints return errors in a consistent JSON format:
```json
{
"error": "human-readable error message"
}
```
Error messages are localized based on the `Accept-Language` header. HTTP status codes follow standard conventions:
| Code | Meaning |
|------|---------|
| `400` | Bad request (invalid JSON, missing fields) |
| `401` | Unauthorized (missing or invalid token) |
| `403` | Forbidden (insufficient permissions) |
| `404` | Not found |
| `409` | Conflict (duplicate name, version mismatch) |
| `429` | Rate limited |
| `500` | Internal server error |
---
## File Reference
| File | Purpose |
|------|---------|
| `internal/http/chat_completions.go` | OpenAI-compatible chat API |
| `internal/http/responses.go` | OpenResponses protocol |
| `internal/http/agents.go` | Agent CRUD + shares |
| `internal/http/skills.go` | Skill management |
| `internal/http/providers.go` | Provider CRUD |
| `internal/http/mcp.go` | MCP server management |
| `internal/http/custom_tools.go` | Custom tool CRUD |
| `internal/http/builtin_tools.go` | Built-in tool management |
| `internal/http/channel_instances.go` | Channel instance management |
| `internal/http/memory.go` | Memory document management |
| `internal/http/knowledge_graph.go` | Knowledge graph API |
| `internal/http/traces.go` | LLM trace listing |
| `internal/http/usage.go` | Usage analytics |
| `internal/http/activity.go` | Activity audit log |
| `internal/http/storage.go` | Workspace file management |
| `internal/http/api_keys.go` | API key management |
| `internal/http/auth.go` | Authentication helpers |
| `internal/http/openapi.go` | OpenAPI spec + Swagger UI |
| `internal/gateway/server.go` | HTTP mux and route wiring |
| `cmd/gateway.go` | Handler instantiation and wiring |
+534
View File
@@ -0,0 +1,534 @@
# 19 — WebSocket RPC Methods
GoClaw's primary control plane is a WebSocket-based JSON-RPC protocol (v3). Clients connect to `/ws`, authenticate via `connect`, then exchange request/response/event frames.
For the wire protocol, frame format, and connection lifecycle, see [04 — Gateway Protocol](04-gateway-protocol.md). This document catalogs every available RPC method.
---
## 1. Connection & System
### `connect`
Establish an authenticated session. Must be the first request after WebSocket upgrade.
**Request:**
```json
{
"token": "gateway-token-or-api-key",
"user_id": "external-user-id",
"sender_id": "optional-device-id",
"locale": "en"
}
```
**Response:**
```json
{
"protocol": 3,
"role": "admin",
"user_id": "user-123",
"server": {
"version": "1.0.0",
"uptime": "2h30m"
}
}
```
**Auth flow:** Gateway token → timing-safe compare → admin role. If no match, SHA-256 hash → API key lookup → role derived from scopes. Pairing codes also accepted for channel devices.
### `health`
Server health and connected clients.
**Response:**
```json
{
"status": "ok",
"version": "1.0.0",
"uptime": "2h30m",
"mode": "managed",
"database": "ok",
"tools": ["exec", "web_fetch", "memory", "..."],
"clients": [{"id": "...", "role": "admin", "user_id": "..."}],
"currentId": "client-uuid"
}
```
### `status`
Quick agent/session/client counts.
**Response:**
```json
{
"agents": [{"id": "...", "name": "...", "isRunning": false}],
"agentTotal": 5,
"clients": 2,
"sessions": 42
}
```
---
## 2. Chat
### `chat.send`
Send a message to an agent and trigger execution.
**Request:**
```json
{
"message": "Hello, agent",
"agentId": "uuid-or-key",
"sessionKey": "optional-session",
"stream": true,
"media": [{"type": "image", "url": "..."}]
}
```
**Response:**
```json
{
"runId": "uuid",
"content": "Agent response text",
"usage": {"input_tokens": 100, "output_tokens": 50},
"media": []
}
```
When `stream: true`, intermediate events are emitted: `chunk`, `tool.call`, `tool.result`, `run.started`, `run.completed`.
### `chat.history`
Retrieve chat history for a session.
**Request:** `{agentId, sessionKey}`
**Response:** `{messages: [{role, content, timestamp, ...}]}`
### `chat.abort`
Cancel running agent invocations.
**Request:** `{sessionKey?, runId?}`
**Response:** `{ok: true, aborted: 1, runIds: ["..."]}`
### `chat.inject`
Inject a message into the session transcript without triggering the agent.
**Request:** `{sessionKey, message, label}`
**Response:** `{ok: true, messageId: "..."}`
---
## 3. Agents
### `agents.list`
List all agents.
**Response:** `{agents: [{id, name, key, emoji, avatar, agent_type, ...}]}`
### `agent`
Get single agent status.
**Request:** `{agentId}`
**Response:** `{id, isRunning}`
### `agent.wait`
Wait for agent completion.
**Request:** `{agentId}`
**Response:** `{id, status}`
### `agent.identity.get`
Get agent identity (name, emoji, avatar, description).
**Request:** `{agentId?, sessionKey?}`
**Response:** `{agentId, name, emoji, avatar, description}`
### `agents.create`
Create a new agent (admin only).
**Request:**
```json
{
"name": "My Agent",
"workspace": "~/agents/my-agent",
"emoji": "🤖",
"agent_type": "open",
"owner_ids": ["user-1"],
"tools_config": {},
"memory_config": {},
"sandbox_config": {}
}
```
**Response:** `{ok: true, agentId: "uuid", name, workspace}`
### `agents.update`
Update agent properties (admin only).
**Request:** `{agentId, name?, model?, avatar?, tools_config?, ...}`
**Response:** `{ok: true, agentId}`
### `agents.delete`
Delete an agent (admin only).
**Request:** `{agentId, deleteFiles: false}`
**Response:** `{ok: true, agentId, removedBindings: 2}`
### Agent Context Files
| Method | Description |
|--------|-------------|
| `agents.files.list` | List allowed context files |
| `agents.files.get` | Get file content |
| `agents.files.set` | Save file content |
**Request:** `{agentId, name?, content?}`
### Agent Links (Delegations)
| Method | Description |
|--------|-------------|
| `agents.links.list` | List delegation links (direction: from/to/all) |
| `agents.links.create` | Create link between agents |
| `agents.links.update` | Update link settings |
| `agents.links.delete` | Delete link |
---
## 4. Sessions
| Method | Description |
|--------|-------------|
| `sessions.list` | List sessions (paginated) |
| `sessions.preview` | Get session history + summary |
| `sessions.patch` | Update label, model, metadata |
| `sessions.delete` | Delete session |
| `sessions.reset` | Clear session messages |
**`sessions.list` request:** `{agentId, limit, offset}`
**Response:** `{sessions[], total, limit, offset}`
---
## 5. Config
### `config.get`
Get current configuration.
**Response:** `{config: {...}, hash: "sha256", path: "/path/to/config.json"}`
### `config.apply`
Replace entire config (admin only). Uses optimistic locking via `baseHash`.
**Request:** `{raw: "json5 content", baseHash: "sha256"}`
**Response:** `{ok, path, config, hash, restart: false}`
### `config.patch`
Merge partial config update (admin only).
**Request:** `{raw: "{gateway: {port: 9090}}", baseHash: "sha256"}`
**Response:** `{ok, path, config, hash, restart: true}`
### `config.schema`
Get JSON schema for config form generation.
**Response:** `{json: {...schema...}}`
---
## 6. Skills
| Method | Description |
|--------|-------------|
| `skills.list` | List all available skills |
| `skills.get` | Get skill metadata and content |
| `skills.update` | Update skill metadata (DB-backed only) |
---
## 7. Cron
| Method | Description |
|--------|-------------|
| `cron.list` | List cron jobs |
| `cron.create` | Create scheduled job |
| `cron.update` | Update job settings |
| `cron.delete` | Delete job |
| `cron.toggle` | Enable/disable job |
| `cron.status` | Get scheduler status |
| `cron.run` | Trigger immediate execution |
| `cron.runs` | List execution history |
### `cron.create` Request
```json
{
"name": "daily-report",
"schedule": "every day at 09:00",
"message": "Generate daily report",
"deliver": "channel",
"channel": "telegram",
"to": "chat-id",
"agentId": "uuid"
}
```
---
## 8. Channels
| Method | Description |
|--------|-------------|
| `channels.list` | List enabled channels |
| `channels.status` | Get channel connection status |
### Channel Instances
| Method | Description |
|--------|-------------|
| `channels.instances.list` | List instances |
| `channels.instances.get` | Get instance details |
| `channels.instances.create` | Create instance |
| `channels.instances.update` | Update instance |
| `channels.instances.delete` | Delete instance |
---
## 9. Device Pairing
| Method | Description | Auth |
|--------|-------------|------|
| `device.pair.request` | Request pairing (from device) | Unauthenticated |
| `device.pair.approve` | Approve request (from admin) | Admin |
| `device.pair.deny` | Deny request | Admin |
| `device.pair.list` | List pending + paired devices | Admin |
| `device.pair.revoke` | Revoke device | Admin |
| `browser.pairing.status` | Poll pairing status | Unauthenticated |
### Pairing Flow
```mermaid
sequenceDiagram
Device->>Gateway: device.pair.request {senderId, channel}
Gateway-->>Device: {code: "A1B2C3D4"}
Device->>Gateway: browser.pairing.status {sender_id} (poll)
Admin->>Gateway: device.pair.approve {code, approvedBy}
Gateway-->>Device: {status: "approved"}
```
---
## 10. Teams
### Team CRUD
| Method | Description |
|--------|-------------|
| `teams.list` | List all teams |
| `teams.create` | Create team (admin only) |
| `teams.get` | Get team with members |
| `teams.update` | Update team properties |
| `teams.delete` | Delete team |
### Members
| Method | Description |
|--------|-------------|
| `teams.members.add` | Add agent to team with role |
| `teams.members.remove` | Remove agent from team |
### Tasks
| Method | Description |
|--------|-------------|
| `teams.tasks.list` | List team tasks (filterable) |
| `teams.tasks.get` | Get task with comments/events |
| `teams.tasks.create` | Create task |
| `teams.tasks.approve` | Approve task |
| `teams.tasks.reject` | Reject task |
| `teams.tasks.comment` | Add comment |
| `teams.tasks.comments` | List comments |
| `teams.tasks.events` | List task events |
| `teams.tasks.assign` | Assign to member |
### Workspace
| Method | Description |
|--------|-------------|
| `teams.workspace.list` | List workspace items |
| `teams.workspace.read` | Read workspace file |
| `teams.workspace.delete` | Delete workspace item |
---
## 11. Exec Approvals
| Method | Description |
|--------|-------------|
| `exec.approval.list` | List pending command approvals |
| `exec.approval.approve` | Approve (optionally always for this command) |
| `exec.approval.deny` | Deny command execution |
---
## 12. Delegations
| Method | Description |
|--------|-------------|
| `delegations.list` | List delegation history (filterable) |
| `delegations.get` | Get delegation record |
**Filters:** `source_agent_id`, `target_agent_id`, `team_id`, `user_id`, `status`
---
## 13. Usage & Quotas
| Method | Description |
|--------|-------------|
| `usage.get` | Get usage records by agent |
| `usage.summary` | Get summary of token usage |
| `quota.usage` | Get quota consumption |
---
## 14. API Keys
Admin-only methods.
| Method | Description |
|--------|-------------|
| `api_keys.list` | List API keys (masked) |
| `api_keys.create` | Create new API key |
| `api_keys.revoke` | Revoke an API key |
See [20 — API Keys & Auth](20-api-keys-auth.md) for the full authentication model.
---
## 15. Messaging
### `send`
Route an outbound message to a channel.
**Request:** `{channel: "telegram", to: "chat-id", message: "Hello"}`
**Response:** `{ok: true, channel, to}`
---
## 16. Logs
### `logs.tail`
Start or stop live log streaming.
**Request:** `{action: "start", level: "info"}`
**Response:** `{status: "started", level: "info"}`
Log entries are delivered as events while tailing is active.
---
## 17. Permission Matrix
Methods are gated by role. The role is determined at `connect` time from the token type and scopes.
| Role | Access |
|------|--------|
| **Admin** | All methods |
| **Operator** | Read + write operations (chat, sessions, cron, approvals, send) |
| **Viewer** | Read-only (list, get, preview, status, history) |
### Admin-Only Methods
`config.apply`, `config.patch`, `agents.create`, `agents.update`, `agents.delete`, `agents.links.*`, `channels.toggle`, `device.pair.approve`, `device.pair.deny`, `device.pair.revoke`, `teams.*`, `api_keys.*`
### Write Methods (Operator+)
`chat.send`, `chat.abort`, `chat.inject`, `sessions.delete`, `sessions.reset`, `sessions.patch`, `cron.*`, `skills.update`, `exec.approval.*`, `send`, `teams.tasks.*`
### Read Methods (Viewer+)
All other methods: list, get, preview, status, history, etc.
---
## 18. Events
The server pushes events to connected clients via event frames. Key event types:
| Event | Description |
|-------|-------------|
| `run.started` | Agent run began |
| `run.completed` | Agent run finished |
| `chunk` | Streaming text chunk |
| `tool.call` | Tool invocation started |
| `tool.result` | Tool invocation completed |
| `session.updated` | Session metadata changed |
| `agent.updated` | Agent config changed |
| `cron.fired` | Cron job triggered |
| `team.task.*` | Team task lifecycle events |
| `exec.approval.pending` | Command awaiting approval |
---
## File Reference
| File | Purpose |
|------|---------|
| `internal/gateway/router.go` | Method dispatch + auth + connect handler |
| `internal/gateway/client.go` | WebSocket client + frame reading |
| `internal/gateway/server.go` | Server + mux setup |
| `internal/gateway/methods/chat.go` | Chat send/history/abort/inject |
| `internal/gateway/methods/agents.go` | Agent list/status |
| `internal/gateway/methods/agents_create.go` | Agent creation |
| `internal/gateway/methods/agents_update.go` | Agent update |
| `internal/gateway/methods/agents_delete.go` | Agent deletion |
| `internal/gateway/methods/agents_files.go` | Agent context files |
| `internal/gateway/methods/agents_identity.go` | Agent identity |
| `internal/gateway/methods/agent_links.go` | Agent delegation links |
| `internal/gateway/methods/config.go` | Config get/apply/patch/schema |
| `internal/gateway/methods/sessions.go` | Session CRUD |
| `internal/gateway/methods/skills.go` | Skill list/get/update |
| `internal/gateway/methods/cron.go` | Cron job management |
| `internal/gateway/methods/channels.go` | Channel listing |
| `internal/gateway/methods/channel_instances.go` | Channel instance CRUD |
| `internal/gateway/methods/pairing.go` | Device pairing flow |
| `internal/gateway/methods/teams.go` | Team list |
| `internal/gateway/methods/teams_crud.go` | Team CRUD |
| `internal/gateway/methods/teams_members.go` | Team membership |
| `internal/gateway/methods/teams_tasks.go` | Team task management |
| `internal/gateway/methods/teams_workspace.go` | Team workspace |
| `internal/gateway/methods/exec_approval.go` | Exec approval flow |
| `internal/gateway/methods/delegations.go` | Delegation history |
| `internal/gateway/methods/usage.go` | Usage records |
| `internal/gateway/methods/quota_methods.go` | Quota consumption |
| `internal/gateway/methods/api_keys.go` | API key management |
| `internal/gateway/methods/send.go` | Outbound messaging |
| `internal/gateway/methods/logs.go` | Log tailing |
| `internal/permissions/policy.go` | RBAC policy engine |
| `pkg/protocol/methods.go` | Method name constants |
+288
View File
@@ -0,0 +1,288 @@
# 20 — API Keys & Authentication
GoClaw supports two authentication mechanisms: a single gateway token (configured at startup) and multiple API keys with fine-grained RBAC scopes. Both work across HTTP REST and WebSocket RPC.
---
## 1. Gateway Token
The gateway token is set in `config.json` under `gateway.token`. It grants full admin access.
```json5
{
"gateway": {
"token": "my-secret-token"
}
}
```
Used as a Bearer token:
```
Authorization: Bearer my-secret-token
```
Or in WebSocket `connect`:
```json
{"method": "connect", "params": {"token": "my-secret-token", "user_id": "admin"}}
```
> The gateway token is compared using timing-safe comparison to prevent timing attacks.
---
## 2. API Keys
API keys provide scoped, revocable access for CI/CD, integrations, and third-party clients.
### Key Format
```
goclaw_a1b2c3d4e5f6789012345678901234567890abcdef
```
- **Prefix:** `goclaw_` (6 chars)
- **Random:** 32 hex characters (128 bits of entropy)
- **Display prefix:** `goclaw_` + first 8 hex chars (shown in UI after creation)
### Security Model
Keys are hashed with SHA-256 before storage — the raw key is never persisted. This follows the same pattern as GitHub Personal Access Tokens.
```
raw key → SHA-256 → stored hash
```
On authentication, the incoming token is hashed and looked up in the `api_keys` table via a partial index on non-revoked keys.
### Show-Once Pattern
The raw key is returned **only once** in the create response. All subsequent list/get calls show only the `prefix` field (first 8 chars after `goclaw_`). Users must copy the key immediately.
---
## 3. RBAC Scopes
Each API key is assigned one or more scopes that determine what operations it can perform.
| Scope | Description | Derived Role |
|-------|-------------|-------------|
| `operator.admin` | Full access (equivalent to gateway token) | Admin |
| `operator.read` | Read-only access to all resources | Viewer |
| `operator.write` | Read + write access (chat, sessions, cron) | Operator |
| `operator.approvals` | Manage exec approvals | Operator |
| `operator.pairing` | Manage device pairings | Operator |
### Role Derivation
The highest-privilege scope determines the effective role:
```
admin scope present → RoleAdmin
write/approvals/pairing → RoleOperator
read only → RoleViewer
```
The role is then used by the `PolicyEngine` to gate method access (see [19 — WebSocket RPC](19-websocket-rpc.md#17-permission-matrix)).
---
## 4. Authentication Flow
```mermaid
flowchart TD
A[Incoming request] --> B{Bearer token?}
B -->|No| C[401 Unauthorized]
B -->|Yes| D{Match gateway token?}
D -->|Yes| E[Admin role]
D -->|No| F[SHA-256 hash token]
F --> G{Lookup in api_keys}
G -->|Not found| C
G -->|Found + revoked| C
G -->|Found + expired| C
G -->|Found + valid| H[Derive role from scopes]
H --> I[Set role on context]
```
This flow is identical for both HTTP (`internal/http/auth.go`) and WebSocket (`internal/gateway/router.go` — connect handler).
### Last-Used Tracking
On successful API key authentication, `last_used_at` is updated asynchronously (via goroutine) to avoid blocking the request path.
---
## 5. Database Schema
```sql
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
prefix TEXT NOT NULL,
key_hash TEXT NOT NULL,
scopes TEXT[] NOT NULL DEFAULT '{}',
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
revoked BOOLEAN NOT NULL DEFAULT FALSE,
created_by TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_api_keys_hash_active
ON api_keys (key_hash)
WHERE NOT revoked;
```
The partial index ensures only active (non-revoked) keys are searched during authentication, keeping lookups fast regardless of how many keys have been revoked over time.
---
## 6. API Endpoints
### HTTP REST
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/api-keys` | List all keys (masked) |
| `POST` | `/v1/api-keys` | Create key |
| `DELETE` | `/v1/api-keys/{id}` | Revoke key |
### WebSocket RPC
| Method | Description |
|--------|-------------|
| `api_keys.list` | List all keys (masked) |
| `api_keys.create` | Create key |
| `api_keys.revoke` | Revoke key |
All API key management operations require admin access (gateway token or API key with `operator.admin` scope).
### Create Request
```json
{
"name": "ci-deploy",
"scopes": ["operator.read", "operator.write"],
"expires_in": 2592000
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | Human-readable label |
| `scopes` | string[] | Yes | Permission scopes |
| `expires_in` | int | No | TTL in seconds (omit for no expiry) |
### Create Response
```json
{
"id": "01961234-5678-7abc-def0-123456789012",
"name": "ci-deploy",
"prefix": "goclaw_a1b2c3d4",
"key": "goclaw_a1b2c3d4e5f6789012345678901234567890abcdef",
"scopes": ["operator.read", "operator.write"],
"expires_at": "2026-04-14T12:00:00Z",
"created_at": "2026-03-15T12:00:00Z"
}
```
### List Response
```json
[
{
"id": "01961234-...",
"name": "ci-deploy",
"prefix": "goclaw_a1b2c3d4",
"scopes": ["operator.read", "operator.write"],
"expires_at": "2026-04-14T12:00:00Z",
"last_used_at": "2026-03-15T14:30:00Z",
"revoked": false,
"created_by": "admin",
"created_at": "2026-03-15T12:00:00Z"
}
]
```
> Note: `key` field is absent in list responses. Only `prefix` is shown.
---
## 7. Backward Compatibility
The gateway token continues to work exactly as before. API keys are an additional authentication path — no breaking changes to existing integrations.
| Auth method | Before | After |
|------------|--------|-------|
| Gateway token | Admin access | Admin access (unchanged) |
| API key | N/A | Scoped access per key |
| Device pairing | Operator access | Operator access (unchanged) |
---
## 8. Web UI
The API Keys management page is accessible from the sidebar under **System > API Keys** (admin only).
Features:
- **Create dialog:** Name, scope checkboxes, optional expiry (1 day, 7 days, 30 days, 90 days, never)
- **Show-once dialog:** Displays the raw key with copy button after creation
- **List view:** Searchable, paginated table with name, prefix, scopes, expiry, last used, status
- **Revoke:** Confirmation dialog before revoking a key
---
## 9. Usage Examples
### cURL with API Key
```bash
# List agents (read scope required)
curl -H "Authorization: Bearer goclaw_a1b2c3d4..." \
http://localhost:9090/v1/agents
# Send chat message (write scope required)
curl -X POST -H "Authorization: Bearer goclaw_a1b2c3d4..." \
-H "Content-Type: application/json" \
-d '{"model":"goclaw:my-agent","messages":[{"role":"user","content":"Hello"}]}' \
http://localhost:9090/v1/chat/completions
```
### WebSocket with API Key
```json
{"id": 1, "method": "connect", "params": {
"token": "goclaw_a1b2c3d4e5f6...",
"user_id": "ci-bot"
}}
```
### Create Key via API
```bash
curl -X POST -H "Authorization: Bearer gateway-admin-token" \
-H "Content-Type: application/json" \
-d '{"name":"ci","scopes":["operator.read","operator.write"]}' \
http://localhost:9090/v1/api-keys
```
---
## File Reference
| File | Purpose |
|------|---------|
| `internal/crypto/apikey.go` | Key generation + SHA-256 hashing |
| `internal/store/api_key_store.go` | Store interface + `APIKeyData` struct |
| `internal/store/pg/api_keys.go` | PostgreSQL implementation |
| `internal/http/api_keys.go` | HTTP API handler |
| `internal/http/auth.go` | HTTP auth middleware (resolveAPIKey) |
| `internal/gateway/router.go` | WebSocket connect auth (API key path) |
| `internal/gateway/methods/api_keys.go` | WebSocket RPC methods |
| `internal/permissions/policy.go` | RBAC policy engine |
| `internal/permissions/scope.go` | Scope constants + RoleFromScopes |
| `migrations/000020_api_keys.up.sql` | Database migration |
| `ui/web/src/pages/api-keys/` | Web UI components |
+1
View File
@@ -77,6 +77,7 @@ require (
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-shellwords v1.0.12 // indirect
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
github.com/mdlayher/socket v0.5.0 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
+2
View File
@@ -266,6 +266,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
+15
View File
@@ -13,6 +13,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// filteredToolNames returns tool names after applying policy filters.
@@ -29,6 +30,19 @@ func (l *Loop) filteredToolNames() []string {
return names
}
// buildCredentialCLIContext generates the TOOLS.md supplement for credentialed CLIs.
// Returns empty string if no secure CLI store is configured or no enabled CLIs.
func (l *Loop) buildCredentialCLIContext(ctx context.Context) string {
if l.secureCLIStore == nil {
return ""
}
creds, err := l.secureCLIStore.ListEnabled(ctx)
if err != nil || len(creds) == 0 {
return ""
}
return tools.GenerateCredentialContext(creds)
}
// buildMCPToolDescs extracts real descriptions for MCP tools from the registry.
// Returns nil if no MCP tools are present.
func (l *Loop) buildMCPToolDescs(toolNames []string) map[string]string {
@@ -135,6 +149,7 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
SandboxContainerDir: l.sandboxContainerDir,
SandboxWorkspaceAccess: l.sandboxWorkspaceAccess,
SelfEvolve: l.selfEvolve,
CredentialCLIContext: l.buildCredentialCLIContext(ctx),
})
messages = append(messages, providers.Message{
+7
View File
@@ -116,6 +116,9 @@ type Loop struct {
// Team store for cross-session pending task detection
teamStore store.TeamStore
// Secure CLI store for credentialed exec context injection
secureCLIStore store.SecureCLIStore
// Persistent media storage for cross-turn image/document access
mediaStore *media.Store
@@ -222,6 +225,9 @@ type LoopConfig struct {
// Team store for cross-session pending task detection
TeamStore store.TeamStore
// Secure CLI store for credentialed exec context injection
SecureCLIStore store.SecureCLIStore
// Persistent media storage for cross-turn image/document access
MediaStore *media.Store
@@ -310,6 +316,7 @@ func NewLoop(cfg LoopConfig) *Loop {
selfEvolve: cfg.SelfEvolve,
groupWriterCache: cfg.GroupWriterCache,
teamStore: cfg.TeamStore,
secureCLIStore: cfg.SecureCLIStore,
mediaStore: cfg.MediaStore,
modelPricing: cfg.ModelPricing,
budgetMonthlyCents: cfg.BudgetMonthlyCents,
+4
View File
@@ -59,6 +59,9 @@ type ResolverDeps struct {
// Agent teams
TeamStore store.TeamStore
// Secure CLI credential store for credentialed exec
SecureCLIStore store.SecureCLIStore
// Builtin tool settings
BuiltinToolStore store.BuiltinToolStore
@@ -341,6 +344,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
WorkspaceSharing: ag.ParseWorkspaceSharing(),
GroupWriterCache: deps.GroupWriterCache,
TeamStore: deps.TeamStore,
SecureCLIStore: deps.SecureCLIStore,
MediaStore: deps.MediaStore,
ModelPricing: deps.ModelPricing,
BudgetMonthlyCents: derefInt(ag.BudgetMonthlyCents),
+9
View File
@@ -49,6 +49,10 @@ type SystemPromptConfig struct {
// Self-evolution: predefined agents can update SOUL.md (style/tone)
SelfEvolve bool
// Credentialed CLI context — appended after tooling section.
// Generated by tools.GenerateCredentialContext() from enabled secure CLI configs.
CredentialCLIContext string
}
// coreToolSummaries maps tool names to one-line descriptions.
@@ -145,6 +149,11 @@ func BuildSystemPrompt(cfg SystemPromptConfig) string {
// 2. ## Tooling
lines = append(lines, buildToolingSection(cfg.ToolNames, cfg.SandboxEnabled)...)
// 2.5. Credentialed CLI context (appended after tooling, before safety)
if cfg.CredentialCLIContext != "" {
lines = append(lines, cfg.CredentialCLIContext, "")
}
// 3. ## Safety
lines = append(lines, buildSafetySection()...)
+1
View File
@@ -65,6 +65,7 @@ const (
CacheKindSkillGrants = "skill_grants"
CacheKindMCP = "mcp"
CacheKindProvider = "provider"
CacheKindAPIKeys = "api_keys"
)
// Topic constants for msgBus.Subscribe() / Broadcast().
+30
View File
@@ -0,0 +1,30 @@
package crypto
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
)
const apiKeyPrefix = "goclaw_"
// GenerateAPIKey creates a new API key with format "goclaw_<32hex>".
// Returns the raw key (show once), its SHA-256 hash, and 8-char display prefix.
func GenerateAPIKey() (raw, hash, displayPrefix string, err error) {
b := make([]byte, 16) // 16 bytes = 32 hex chars
if _, err = rand.Read(b); err != nil {
return "", "", "", fmt.Errorf("generate random bytes: %w", err)
}
raw = apiKeyPrefix + hex.EncodeToString(b)
hash = HashAPIKey(raw)
displayPrefix = hex.EncodeToString(b[:4]) // first 8 hex chars of the random part
return raw, hash, displayPrefix, nil
}
// HashAPIKey returns the SHA-256 hex digest of a raw API key.
func HashAPIKey(raw string) string {
h := sha256.Sum256([]byte(raw))
return hex.EncodeToString(h[:])
}
+1
View File
@@ -27,6 +27,7 @@ type Client struct {
remoteAddr string // peer IP (extracted from proxy headers or RemoteAddr)
locale string // user's preferred locale (e.g. "en", "vi", "zh")
scopes []permissions.Scope // API key scopes (empty = role-based auth, no scope restriction)
// Browser pairing state
pairingCode string // 8-char code if pending approval
+149
View File
@@ -0,0 +1,149 @@
package methods
import (
"context"
"encoding/json"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// APIKeysMethods handles api_keys.list, api_keys.create, api_keys.revoke.
type APIKeysMethods struct {
apiKeys store.APIKeyStore
}
// NewAPIKeysMethods creates a new API keys method handler.
func NewAPIKeysMethods(apiKeys store.APIKeyStore) *APIKeysMethods {
return &APIKeysMethods{apiKeys: apiKeys}
}
// Register registers API key management RPC methods.
func (m *APIKeysMethods) Register(router *gateway.MethodRouter) {
router.Register(protocol.MethodAPIKeysList, m.handleList)
router.Register(protocol.MethodAPIKeysCreate, m.handleCreate)
router.Register(protocol.MethodAPIKeysRevoke, m.handleRevoke)
}
func (m *APIKeysMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
keys, err := m.apiKeys.List(ctx)
if err != nil {
slog.Error("api_keys.list failed", "error", err)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "API keys")))
return
}
if keys == nil {
keys = []store.APIKeyData{}
}
client.SendResponse(protocol.NewOKResponse(req.ID, keys))
}
func (m *APIKeysMethods) handleCreate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
Name string `json:"name"`
Scopes []string `json:"scopes"`
ExpiresIn *int `json:"expires_in"` // seconds; nil = never
}
if req.Params != nil {
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
}
if params.Name == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")))
return
}
if len(params.Scopes) == 0 {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "scopes")))
return
}
// Validate scopes
for _, s := range params.Scopes {
if !permissions.ValidScope(s) {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "invalid scope: "+s)))
return
}
}
raw, hash, prefix, err := crypto.GenerateAPIKey()
if err != nil {
slog.Error("api_keys.generate failed", "error", err)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "key generation")))
return
}
now := time.Now()
key := &store.APIKeyData{
ID: store.GenNewID(),
Name: params.Name,
Prefix: prefix,
KeyHash: hash,
Scopes: params.Scopes,
CreatedBy: store.UserIDFromContext(ctx),
CreatedAt: now,
UpdatedAt: now,
}
if params.ExpiresIn != nil && *params.ExpiresIn > 0 {
exp := now.Add(time.Duration(*params.ExpiresIn) * time.Second)
key.ExpiresAt = &exp
}
if err := m.apiKeys.Create(ctx, key); err != nil {
slog.Error("api_keys.create failed", "error", err)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "API key", "internal error")))
return
}
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"id": key.ID,
"name": key.Name,
"prefix": key.Prefix,
"key": raw,
"scopes": key.Scopes,
"expires_at": key.ExpiresAt,
"created_at": key.CreatedAt,
}))
}
func (m *APIKeysMethods) handleRevoke(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
ID string `json:"id"`
}
if req.Params != nil {
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
}
id, err := uuid.Parse(params.ID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "API key")))
return
}
if err := m.apiKeys.Revoke(ctx, id); err != nil {
slog.Error("api_keys.revoke failed", "error", err, "id", params.ID)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "API key", params.ID)))
return
}
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]string{"status": "revoked"}))
}
+20 -2
View File
@@ -2,10 +2,12 @@ package gateway
import (
"context"
"crypto/subtle"
"encoding/json"
"log/slog"
"time"
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -99,8 +101,8 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p
configToken := r.server.cfg.Gateway.Token
// Path 1: Valid token → admin
if configToken != "" && params.Token == configToken {
// Path 1: Valid gateway token → admin (constant-time comparison)
if configToken != "" && subtle.ConstantTimeCompare([]byte(params.Token), []byte(configToken)) == 1 {
client.role = permissions.RoleAdmin
client.authenticated = true
client.userID = params.UserID
@@ -108,6 +110,22 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p
return
}
// Path 1b: API key → role derived from scopes (uses shared cache)
if params.Token != "" {
if keyData, role := httpapi.ResolveAPIKey(ctx, params.Token); keyData != nil {
scopes := make([]permissions.Scope, len(keyData.Scopes))
for i, s := range keyData.Scopes {
scopes[i] = permissions.Scope(s)
}
client.role = role
client.scopes = scopes
client.authenticated = true
client.userID = params.UserID
r.sendConnectResponse(client, req.ID)
return
}
}
// Path 2: No token configured → operator (backward compat)
if configToken == "" {
client.role = permissions.RoleOperator
+33 -1
View File
@@ -2,6 +2,7 @@ package gateway
import (
"context"
"crypto/subtle"
"errors"
"fmt"
"log/slog"
@@ -49,6 +50,7 @@ type Server struct {
teamEventsHandler *httpapi.TeamEventsHandler // team event history API
builtinToolsHandler *httpapi.BuiltinToolsHandler // builtin tool management API
pendingMessagesHandler *httpapi.PendingMessagesHandler // pending messages API
secureCLIHandler *httpapi.SecureCLIHandler // secure CLI credential CRUD API
memoryHandler *httpapi.MemoryHandler // memory management API
kgHandler *httpapi.KnowledgeGraphHandler // knowledge graph API
oauthHandler *httpapi.OAuthHandler // OAuth endpoints
@@ -58,6 +60,9 @@ type Server struct {
mediaServeHandler *httpapi.MediaServeHandler // media serve endpoint
activityHandler *httpapi.ActivityHandler // activity audit log API
usageHandler *httpapi.UsageHandler // usage analytics API
apiKeysHandler *httpapi.APIKeysHandler // API key management
apiKeyStore store.APIKeyStore // for API key auth lookup
docsHandler *httpapi.DocsHandler // OpenAPI spec + Swagger UI
agentStore store.AgentStore // for context injection in tools_invoke
msgBus *bus.MessageBus // for MCP bridge media delivery
@@ -194,6 +199,11 @@ func (s *Server) BuildMux() *http.ServeMux {
s.customToolsHandler.RegisterRoutes(mux)
}
// Secure CLI credential CRUD API
if s.secureCLIHandler != nil {
s.secureCLIHandler.RegisterRoutes(mux)
}
// Channel instance CRUD API
if s.channelInstancesHandler != nil {
s.channelInstancesHandler.RegisterRoutes(mux)
@@ -254,6 +264,10 @@ func (s *Server) BuildMux() *http.ServeMux {
s.mediaServeHandler.RegisterRoutes(mux)
}
if s.apiKeysHandler != nil {
s.apiKeysHandler.RegisterRoutes(mux)
}
if s.activityHandler != nil {
s.activityHandler.RegisterRoutes(mux)
}
@@ -262,6 +276,11 @@ func (s *Server) BuildMux() *http.ServeMux {
s.usageHandler.RegisterRoutes(mux)
}
// API documentation (OpenAPI spec + Swagger UI)
if s.docsHandler != nil {
s.docsHandler.RegisterRoutes(mux)
}
// OAuth endpoints (available in all modes)
if s.oauthHandler != nil {
s.oauthHandler.RegisterRoutes(mux)
@@ -346,7 +365,8 @@ func bridgeContextMiddleware(gatewayToken string, next http.Handler) http.Handle
func tokenAuthMiddleware(token string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != token {
provided := strings.TrimPrefix(auth, "Bearer ")
if !strings.HasPrefix(auth, "Bearer ") || subtle.ConstantTimeCompare([]byte(provided), []byte(token)) != 1 {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
@@ -471,9 +491,18 @@ func (s *Server) SetBuiltinToolsHandler(h *httpapi.BuiltinToolsHandler) {
s.builtinToolsHandler = h
}
// SetSecureCLIHandler sets the secure CLI credential CRUD handler.
func (s *Server) SetSecureCLIHandler(h *httpapi.SecureCLIHandler) { s.secureCLIHandler = h }
// SetOAuthHandler sets the OAuth handler (available in all modes).
func (s *Server) SetOAuthHandler(h *httpapi.OAuthHandler) { s.oauthHandler = h }
// SetAPIKeysHandler sets the API key management handler.
func (s *Server) SetAPIKeysHandler(h *httpapi.APIKeysHandler) { s.apiKeysHandler = h }
// SetAPIKeyStore sets the API key store for token-based auth lookup.
func (s *Server) SetAPIKeyStore(st store.APIKeyStore) { s.apiKeyStore = st }
// SetFilesHandler sets the workspace file serving handler.
func (s *Server) SetFilesHandler(h *httpapi.FilesHandler) { s.filesHandler = h }
@@ -498,6 +527,9 @@ func (s *Server) SetActivityHandler(h *httpapi.ActivityHandler) { s.activityHand
// SetUsageHandler sets the usage analytics handler.
func (s *Server) SetUsageHandler(h *httpapi.UsageHandler) { s.usageHandler = h }
// SetDocsHandler sets the OpenAPI spec + Swagger UI handler.
func (s *Server) SetDocsHandler(h *httpapi.DocsHandler) { s.docsHandler = h }
// SetAgentStore sets the agent store for context injection in tools_invoke.
func (s *Server) SetAgentStore(as store.AgentStore) { s.agentStore = as }
+1 -14
View File
@@ -4,7 +4,6 @@ import (
"net/http"
"strconv"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -25,19 +24,7 @@ func (h *ActivityHandler) RegisterRoutes(mux *http.ServeMux) {
}
func (h *ActivityHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
locale := extractLocale(r)
ctx := store.WithLocale(r.Context(), locale)
r = r.WithContext(ctx)
next(w, r)
}
return requireAuth(h.token, "", next)
}
func (h *ActivityHandler) handleList(w http.ResponseWriter, r *http.Request) {
+1 -17
View File
@@ -66,23 +66,7 @@ func (h *AgentsHandler) RegisterRoutes(mux *http.ServeMux) {
}
func (h *AgentsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
// Inject user_id and locale into context
userID := extractUserID(r)
ctx := store.WithLocale(r.Context(), extractLocale(r))
if userID != "" {
ctx = store.WithUserID(ctx, userID)
}
r = r.WithContext(ctx)
next(w, r)
}
return requireAuth(h.token, "", next)
}
func (h *AgentsHandler) handleList(w http.ResponseWriter, r *http.Request) {
+98
View File
@@ -0,0 +1,98 @@
package http
import (
"context"
"sync"
"time"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// cacheEntry holds a cached API key lookup result.
type cacheEntry struct {
key *store.APIKeyData // nil = negative cache (key not found)
role permissions.Role
fetchedAt time.Time
}
// maxNegativeCacheEntries caps the number of negative cache entries to prevent
// memory exhaustion from token spraying attacks.
const maxNegativeCacheEntries = 10000
// apiKeyCache is a TTL cache for API key lookups, invalidated via pubsub.
type apiKeyCache struct {
mu sync.RWMutex
entries map[string]*cacheEntry // keyed by SHA-256 hash
ttl time.Duration
store store.APIKeyStore
}
func newAPIKeyCache(s store.APIKeyStore, ttl time.Duration) *apiKeyCache {
return &apiKeyCache{
entries: make(map[string]*cacheEntry),
ttl: ttl,
store: s,
}
}
// get returns a cached entry if it exists and is not expired.
func (c *apiKeyCache) get(hash string) (*store.APIKeyData, permissions.Role, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
entry, ok := c.entries[hash]
if !ok || time.Since(entry.fetchedAt) > c.ttl {
return nil, "", false
}
return entry.key, entry.role, true
}
// getOrFetch returns a cached entry or fetches from the store on cache miss.
func (c *apiKeyCache) getOrFetch(ctx context.Context, hash string) (*store.APIKeyData, permissions.Role) {
if key, role, ok := c.get(hash); ok {
return key, role
}
// Cache miss — fetch from DB
keyData, err := c.store.GetByHash(ctx, hash)
if err != nil || keyData == nil {
// Negative cache: avoid repeated DB misses for invalid tokens.
// Cap size to prevent memory exhaustion from token spraying.
c.mu.Lock()
if len(c.entries) < maxNegativeCacheEntries {
c.entries[hash] = &cacheEntry{fetchedAt: time.Now()}
}
c.mu.Unlock()
return nil, ""
}
scopes := make([]permissions.Scope, len(keyData.Scopes))
for i, s := range keyData.Scopes {
scopes[i] = permissions.Scope(s)
}
role := permissions.RoleFromScopes(scopes)
c.mu.Lock()
c.entries[hash] = &cacheEntry{
key: keyData,
role: role,
fetchedAt: time.Now(),
}
c.mu.Unlock()
// Touch last-used in background (fire-and-forget with timeout)
go func() {
tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
c.store.TouchLastUsed(tctx, keyData.ID)
}()
return keyData, role
}
// invalidateAll clears all cached entries. Called on pubsub cache.invalidate events.
func (c *apiKeyCache) invalidateAll() {
c.mu.Lock()
defer c.mu.Unlock()
c.entries = make(map[string]*cacheEntry)
}
+215
View File
@@ -0,0 +1,215 @@
package http
import (
"context"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// mockAPIKeyStore is a minimal mock for testing the cache layer.
type mockAPIKeyStore struct {
mu sync.Mutex
keys map[string]*store.APIKeyData // hash → key
calls int // GetByHash call count
touchedID uuid.UUID // last TouchLastUsed ID
}
func newMockAPIKeyStore() *mockAPIKeyStore {
return &mockAPIKeyStore{keys: make(map[string]*store.APIKeyData)}
}
func (m *mockAPIKeyStore) GetByHash(_ context.Context, hash string) (*store.APIKeyData, error) {
m.mu.Lock()
m.calls++
m.mu.Unlock()
if k, ok := m.keys[hash]; ok {
return k, nil
}
return nil, nil
}
func (m *mockAPIKeyStore) TouchLastUsed(_ context.Context, id uuid.UUID) error {
m.mu.Lock()
m.touchedID = id
m.mu.Unlock()
return nil
}
func (m *mockAPIKeyStore) Create(_ context.Context, _ *store.APIKeyData) error { return nil }
func (m *mockAPIKeyStore) List(_ context.Context) ([]store.APIKeyData, error) { return nil, nil }
func (m *mockAPIKeyStore) Revoke(_ context.Context, _ uuid.UUID) error { return nil }
func (m *mockAPIKeyStore) Delete(_ context.Context, _ uuid.UUID) error { return nil }
func (m *mockAPIKeyStore) getCalls() int {
m.mu.Lock()
defer m.mu.Unlock()
return m.calls
}
func TestCacheMissFetchesFromStore(t *testing.T) {
ms := newMockAPIKeyStore()
keyID := uuid.New()
ms.keys["hash123"] = &store.APIKeyData{
ID: keyID,
Scopes: []string{"operator.read"},
}
c := newAPIKeyCache(ms, 5*time.Minute)
key, role := c.getOrFetch(context.Background(), "hash123")
if key == nil {
t.Fatal("expected key, got nil")
}
if key.ID != keyID {
t.Errorf("key ID = %v, want %v", key.ID, keyID)
}
if role != permissions.RoleViewer {
t.Errorf("role = %v, want %v", role, permissions.RoleViewer)
}
if ms.getCalls() != 1 {
t.Errorf("store calls = %d, want 1", ms.getCalls())
}
}
func TestCacheHitReturnsWithoutStoreCall(t *testing.T) {
ms := newMockAPIKeyStore()
keyID := uuid.New()
ms.keys["hash456"] = &store.APIKeyData{
ID: keyID,
Scopes: []string{"operator.admin"},
}
c := newAPIKeyCache(ms, 5*time.Minute)
// First call: cache miss → store fetch
c.getOrFetch(context.Background(), "hash456")
// Second call: cache hit → no store fetch
key, role := c.getOrFetch(context.Background(), "hash456")
if key == nil {
t.Fatal("expected key on cache hit, got nil")
}
if role != permissions.RoleAdmin {
t.Errorf("role = %v, want %v", role, permissions.RoleAdmin)
}
if ms.getCalls() != 1 {
t.Errorf("store calls = %d, want 1 (cache hit should not call store)", ms.getCalls())
}
}
func TestCacheTTLExpiry(t *testing.T) {
ms := newMockAPIKeyStore()
ms.keys["hash789"] = &store.APIKeyData{
ID: uuid.New(),
Scopes: []string{"operator.write"},
}
c := newAPIKeyCache(ms, 10*time.Millisecond) // very short TTL
// First fetch
c.getOrFetch(context.Background(), "hash789")
if ms.getCalls() != 1 {
t.Fatalf("initial calls = %d, want 1", ms.getCalls())
}
// Wait for TTL to expire
time.Sleep(20 * time.Millisecond)
// Should re-fetch from store
key, _ := c.getOrFetch(context.Background(), "hash789")
if key == nil {
t.Fatal("expected key after TTL expiry")
}
if ms.getCalls() != 2 {
t.Errorf("store calls after TTL = %d, want 2", ms.getCalls())
}
}
func TestCacheInvalidateAll(t *testing.T) {
ms := newMockAPIKeyStore()
ms.keys["hashA"] = &store.APIKeyData{
ID: uuid.New(),
Scopes: []string{"operator.read"},
}
c := newAPIKeyCache(ms, 5*time.Minute)
// Populate cache
c.getOrFetch(context.Background(), "hashA")
if ms.getCalls() != 1 {
t.Fatalf("initial calls = %d, want 1", ms.getCalls())
}
// Invalidate
c.invalidateAll()
// Should fetch again from store
c.getOrFetch(context.Background(), "hashA")
if ms.getCalls() != 2 {
t.Errorf("store calls after invalidate = %d, want 2", ms.getCalls())
}
}
func TestCacheNegativeCache(t *testing.T) {
ms := newMockAPIKeyStore()
// No keys in store
c := newAPIKeyCache(ms, 5*time.Minute)
// First lookup: cache miss → store returns nil → negative cache
key1, _ := c.getOrFetch(context.Background(), "unknown")
if key1 != nil {
t.Fatal("expected nil key for unknown hash")
}
if ms.getCalls() != 1 {
t.Fatalf("initial calls = %d, want 1", ms.getCalls())
}
// Second lookup: negative cache hit → no store call
key2, _ := c.getOrFetch(context.Background(), "unknown")
if key2 != nil {
t.Fatal("expected nil key on negative cache hit")
}
if ms.getCalls() != 1 {
t.Errorf("store calls after negative cache = %d, want 1", ms.getCalls())
}
}
func TestCacheConcurrentAccess(t *testing.T) {
ms := newMockAPIKeyStore()
ms.keys["concurrent"] = &store.APIKeyData{
ID: uuid.New(),
Scopes: []string{"operator.admin"},
}
c := newAPIKeyCache(ms, 5*time.Minute)
var wg sync.WaitGroup
for range 50 {
wg.Add(1)
go func() {
defer wg.Done()
key, role := c.getOrFetch(context.Background(), "concurrent")
if key == nil {
t.Error("expected key, got nil")
}
if role != permissions.RoleAdmin {
t.Errorf("role = %v, want admin", role)
}
}()
}
wg.Wait()
// Store should have been called at least once, but not 50 times
// (cache may have been populated after the first call)
calls := ms.getCalls()
if calls == 0 || calls > 50 {
t.Errorf("store calls = %d, want 1..50", calls)
}
}
+162
View File
@@ -0,0 +1,162 @@
package http
import (
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// APIKeysHandler handles API key management endpoints.
type APIKeysHandler struct {
apiKeys store.APIKeyStore
token string // gateway token for admin auth
msgBus *bus.MessageBus // for cache invalidation events
}
// NewAPIKeysHandler creates a handler for API key management endpoints.
func NewAPIKeysHandler(apiKeys store.APIKeyStore, token string, msgBus *bus.MessageBus) *APIKeysHandler {
return &APIKeysHandler{apiKeys: apiKeys, token: token, msgBus: msgBus}
}
// RegisterRoutes registers all API key management routes on the given mux.
func (h *APIKeysHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/api-keys", h.adminAuth(h.handleList))
mux.HandleFunc("POST /v1/api-keys", h.adminAuth(h.handleCreate))
mux.HandleFunc("POST /v1/api-keys/{id}/revoke", h.adminAuth(h.handleRevoke))
}
// adminAuth ensures the caller has admin access (gateway token or API key with admin scope).
func (h *APIKeysHandler) adminAuth(next http.HandlerFunc) http.HandlerFunc {
return requireAuth(h.token, permissions.RoleAdmin, next)
}
func (h *APIKeysHandler) handleList(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
keys, err := h.apiKeys.List(r.Context())
if err != nil {
slog.Error("api_keys.list failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "API keys")})
return
}
if keys == nil {
keys = []store.APIKeyData{}
}
writeJSON(w, http.StatusOK, keys)
}
func (h *APIKeysHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
var input struct {
Name string `json:"name"`
Scopes []string `json:"scopes"`
ExpiresIn *int `json:"expires_in"` // seconds; nil = never
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if input.Name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name")})
return
}
if len(input.Name) > 100 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "name must be 100 characters or less")})
return
}
if len(input.Scopes) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "scopes")})
return
}
// Validate scopes
for _, s := range input.Scopes {
if !permissions.ValidScope(s) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "invalid scope: "+s)})
return
}
}
raw, hash, prefix, err := crypto.GenerateAPIKey()
if err != nil {
slog.Error("api_keys.generate failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "key generation")})
return
}
now := time.Now()
key := &store.APIKeyData{
ID: store.GenNewID(),
Name: input.Name,
Prefix: prefix,
KeyHash: hash,
Scopes: input.Scopes,
CreatedBy: extractUserID(r),
CreatedAt: now,
UpdatedAt: now,
}
if input.ExpiresIn != nil && *input.ExpiresIn > 0 {
exp := now.Add(time.Duration(*input.ExpiresIn) * time.Second)
key.ExpiresAt = &exp
}
if err := h.apiKeys.Create(r.Context(), key); err != nil {
slog.Error("api_keys.create failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "API key", "internal error")})
return
}
h.emitCacheInvalidate("api_keys", key.ID.String())
// Return key with raw secret (shown only once)
writeJSON(w, http.StatusCreated, map[string]any{
"id": key.ID,
"name": key.Name,
"prefix": key.Prefix,
"key": raw, // shown only once!
"scopes": key.Scopes,
"expires_at": key.ExpiresAt,
"created_at": key.CreatedAt,
})
}
func (h *APIKeysHandler) handleRevoke(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
idStr := r.PathValue("id")
id, err := uuid.Parse(idStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "API key")})
return
}
if err := h.apiKeys.Revoke(r.Context(), id); err != nil {
slog.Error("api_keys.revoke failed", "error", err, "id", idStr)
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "API key", idStr)})
return
}
h.emitCacheInvalidate("api_keys", idStr)
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
func (h *APIKeysHandler) emitCacheInvalidate(kind, key string) {
if h.msgBus == nil {
return
}
h.msgBus.Broadcast(bus.Event{
Name: protocol.EventCacheInvalidate,
Payload: bus.CacheInvalidatePayload{Kind: kind, Key: key},
})
}
+134
View File
@@ -1,12 +1,17 @@
package http
import (
"context"
"crypto/subtle"
"log/slog"
"net/http"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -68,6 +73,135 @@ func extractAgentID(r *http.Request, model string) string {
return "default"
}
// --- Package-level API key cache for shared auth ---
var pkgAPIKeyCache *apiKeyCache
// InitAPIKeyCache initializes the shared API key cache with TTL and pubsub invalidation.
// Must be called once during server startup before handling requests.
func InitAPIKeyCache(s store.APIKeyStore, mb *bus.MessageBus) {
pkgAPIKeyCache = newAPIKeyCache(s, 5*time.Minute)
if mb != nil {
mb.Subscribe("http-api-key-cache", func(e bus.Event) {
if p, ok := e.Payload.(bus.CacheInvalidatePayload); ok && p.Kind == bus.CacheKindAPIKeys {
pkgAPIKeyCache.invalidateAll()
}
})
}
}
// ResolveAPIKey checks if the bearer token is a valid API key using the shared cache.
// Returns the key data and derived role, or nil if not found/expired/revoked.
func ResolveAPIKey(ctx context.Context, token string) (*store.APIKeyData, permissions.Role) {
if pkgAPIKeyCache == nil || token == "" {
return nil, ""
}
hash := crypto.HashAPIKey(token)
return pkgAPIKeyCache.getOrFetch(ctx, hash)
}
// authResult holds the resolved authentication state for an HTTP request.
type authResult struct {
Role permissions.Role
Authenticated bool
}
// resolveAuth determines the caller's role from the request.
// Priority: gateway token → API key → no-auth fallback.
func resolveAuth(r *http.Request, gatewayToken string) authResult {
return resolveAuthBearer(r, gatewayToken, extractBearerToken(r))
}
// resolveAuthBearer is like resolveAuth but accepts a pre-extracted bearer token.
// Useful for handlers that also accept tokens from query params.
func resolveAuthBearer(r *http.Request, gatewayToken, bearer string) authResult {
// Gateway token → admin
if gatewayToken != "" && tokenMatch(bearer, gatewayToken) {
return authResult{Role: permissions.RoleAdmin, Authenticated: true}
}
// API key → role from scopes
if _, role := ResolveAPIKey(r.Context(), bearer); role != "" {
return authResult{Role: role, Authenticated: true}
}
// No auth configured → operator (backward compat)
if gatewayToken == "" {
return authResult{Role: permissions.RoleOperator, Authenticated: true}
}
return authResult{}
}
// httpMinRole returns the minimum role required for an HTTP endpoint based on HTTP method.
func httpMinRole(method string) permissions.Role {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return permissions.RoleViewer
default: // POST, PUT, PATCH, DELETE
return permissions.RoleOperator
}
}
// requireAuth is a middleware that checks authentication and minimum role.
// Pass "" for minRole to auto-detect from HTTP method (GET→Viewer, POST→Operator).
// Injects locale and userID into request context.
func requireAuth(token string, minRole permissions.Role, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
auth := resolveAuth(r, token)
if !auth.Authenticated {
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": i18n.T(locale, i18n.MsgUnauthorized),
})
return
}
required := minRole
if required == "" {
required = httpMinRole(r.Method)
}
if !permissions.HasMinRole(auth.Role, required) {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": i18n.T(locale, i18n.MsgPermissionDenied, r.URL.Path),
})
return
}
ctx := store.WithLocale(r.Context(), locale)
if userID := extractUserID(r); userID != "" {
ctx = store.WithUserID(ctx, userID)
}
next(w, r.WithContext(ctx))
}
}
// requireAuthBearer is like requireAuth but accepts a pre-extracted bearer token.
// Used by handlers that accept tokens from query params (files, media).
func requireAuthBearer(token string, minRole permissions.Role, bearer string, w http.ResponseWriter, r *http.Request) bool {
locale := extractLocale(r)
auth := resolveAuthBearer(r, token, bearer)
if !auth.Authenticated {
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": i18n.T(locale, i18n.MsgUnauthorized),
})
return false
}
required := minRole
if required == "" {
required = httpMinRole(r.Method)
}
if !permissions.HasMinRole(auth.Role, required) {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": i18n.T(locale, i18n.MsgPermissionDenied, r.URL.Path),
})
return false
}
return true
}
// extractLocale parses the Accept-Language header and returns a supported locale.
// Falls back to "en" if no supported language is found.
func extractLocale(r *http.Request) string {
+315
View File
@@ -0,0 +1,315 @@
package http
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/google/uuid"
)
// setupTestCache initializes the package-level cache for testing.
// Returns a cleanup function to restore state.
func setupTestCache(t *testing.T, keys map[string]*store.APIKeyData) *mockAPIKeyStore {
t.Helper()
ms := newMockAPIKeyStore()
for hash, key := range keys {
ms.keys[hash] = key
}
pkgAPIKeyCache = newAPIKeyCache(ms, 5*time.Minute)
t.Cleanup(func() { pkgAPIKeyCache = nil })
return ms
}
func TestResolveAuth_GatewayToken(t *testing.T) {
setupTestCache(t, nil)
r := httptest.NewRequest("GET", "/v1/agents", nil)
r.Header.Set("Authorization", "Bearer my-gateway-token")
auth := resolveAuth(r, "my-gateway-token")
if !auth.Authenticated {
t.Fatal("expected authenticated")
}
if auth.Role != permissions.RoleAdmin {
t.Errorf("role = %v, want admin", auth.Role)
}
}
func TestResolveAuth_WrongToken(t *testing.T) {
setupTestCache(t, nil)
r := httptest.NewRequest("GET", "/v1/agents", nil)
r.Header.Set("Authorization", "Bearer wrong-token")
auth := resolveAuth(r, "correct-token")
if auth.Authenticated {
t.Fatal("expected unauthenticated for wrong token")
}
}
func TestResolveAuth_NoAuthConfigured(t *testing.T) {
setupTestCache(t, nil)
r := httptest.NewRequest("GET", "/v1/agents", nil)
auth := resolveAuth(r, "") // no gateway token configured
if !auth.Authenticated {
t.Fatal("expected authenticated when no token configured")
}
if auth.Role != permissions.RoleOperator {
t.Errorf("role = %v, want operator (backward compat)", auth.Role)
}
}
func TestResolveAuth_APIKeyReadScope(t *testing.T) {
// We need to hash the token the same way crypto.HashAPIKey does
// For testing, we'll inject directly into the cache
keyID := uuid.New()
ms := newMockAPIKeyStore()
ms.keys["test-hash"] = &store.APIKeyData{
ID: keyID,
Scopes: []string{"operator.read"},
}
pkgAPIKeyCache = newAPIKeyCache(ms, 5*time.Minute)
defer func() { pkgAPIKeyCache = nil }()
// Pre-populate cache directly for the hash
pkgAPIKeyCache.getOrFetch(nil, "test-hash")
// Now test via resolveAuthBearer with the hash lookup
r := httptest.NewRequest("GET", "/v1/agents", nil)
// Directly test with the resolved key
key, role := pkgAPIKeyCache.getOrFetch(nil, "test-hash")
if key == nil {
t.Fatal("expected key from cache")
}
_ = r
if role != permissions.RoleViewer {
t.Errorf("role = %v, want viewer for read scope", role)
}
}
func TestResolveAuth_APIKeyAdminScope(t *testing.T) {
ms := newMockAPIKeyStore()
ms.keys["admin-hash"] = &store.APIKeyData{
ID: uuid.New(),
Scopes: []string{"operator.admin"},
}
pkgAPIKeyCache = newAPIKeyCache(ms, 5*time.Minute)
defer func() { pkgAPIKeyCache = nil }()
key, role := pkgAPIKeyCache.getOrFetch(nil, "admin-hash")
if key == nil {
t.Fatal("expected key from cache")
}
if role != permissions.RoleAdmin {
t.Errorf("role = %v, want admin", role)
}
}
func TestResolveAuth_APIKeyWriteScope(t *testing.T) {
ms := newMockAPIKeyStore()
ms.keys["write-hash"] = &store.APIKeyData{
ID: uuid.New(),
Scopes: []string{"operator.write"},
}
pkgAPIKeyCache = newAPIKeyCache(ms, 5*time.Minute)
defer func() { pkgAPIKeyCache = nil }()
key, role := pkgAPIKeyCache.getOrFetch(nil, "write-hash")
if key == nil {
t.Fatal("expected key from cache")
}
if role != permissions.RoleOperator {
t.Errorf("role = %v, want operator for write scope", role)
}
}
func TestHttpMinRole(t *testing.T) {
tests := []struct {
method string
want permissions.Role
}{
{http.MethodGet, permissions.RoleViewer},
{http.MethodHead, permissions.RoleViewer},
{http.MethodOptions, permissions.RoleViewer},
{http.MethodPost, permissions.RoleOperator},
{http.MethodPut, permissions.RoleOperator},
{http.MethodPatch, permissions.RoleOperator},
{http.MethodDelete, permissions.RoleOperator},
}
for _, tt := range tests {
got := httpMinRole(tt.method)
if got != tt.want {
t.Errorf("httpMinRole(%s) = %v, want %v", tt.method, got, tt.want)
}
}
}
func TestRequireAuth_Unauthorized(t *testing.T) {
setupTestCache(t, nil)
handler := requireAuth("secret", "", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
r := httptest.NewRequest("GET", "/v1/agents", nil)
w := httptest.NewRecorder()
handler(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
func TestRequireAuth_GatewayTokenPasses(t *testing.T) {
setupTestCache(t, nil)
handler := requireAuth("secret", "", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
r := httptest.NewRequest("GET", "/v1/agents", nil)
r.Header.Set("Authorization", "Bearer secret")
w := httptest.NewRecorder()
handler(w, r)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestRequireAuth_InjectLocaleAndUserID(t *testing.T) {
setupTestCache(t, nil)
var gotLocale, gotUserID string
handler := requireAuth("secret", "", func(w http.ResponseWriter, r *http.Request) {
gotLocale = store.LocaleFromContext(r.Context())
gotUserID = store.UserIDFromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
r := httptest.NewRequest("GET", "/v1/agents", nil)
r.Header.Set("Authorization", "Bearer secret")
r.Header.Set("Accept-Language", "vi")
r.Header.Set("X-GoClaw-User-Id", "user123")
w := httptest.NewRecorder()
handler(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if gotLocale != "vi" {
t.Errorf("locale = %q, want 'vi'", gotLocale)
}
if gotUserID != "user123" {
t.Errorf("userID = %q, want 'user123'", gotUserID)
}
}
func TestRequireAuth_AdminRoleEnforced(t *testing.T) {
// No auth configured → operator role (backward compat)
setupTestCache(t, nil)
handler := requireAuth("", permissions.RoleAdmin, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
r := httptest.NewRequest("POST", "/v1/api-keys", nil)
w := httptest.NewRecorder()
handler(w, r)
// No token configured → operator role, but admin required → 403
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403 (operator cannot access admin endpoint)", w.Code)
}
}
func TestRequireAuth_AutoDetectRole_GET(t *testing.T) {
// No auth configured → operator role. GET needs viewer → passes.
setupTestCache(t, nil)
handler := requireAuth("", "", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
r := httptest.NewRequest("GET", "/v1/agents", nil)
w := httptest.NewRecorder()
handler(w, r)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200 (operator can access viewer endpoint)", w.Code)
}
}
func TestInitAPIKeyCache_PubsubInvalidation(t *testing.T) {
mb := bus.New()
ms := newMockAPIKeyStore()
ms.keys["pubsub-hash"] = &store.APIKeyData{
ID: uuid.New(),
Scopes: []string{"operator.read"},
}
// Save original and restore after test
origCache := pkgAPIKeyCache
defer func() { pkgAPIKeyCache = origCache }()
InitAPIKeyCache(ms, mb)
// Populate cache
key, _ := pkgAPIKeyCache.getOrFetch(nil, "pubsub-hash")
if key == nil {
t.Fatal("expected key after initial fetch")
}
if ms.getCalls() != 1 {
t.Fatalf("calls = %d, want 1", ms.getCalls())
}
// Broadcast cache invalidation
mb.Broadcast(bus.Event{
Name: "cache.invalidate",
Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindAPIKeys, Key: "any"},
})
// Cache should be cleared, next fetch should hit store
pkgAPIKeyCache.getOrFetch(nil, "pubsub-hash")
if ms.getCalls() != 2 {
t.Errorf("calls after invalidation = %d, want 2", ms.getCalls())
}
}
func TestInitAPIKeyCache_IgnoresOtherKinds(t *testing.T) {
mb := bus.New()
ms := newMockAPIKeyStore()
ms.keys["other-hash"] = &store.APIKeyData{
ID: uuid.New(),
Scopes: []string{"operator.read"},
}
origCache := pkgAPIKeyCache
defer func() { pkgAPIKeyCache = origCache }()
InitAPIKeyCache(ms, mb)
// Populate cache
pkgAPIKeyCache.getOrFetch(nil, "other-hash")
// Broadcast a different kind
mb.Broadcast(bus.Event{
Name: "cache.invalidate",
Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindAgent, Key: "any"},
})
// Cache should NOT be cleared
pkgAPIKeyCache.getOrFetch(nil, "other-hash")
if ms.getCalls() != 1 {
t.Errorf("calls = %d, want 1 (non-api_keys kind should not invalidate)", ms.getCalls())
}
}
+1 -10
View File
@@ -32,16 +32,7 @@ func (h *BuiltinToolsHandler) RegisterRoutes(mux *http.ServeMux) {
}
func (h *BuiltinToolsHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
next(w, r)
}
return requireAuth(h.token, "", next)
}
func (h *BuiltinToolsHandler) emitCacheInvalidate(key string) {
+1 -16
View File
@@ -53,22 +53,7 @@ func (h *ChannelInstancesHandler) RegisterRoutes(mux *http.ServeMux) {
}
func (h *ChannelInstancesHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
userID := extractUserID(r)
ctx := store.WithLocale(r.Context(), extractLocale(r))
if userID != "" {
ctx = store.WithUserID(ctx, userID)
}
r = r.WithContext(ctx)
next(w, r)
}
return requireAuth(h.token, "", next)
}
func (h *ChannelInstancesHandler) emitCacheInvalidate() {
+8 -2
View File
@@ -11,6 +11,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/sessions"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -82,11 +83,16 @@ func (h *ChatCompletionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
return
}
// Auth check (timing-safe comparison)
if !tokenMatch(extractBearerToken(r), h.token) {
// Auth + RBAC check (gateway token or API key, operator required for POST)
auth := resolveAuth(r, h.token)
if !auth.Authenticated {
http.Error(w, fmt.Sprintf(`{"error":{"message":"%s","type":"invalid_request_error"}}`, i18n.T(locale, i18n.MsgInvalidAuth)), http.StatusUnauthorized)
return
}
if !permissions.HasMinRole(auth.Role, permissions.RoleOperator) {
http.Error(w, fmt.Sprintf(`{"error":{"message":"%s","type":"invalid_request_error"}}`, i18n.T(locale, i18n.MsgPermissionDenied, "/v1/chat/completions")), http.StatusForbidden)
return
}
// Rate limit check (per IP or bearer token)
if h.rateLimiter != nil {
+1 -16
View File
@@ -38,22 +38,7 @@ func (h *CustomToolsHandler) RegisterRoutes(mux *http.ServeMux) {
}
func (h *CustomToolsHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
userID := extractUserID(r)
ctx := store.WithLocale(r.Context(), extractLocale(r))
if userID != "" {
ctx = store.WithUserID(ctx, userID)
}
r = r.WithContext(ctx)
next(w, r)
}
return requireAuth(h.token, "", next)
}
func (h *CustomToolsHandler) emitCacheInvalidate(key string) {
+1 -13
View File
@@ -26,19 +26,7 @@ func (h *DelegationsHandler) RegisterRoutes(mux *http.ServeMux) {
}
func (h *DelegationsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
locale := extractLocale(r)
ctx := store.WithLocale(r.Context(), locale)
r = r.WithContext(ctx)
next(w, r)
}
return requireAuth(h.token, "", next)
}
func (h *DelegationsHandler) handleList(w http.ResponseWriter, r *http.Request) {
+7 -11
View File
@@ -29,17 +29,13 @@ func (h *FilesHandler) RegisterRoutes(mux *http.ServeMux) {
func (h *FilesHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
// Accept token via Bearer header or ?token= query param (for <img src>).
provided := extractBearerToken(r)
if provided == "" {
provided = r.URL.Query().Get("token")
}
if provided != h.token {
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
// Accept token via Bearer header or ?token= query param (for <img src>).
provided := extractBearerToken(r)
if provided == "" {
provided = r.URL.Query().Get("token")
}
if !requireAuthBearer(h.token, "", provided, w, r) {
return
}
next(w, r)
}
+1 -9
View File
@@ -45,13 +45,5 @@ func (h *KnowledgeGraphHandler) RegisterRoutes(mux *http.ServeMux) {
}
func (h *KnowledgeGraphHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
}
next(w, r)
}
return requireAuth(h.token, "", next)
}
+1 -16
View File
@@ -73,22 +73,7 @@ func (h *MCPHandler) RegisterRoutes(mux *http.ServeMux) {
}
func (h *MCPHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
userID := extractUserID(r)
ctx := store.WithLocale(r.Context(), extractLocale(r))
if userID != "" {
ctx = store.WithUserID(ctx, userID)
}
r = r.WithContext(ctx)
next(w, r)
}
return requireAuth(h.token, "", next)
}
// --- Server CRUD ---
+7 -10
View File
@@ -29,16 +29,13 @@ func (h *MediaServeHandler) RegisterRoutes(mux *http.ServeMux) {
func (h *MediaServeHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
provided := extractBearerToken(r)
if provided == "" {
provided = r.URL.Query().Get("token")
}
if provided != h.token {
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
// Accept token via Bearer header or ?token= query param (for <img src>).
provided := extractBearerToken(r)
if provided == "" {
provided = r.URL.Query().Get("token")
}
if !requireAuthBearer(h.token, "", provided, w, r) {
return
}
next(w, r)
}
+1 -11
View File
@@ -34,17 +34,7 @@ func (h *MediaUploadHandler) RegisterRoutes(mux *http.ServeMux) {
}
func (h *MediaUploadHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
provided := extractBearerToken(r)
if provided != h.token {
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
next(w, r)
}
return requireAuth(h.token, "", next)
}
func (h *MediaUploadHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
+1 -11
View File
@@ -3,7 +3,6 @@ package http
import (
"net/http"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -32,14 +31,5 @@ func (h *MemoryHandler) RegisterRoutes(mux *http.ServeMux) {
}
func (h *MemoryHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
next(w, r)
}
return requireAuth(h.token, "", next)
}
+1 -8
View File
@@ -49,14 +49,7 @@ func (h *OAuthHandler) RegisterRoutes(mux *http.ServeMux) {
}
func (h *OAuthHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !tokenMatch(extractBearerToken(r), h.token) {
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
next(w, r)
}
return requireAuth(h.token, "", next)
}
func (h *OAuthHandler) newTokenSource() *oauth.DBTokenSource {
+69
View File
@@ -0,0 +1,69 @@
package http
import (
_ "embed"
"net/http"
)
//go:embed openapi_spec.json
var openapiSpec []byte
// DocsHandler serves the OpenAPI spec and Swagger UI.
type DocsHandler struct {
token string
}
// NewDocsHandler creates a handler for API documentation endpoints.
func NewDocsHandler(token string) *DocsHandler {
return &DocsHandler{token: token}
}
// RegisterRoutes registers documentation routes on the given mux.
func (h *DocsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/openapi.json", h.handleSpec)
mux.HandleFunc("GET /docs", h.handleSwaggerUI)
mux.HandleFunc("GET /docs/", h.handleSwaggerUI)
}
func (h *DocsHandler) handleSpec(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Write(openapiSpec)
}
func (h *DocsHandler) handleSwaggerUI(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(swaggerHTML))
}
const swaggerHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GoClaw API Documentation</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.18.2/swagger-ui.css">
<style>
html { box-sizing: border-box; overflow-y: scroll; }
*, *:before, *:after { box-sizing: inherit; }
body { margin: 0; background: #fafafa; }
.topbar { display: none; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.18.2/swagger-ui-bundle.js"></script>
<script>
SwaggerUIBundle({
url: "/v1/openapi.json",
dom_id: "#swagger-ui",
deepLinking: true,
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
layout: "BaseLayout",
defaultModelsExpandDepth: 2,
defaultModelExpandDepth: 2,
tryItOutEnabled: true,
});
</script>
</body>
</html>`
+680
View File
@@ -0,0 +1,680 @@
{
"openapi": "3.0.3",
"info": {
"title": "GoClaw Gateway API",
"description": "PostgreSQL multi-tenant AI agent gateway with WebSocket RPC + HTTP API.\n\n## Authentication\n\nAll endpoints require a Bearer token in the `Authorization` header:\n\n```\nAuthorization: Bearer <gateway-token-or-api-key>\n```\n\nYou can use either the **gateway token** (grants admin access) or an **API key** created via the API Keys endpoints (grants scoped access).\n\nIf no token is configured on the server, authentication is disabled (backward compatibility).\n\n## Common Headers\n\n| Header | Description |\n|--------|-------------|\n| `X-GoClaw-User-Id` | External user ID for multi-tenant context |\n| `X-GoClaw-Agent-Id` | Target agent ID (alternative to model prefix) |\n| `Accept-Language` | Locale for error messages (`en`, `vi`, `zh`) |\n\n## WebSocket Protocol\n\nConnect via `POST /ws` (upgrade). Protocol v3 uses frame types: `req`, `res`, `event`.\nFirst request must be `connect` with `{\"token\": \"...\", \"user_id\": \"...\", \"locale\": \"en\"}`.",
"version": "0.2.0",
"contact": {
"name": "GoClaw",
"url": "https://github.com/nextlevelbuilder/goclaw"
}
},
"servers": [
{
"url": "/",
"description": "Current server"
}
],
"tags": [
{ "name": "Chat", "description": "OpenAI-compatible chat completions" },
{ "name": "API Keys", "description": "Gateway API key management (admin only)" },
{ "name": "Agents", "description": "Agent CRUD and configuration" },
{ "name": "Sessions", "description": "Chat session management (via WebSocket RPC)" },
{ "name": "Providers", "description": "LLM provider configuration" },
{ "name": "Skills", "description": "Skill management and grants" },
{ "name": "MCP Servers", "description": "MCP server configuration and grants" },
{ "name": "Custom Tools", "description": "Custom tool definitions" },
{ "name": "Built-in Tools", "description": "Built-in tool configuration" },
{ "name": "Memory", "description": "Agent memory (pgvector) management" },
{ "name": "Knowledge Graph", "description": "Entity knowledge graph" },
{ "name": "Channels", "description": "Channel instance management" },
{ "name": "Traces", "description": "LLM call tracing" },
{ "name": "Usage", "description": "Usage analytics" },
{ "name": "Activity", "description": "Audit activity log" },
{ "name": "Storage", "description": "Workspace file management" },
{ "name": "Media", "description": "Media upload and serving" },
{ "name": "System", "description": "Health check and system info" }
],
"security": [{ "bearerAuth": [] }],
"paths": {
"/health": {
"get": {
"tags": ["System"],
"summary": "Health check",
"security": [],
"responses": {
"200": {
"description": "Server health status",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": { "type": "string", "example": "ok" },
"uptime_ms": { "type": "integer" },
"mode": { "type": "string", "example": "managed" },
"database": { "type": "string", "example": "ok" }
}
}
}
}
}
}
}
},
"/v1/chat/completions": {
"post": {
"tags": ["Chat"],
"summary": "OpenAI-compatible chat completions",
"description": "Send messages to an agent and get a response. Compatible with OpenAI's chat completions API.\n\nUse the `model` field to target a specific agent: `goclaw:<agent-id>` or `agent:<agent-id>`.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["messages"],
"properties": {
"model": {
"type": "string",
"description": "Agent targeting: `goclaw:<id>` or `agent:<id>`. Defaults to `default`.",
"example": "goclaw:default"
},
"messages": {
"type": "array",
"items": {
"type": "object",
"required": ["role", "content"],
"properties": {
"role": { "type": "string", "enum": ["system", "user", "assistant"] },
"content": { "type": "string" }
}
}
},
"stream": { "type": "boolean", "default": false },
"user": { "type": "string", "description": "External user ID" }
}
}
}
}
},
"responses": {
"200": { "description": "Chat completion response (or SSE stream if stream=true)" },
"401": { "$ref": "#/components/responses/Unauthorized" },
"429": { "description": "Rate limit exceeded" }
}
}
},
"/v1/api-keys": {
"get": {
"tags": ["API Keys"],
"summary": "List all API keys",
"description": "Returns all API keys (active and revoked). Key hashes are never included in responses. Requires admin access.",
"responses": {
"200": {
"description": "List of API keys",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": { "$ref": "#/components/schemas/ApiKey" }
}
}
}
},
"401": { "$ref": "#/components/responses/Unauthorized" }
}
},
"post": {
"tags": ["API Keys"],
"summary": "Create a new API key",
"description": "Creates a new API key with the specified scopes. The raw key is returned **only once** in the response — store it securely.\n\nAvailable scopes:\n- `operator.admin` — Full admin access\n- `operator.read` — Read-only access\n- `operator.write` — Read + write access\n- `operator.approvals` — Manage exec approvals\n- `operator.pairing` — Manage device pairing",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["name", "scopes"],
"properties": {
"name": { "type": "string", "example": "ci-deploy" },
"scopes": {
"type": "array",
"items": { "type": "string", "enum": ["operator.admin", "operator.read", "operator.write", "operator.approvals", "operator.pairing"] },
"example": ["operator.read", "operator.write"]
},
"expires_in": {
"type": "integer",
"description": "Expiry in seconds. Omit or 0 for never.",
"example": 2592000
}
}
}
}
}
},
"responses": {
"201": {
"description": "API key created. The `key` field is shown only once.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"name": { "type": "string" },
"prefix": { "type": "string", "example": "goclaw_a" },
"key": { "type": "string", "description": "Raw API key (shown once)", "example": "goclaw_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" },
"scopes": { "type": "array", "items": { "type": "string" } },
"expires_at": { "type": "string", "format": "date-time", "nullable": true },
"created_at": { "type": "string", "format": "date-time" }
}
}
}
}
},
"400": { "$ref": "#/components/responses/BadRequest" },
"401": { "$ref": "#/components/responses/Unauthorized" }
}
}
},
"/v1/api-keys/{id}": {
"delete": {
"tags": ["API Keys"],
"summary": "Revoke an API key",
"description": "Revokes an API key. Revoked keys can no longer be used for authentication. This action cannot be undone.",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
],
"responses": {
"200": {
"description": "Key revoked",
"content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "example": "revoked" } } } } }
},
"401": { "$ref": "#/components/responses/Unauthorized" },
"404": { "$ref": "#/components/responses/NotFound" }
}
}
},
"/v1/agents": {
"get": {
"tags": ["Agents"],
"summary": "List agents",
"description": "List all agents accessible to the current user. Owners see all agents.",
"parameters": [
{ "name": "X-GoClaw-User-Id", "in": "header", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": { "description": "Agent list" },
"401": { "$ref": "#/components/responses/Unauthorized" }
}
},
"post": {
"tags": ["Agents"],
"summary": "Create agent",
"description": "Create a new agent with the specified configuration.",
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AgentInput" } } } },
"responses": {
"201": { "description": "Agent created" },
"400": { "$ref": "#/components/responses/BadRequest" },
"401": { "$ref": "#/components/responses/Unauthorized" }
}
}
},
"/v1/agents/{id}": {
"get": {
"tags": ["Agents"],
"summary": "Get agent",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
"responses": { "200": { "description": "Agent details" }, "404": { "$ref": "#/components/responses/NotFound" } }
},
"put": {
"tags": ["Agents"],
"summary": "Update agent",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AgentInput" } } } },
"responses": { "200": { "description": "Agent updated" }, "404": { "$ref": "#/components/responses/NotFound" } }
},
"delete": {
"tags": ["Agents"],
"summary": "Delete agent",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
"responses": { "200": { "description": "Agent deleted" }, "404": { "$ref": "#/components/responses/NotFound" } }
}
},
"/v1/agents/{id}/wake": {
"post": {
"tags": ["Agents"],
"summary": "Wake/trigger agent externally",
"description": "Trigger an agent run from an external webhook or automation.",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
"requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { "message": { "type": "string" }, "user_id": { "type": "string" } } } } } },
"responses": { "200": { "description": "Agent triggered" } }
}
},
"/v1/providers": {
"get": {
"tags": ["Providers"],
"summary": "List LLM providers",
"responses": { "200": { "description": "Provider list (API keys masked)" } }
},
"post": {
"tags": ["Providers"],
"summary": "Create LLM provider",
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProviderInput" } } } },
"responses": { "201": { "description": "Provider created" } }
}
},
"/v1/providers/{id}": {
"get": {
"tags": ["Providers"],
"summary": "Get provider",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
"responses": { "200": { "description": "Provider details (API key masked)" } }
},
"put": {
"tags": ["Providers"],
"summary": "Update provider",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
"responses": { "200": { "description": "Provider updated" } }
},
"delete": {
"tags": ["Providers"],
"summary": "Delete provider",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
"responses": { "200": { "description": "Provider deleted" } }
}
},
"/v1/providers/{id}/models": {
"get": {
"tags": ["Providers"],
"summary": "List available models",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
"responses": { "200": { "description": "Model list" } }
}
},
"/v1/providers/{id}/verify": {
"post": {
"tags": ["Providers"],
"summary": "Verify provider connection",
"description": "Test provider connectivity with a minimal LLM call.",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
"requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { "model": { "type": "string" } } } } } },
"responses": { "200": { "description": "Verification result", "content": { "application/json": { "schema": { "type": "object", "properties": { "valid": { "type": "boolean" }, "error": { "type": "string" } } } } } } }
}
},
"/v1/skills": {
"get": {
"tags": ["Skills"],
"summary": "List all skills",
"responses": { "200": { "description": "Skill list" } }
},
"post": {
"tags": ["Skills"],
"summary": "Upload skill",
"requestBody": { "content": { "multipart/form-data": {} } },
"responses": { "201": { "description": "Skill uploaded" } }
}
},
"/v1/skills/{id}": {
"get": {
"tags": ["Skills"],
"summary": "Get skill",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
"responses": { "200": { "description": "Skill details" } }
},
"put": {
"tags": ["Skills"],
"summary": "Update skill",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
"responses": { "200": { "description": "Skill updated" } }
},
"delete": {
"tags": ["Skills"],
"summary": "Delete skill",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
"responses": { "200": { "description": "Skill deleted" } }
}
},
"/v1/tools/custom": {
"get": {
"tags": ["Custom Tools"],
"summary": "List custom tools",
"responses": { "200": { "description": "Custom tool list" } }
},
"post": {
"tags": ["Custom Tools"],
"summary": "Create custom tool",
"responses": { "201": { "description": "Tool created" } }
}
},
"/v1/tools/custom/{id}": {
"get": {
"tags": ["Custom Tools"],
"summary": "Get custom tool",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
"responses": { "200": { "description": "Tool details" } }
},
"put": {
"tags": ["Custom Tools"],
"summary": "Update custom tool",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
"responses": { "200": { "description": "Tool updated" } }
},
"delete": {
"tags": ["Custom Tools"],
"summary": "Delete custom tool",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
"responses": { "200": { "description": "Tool deleted" } }
}
},
"/v1/tools/builtin": {
"get": {
"tags": ["Built-in Tools"],
"summary": "List built-in tools",
"responses": { "200": { "description": "Built-in tool list" } }
}
},
"/v1/tools/builtin/{name}": {
"get": {
"tags": ["Built-in Tools"],
"summary": "Get built-in tool",
"parameters": [{ "name": "name", "in": "path", "required": true, "schema": { "type": "string" } }],
"responses": { "200": { "description": "Tool details" } }
},
"put": {
"tags": ["Built-in Tools"],
"summary": "Update tool settings",
"parameters": [{ "name": "name", "in": "path", "required": true, "schema": { "type": "string" } }],
"responses": { "200": { "description": "Tool updated" } }
}
},
"/v1/mcp/servers": {
"get": {
"tags": ["MCP Servers"],
"summary": "List MCP servers",
"responses": { "200": { "description": "MCP server list" } }
},
"post": {
"tags": ["MCP Servers"],
"summary": "Create MCP server",
"responses": { "201": { "description": "Server created" } }
}
},
"/v1/mcp/servers/{id}": {
"get": {
"tags": ["MCP Servers"],
"summary": "Get MCP server",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
"responses": { "200": { "description": "Server details" } }
},
"put": {
"tags": ["MCP Servers"],
"summary": "Update MCP server",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
"responses": { "200": { "description": "Server updated" } }
},
"delete": {
"tags": ["MCP Servers"],
"summary": "Delete MCP server",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
"responses": { "200": { "description": "Server deleted" } }
}
},
"/v1/mcp/servers/{id}/tools": {
"get": {
"tags": ["MCP Servers"],
"summary": "List server tools",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
"responses": { "200": { "description": "Tool list" } }
}
},
"/v1/memory/documents": {
"get": {
"tags": ["Memory"],
"summary": "List all memory documents",
"responses": { "200": { "description": "Document list" } }
}
},
"/v1/agents/{agentID}/memory/search": {
"post": {
"tags": ["Memory"],
"summary": "Search agent memory",
"parameters": [{ "name": "agentID", "in": "path", "required": true, "schema": { "type": "string" } }],
"requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { "query": { "type": "string" }, "limit": { "type": "integer", "default": 10 } } } } } },
"responses": { "200": { "description": "Search results" } }
}
},
"/v1/agents/{agentID}/kg/entities": {
"get": {
"tags": ["Knowledge Graph"],
"summary": "List KG entities",
"parameters": [{ "name": "agentID", "in": "path", "required": true, "schema": { "type": "string" } }],
"responses": { "200": { "description": "Entity list" } }
},
"post": {
"tags": ["Knowledge Graph"],
"summary": "Upsert KG entity",
"parameters": [{ "name": "agentID", "in": "path", "required": true, "schema": { "type": "string" } }],
"responses": { "200": { "description": "Entity upserted" } }
}
},
"/v1/agents/{agentID}/kg/traverse": {
"post": {
"tags": ["Knowledge Graph"],
"summary": "Traverse knowledge graph",
"parameters": [{ "name": "agentID", "in": "path", "required": true, "schema": { "type": "string" } }],
"responses": { "200": { "description": "Traversal results" } }
}
},
"/v1/channels/instances": {
"get": {
"tags": ["Channels"],
"summary": "List channel instances",
"responses": { "200": { "description": "Instance list" } }
},
"post": {
"tags": ["Channels"],
"summary": "Create channel instance",
"responses": { "201": { "description": "Instance created" } }
}
},
"/v1/channels/instances/{id}": {
"get": {
"tags": ["Channels"],
"summary": "Get channel instance",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
"responses": { "200": { "description": "Instance details" } }
},
"put": {
"tags": ["Channels"],
"summary": "Update channel instance",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
"responses": { "200": { "description": "Instance updated" } }
},
"delete": {
"tags": ["Channels"],
"summary": "Delete channel instance",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
"responses": { "200": { "description": "Instance deleted" } }
}
},
"/v1/contacts": {
"get": {
"tags": ["Channels"],
"summary": "List channel contacts",
"responses": { "200": { "description": "Contact list" } }
}
},
"/v1/traces": {
"get": {
"tags": ["Traces"],
"summary": "List LLM traces",
"parameters": [
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } },
{ "name": "offset", "in": "query", "schema": { "type": "integer", "default": 0 } },
{ "name": "agent_id", "in": "query", "schema": { "type": "string" } }
],
"responses": { "200": { "description": "Trace list" } }
}
},
"/v1/traces/{traceID}": {
"get": {
"tags": ["Traces"],
"summary": "Get trace detail",
"parameters": [{ "name": "traceID", "in": "path", "required": true, "schema": { "type": "string" } }],
"responses": { "200": { "description": "Trace detail with messages" } }
}
},
"/v1/costs/summary": {
"get": {
"tags": ["Traces"],
"summary": "Get cost summary",
"responses": { "200": { "description": "Cost summary" } }
}
},
"/v1/usage/timeseries": {
"get": {
"tags": ["Usage"],
"summary": "Usage timeseries analytics",
"parameters": [
{ "name": "period", "in": "query", "schema": { "type": "string", "enum": ["1h", "24h", "7d", "30d"] } }
],
"responses": { "200": { "description": "Timeseries data" } }
}
},
"/v1/usage/summary": {
"get": {
"tags": ["Usage"],
"summary": "Usage summary",
"responses": { "200": { "description": "Usage summary" } }
}
},
"/v1/activity": {
"get": {
"tags": ["Activity"],
"summary": "List audit activity logs",
"parameters": [
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } },
{ "name": "offset", "in": "query", "schema": { "type": "integer", "default": 0 } }
],
"responses": { "200": { "description": "Activity log entries" } }
}
},
"/v1/delegations": {
"get": {
"tags": ["Activity"],
"summary": "List delegation history",
"responses": { "200": { "description": "Delegation list" } }
}
},
"/v1/storage/files": {
"get": {
"tags": ["Storage"],
"summary": "List workspace files",
"parameters": [
{ "name": "path", "in": "query", "schema": { "type": "string" }, "description": "Subdirectory path" }
],
"responses": { "200": { "description": "File listing" } }
}
},
"/v1/media/upload": {
"post": {
"tags": ["Media"],
"summary": "Upload media file",
"requestBody": { "content": { "multipart/form-data": { "schema": { "type": "object", "properties": { "file": { "type": "string", "format": "binary" } } } } } },
"responses": { "200": { "description": "Upload result with temp path and MIME type" } }
}
},
"/v1/media/{id}": {
"get": {
"tags": ["Media"],
"summary": "Serve media by ID",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
"responses": { "200": { "description": "Media file" } }
}
},
"/v1/pending-messages": {
"get": {
"tags": ["Channels"],
"summary": "List pending message groups",
"responses": { "200": { "description": "Message group list" } }
},
"delete": {
"tags": ["Channels"],
"summary": "Clear pending message group",
"responses": { "200": { "description": "Group cleared" } }
}
}
},
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"description": "Gateway token or API key"
}
},
"schemas": {
"ApiKey": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"name": { "type": "string" },
"prefix": { "type": "string", "description": "First 8 chars for identification" },
"scopes": { "type": "array", "items": { "type": "string" } },
"expires_at": { "type": "string", "format": "date-time", "nullable": true },
"last_used_at": { "type": "string", "format": "date-time", "nullable": true },
"revoked": { "type": "boolean" },
"created_by": { "type": "string" },
"created_at": { "type": "string", "format": "date-time" },
"updated_at": { "type": "string", "format": "date-time" }
}
},
"AgentInput": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "Unique agent slug" },
"display_name": { "type": "string" },
"agent_type": { "type": "string", "enum": ["open", "predefined"] },
"description": { "type": "string" },
"provider": { "type": "string", "description": "LLM provider name" },
"model": { "type": "string", "description": "Model ID" },
"system_prompt": { "type": "string" }
}
},
"ProviderInput": {
"type": "object",
"required": ["name", "provider_type"],
"properties": {
"name": { "type": "string", "description": "Unique provider slug" },
"display_name": { "type": "string" },
"provider_type": { "type": "string", "enum": ["anthropic_native", "openai_compat", "gemini_native", "groq", "deepseek", "mistral", "xai", "ollama"] },
"api_base": { "type": "string" },
"api_key": { "type": "string" },
"enabled": { "type": "boolean", "default": true }
}
},
"Error": {
"type": "object",
"properties": {
"error": { "type": "string" }
}
}
},
"responses": {
"Unauthorized": {
"description": "Authentication required or invalid token",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
},
"BadRequest": {
"description": "Invalid request parameters",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
},
"NotFound": {
"description": "Resource not found",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
}
}
}
}
+1 -13
View File
@@ -49,19 +49,7 @@ func (h *PendingMessagesHandler) RegisterRoutes(mux *http.ServeMux) {
}
func (h *PendingMessagesHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
locale := extractLocale(r)
ctx := store.WithLocale(r.Context(), locale)
r = r.WithContext(ctx)
next(w, r)
}
return requireAuth(h.token, "", next)
}
// GET /v1/pending-messages — list all groups with resolved titles
+1 -10
View File
@@ -77,16 +77,7 @@ func (h *ProvidersHandler) RegisterRoutes(mux *http.ServeMux) {
}
func (h *ProvidersHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
next(w, r)
}
return requireAuth(h.token, "", next)
}
// maskAPIKey replaces non-empty API keys with "***".
+8 -2
View File
@@ -10,6 +10,7 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/sessions"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -43,11 +44,16 @@ func (h *ResponsesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// Auth check (timing-safe comparison)
if !tokenMatch(extractBearerToken(r), h.token) {
// Auth + RBAC check (gateway token or API key, operator required for POST)
auth := resolveAuth(r, h.token)
if !auth.Authenticated {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
if !permissions.HasMinRole(auth.Role, permissions.RoleOperator) {
http.Error(w, `{"error":"permission denied: insufficient role"}`, http.StatusForbidden)
return
}
// Limit request body size to prevent DoS
const maxRequestBodySize = 1 << 20 // 1MB
+317
View File
@@ -0,0 +1,317 @@
package http
import (
"encoding/json"
"log/slog"
"net/http"
"regexp"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// SecureCLIHandler handles secure CLI binary credential CRUD endpoints.
type SecureCLIHandler struct {
store store.SecureCLIStore
token string
msgBus *bus.MessageBus
}
// NewSecureCLIHandler creates a handler for secure CLI credential management.
func NewSecureCLIHandler(s store.SecureCLIStore, token string, msgBus *bus.MessageBus) *SecureCLIHandler {
return &SecureCLIHandler{store: s, token: token, msgBus: msgBus}
}
// RegisterRoutes registers all secure CLI routes on the given mux.
func (h *SecureCLIHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/cli-credentials", h.auth(h.handleList))
mux.HandleFunc("POST /v1/cli-credentials", h.auth(h.handleCreate))
mux.HandleFunc("GET /v1/cli-credentials/presets", h.auth(h.handlePresets))
mux.HandleFunc("GET /v1/cli-credentials/{id}", h.auth(h.handleGet))
mux.HandleFunc("PUT /v1/cli-credentials/{id}", h.auth(h.handleUpdate))
mux.HandleFunc("DELETE /v1/cli-credentials/{id}", h.auth(h.handleDelete))
mux.HandleFunc("POST /v1/cli-credentials/{id}/test", h.auth(h.handleDryRun))
}
func (h *SecureCLIHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return requireAuth(h.token, permissions.RoleAdmin, next)
}
func (h *SecureCLIHandler) emitCacheInvalidate(key string) {
if h.msgBus == nil {
return
}
h.msgBus.Broadcast(bus.Event{
Name: protocol.EventCacheInvalidate,
Payload: bus.CacheInvalidatePayload{Kind: "secure_cli", Key: key},
})
}
func (h *SecureCLIHandler) handleList(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
result, err := h.store.List(r.Context())
if err != nil {
slog.Error("secure_cli.list", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "CLI credentials")})
return
}
// Mask encrypted env — never send raw credentials to UI
for i := range result {
result[i].EncryptedEnv = nil
}
writeJSON(w, http.StatusOK, map[string]any{"items": result})
}
// secureCLICreateRequest supports both preset-based and custom creation.
type secureCLICreateRequest struct {
Preset string `json:"preset,omitempty"` // auto-fill from preset
BinaryName string `json:"binary_name"`
BinaryPath *string `json:"binary_path,omitempty"`
Description string `json:"description"`
Env map[string]string `json:"env"` // plaintext env vars (encrypted by store)
DenyArgs json.RawMessage `json:"deny_args,omitempty"`
DenyVerbose json.RawMessage `json:"deny_verbose,omitempty"`
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
Tips string `json:"tips,omitempty"`
AgentID *uuid.UUID `json:"agent_id,omitempty"`
Enabled bool `json:"enabled"`
}
func (h *SecureCLIHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
var req secureCLICreateRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
// Apply preset defaults if specified
if req.Preset != "" {
preset := tools.GetPreset(req.Preset)
if preset == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown preset: " + req.Preset})
return
}
if req.BinaryName == "" {
req.BinaryName = preset.BinaryName
}
if req.Description == "" {
req.Description = preset.Description
}
if len(req.DenyArgs) == 0 {
req.DenyArgs, _ = json.Marshal(preset.DenyArgs)
}
if len(req.DenyVerbose) == 0 {
req.DenyVerbose, _ = json.Marshal(preset.DenyVerbose)
}
if req.TimeoutSeconds <= 0 {
req.TimeoutSeconds = preset.Timeout
}
if req.Tips == "" {
req.Tips = preset.Tips
}
}
if req.BinaryName == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "binary_name")})
return
}
if len(req.Env) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "env")})
return
}
// Serialize env as JSON bytes (store layer encrypts)
envJSON, err := json.Marshal(req.Env)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid env"})
return
}
b := &store.SecureCLIBinary{
BinaryName: req.BinaryName,
BinaryPath: req.BinaryPath,
Description: req.Description,
EncryptedEnv: envJSON,
DenyArgs: req.DenyArgs,
DenyVerbose: req.DenyVerbose,
TimeoutSeconds: req.TimeoutSeconds,
Tips: req.Tips,
AgentID: req.AgentID,
Enabled: req.Enabled,
CreatedBy: store.UserIDFromContext(r.Context()),
}
if b.TimeoutSeconds <= 0 {
b.TimeoutSeconds = 30
}
if err := h.store.Create(r.Context(), b); err != nil {
slog.Error("secure_cli.create", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
tools.ResetCredentialScrubValues() // clear stale scrub values
b.EncryptedEnv = nil // don't return credentials
emitAudit(h.msgBus, r, "secure_cli.created", "secure_cli", b.ID.String())
h.emitCacheInvalidate(b.ID.String())
writeJSON(w, http.StatusCreated, b)
}
func (h *SecureCLIHandler) handleGet(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "credential")})
return
}
b, err := h.store.Get(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "credential", id.String())})
return
}
b.EncryptedEnv = nil // don't expose credentials
writeJSON(w, http.StatusOK, b)
}
func (h *SecureCLIHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "credential")})
return
}
var updates map[string]any
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
// Allowlist of updatable fields to prevent column injection
allowed := map[string]bool{
"binary_name": true, "binary_path": true, "description": true,
"env": true, "deny_args": true, "deny_verbose": true,
"timeout_seconds": true, "tips": true, "agent_id": true, "enabled": true,
}
for k := range updates {
if !allowed[k] {
delete(updates, k)
}
}
// If env is updated, serialize as JSON string for the store encrypt path
if envVal, ok := updates["env"]; ok {
if envMap, isMap := envVal.(map[string]any); isMap {
envJSON, _ := json.Marshal(envMap)
updates["encrypted_env"] = string(envJSON)
delete(updates, "env")
}
}
if err := h.store.Update(r.Context(), id, updates); err != nil {
slog.Error("secure_cli.update", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
tools.ResetCredentialScrubValues() // clear stale scrub values
emitAudit(h.msgBus, r, "secure_cli.updated", "secure_cli", id.String())
h.emitCacheInvalidate(id.String())
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
}
func (h *SecureCLIHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "credential")})
return
}
if err := h.store.Delete(r.Context(), id); err != nil {
slog.Error("secure_cli.delete", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
tools.ResetCredentialScrubValues() // clear stale scrub values
emitAudit(h.msgBus, r, "secure_cli.deleted", "secure_cli", id.String())
h.emitCacheInvalidate(id.String())
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func (h *SecureCLIHandler) handlePresets(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"presets": tools.CLIPresets})
}
// dryRunRequest tests commands against deny patterns.
type dryRunRequest struct {
TestCommands []string `json:"test_commands"`
}
type dryRunResult struct {
Command string `json:"command"`
Allowed bool `json:"allowed"`
MatchedDeny *string `json:"matched_deny"`
}
func (h *SecureCLIHandler) handleDryRun(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "credential")})
return
}
b, err := h.store.Get(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "credential", id.String())})
return
}
var req dryRunRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
// Parse deny patterns from config
var denyArgs, denyVerbose []string
_ = json.Unmarshal(b.DenyArgs, &denyArgs)
_ = json.Unmarshal(b.DenyVerbose, &denyVerbose)
allPatterns := append(denyArgs, denyVerbose...)
results := make([]dryRunResult, 0, len(req.TestCommands))
for _, cmd := range req.TestCommands {
result := dryRunResult{Command: cmd, Allowed: true}
// Strip binary name prefix to get just the args portion
argsStr := cmd
if strings.HasPrefix(cmd, b.BinaryName+" ") {
argsStr = cmd[len(b.BinaryName)+1:]
}
for _, p := range allPatterns {
re, err := regexp.Compile(p)
if err != nil {
continue
}
if re.MatchString(argsStr) {
result.Allowed = false
result.MatchedDeny = &p
break
}
}
results = append(results, result)
}
writeJSON(w, http.StatusOK, map[string]any{"results": results})
}
+1 -16
View File
@@ -63,22 +63,7 @@ func (h *SkillsHandler) RegisterRoutes(mux *http.ServeMux) {
}
func (h *SkillsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
userID := extractUserID(r)
ctx := store.WithLocale(r.Context(), extractLocale(r))
if userID != "" {
ctx = store.WithUserID(ctx, userID)
}
r = r.WithContext(ctx)
next(w, r)
}
return requireAuth(h.token, "", next)
}
func (h *SkillsHandler) handleList(w http.ResponseWriter, r *http.Request) {
+1 -11
View File
@@ -47,17 +47,7 @@ func (h *StorageHandler) RegisterRoutes(mux *http.ServeMux) {
}
func (h *StorageHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
provided := extractBearerToken(r)
if !tokenMatch(provided, h.token) {
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
next(w, r)
}
return requireAuth(h.token, "", next)
}
// protectedDirs are top-level directories where deletion is blocked
+9 -5
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
@@ -46,11 +47,14 @@ func (h *ToolsInvokeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
if h.token != "" {
if extractBearerToken(r) != h.token {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
auth := resolveAuth(r, h.token)
if !auth.Authenticated {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
if !permissions.HasMinRole(auth.Role, permissions.RoleOperator) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, r.URL.Path)})
return
}
var req toolsInvokeRequest
+1 -13
View File
@@ -35,19 +35,7 @@ func (h *TracesHandler) RegisterRoutes(mux *http.ServeMux) {
}
func (h *TracesHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
locale := extractLocale(r)
ctx := store.WithLocale(r.Context(), locale)
r = r.WithContext(ctx)
next(w, r)
}
return requireAuth(h.token, "", next)
}
func (h *TracesHandler) handleList(w http.ResponseWriter, r *http.Request) {
+1 -11
View File
@@ -9,7 +9,6 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -31,16 +30,7 @@ func (h *UsageHandler) RegisterRoutes(mux *http.ServeMux) {
}
func (h *UsageHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
next(w, r)
}
return requireAuth(h.token, "", next)
}
func (h *UsageHandler) handleTimeSeries(w http.ResponseWriter, r *http.Request) {
+8 -2
View File
@@ -10,6 +10,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/sessions"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -53,11 +54,16 @@ type wakeUsage struct {
func (h *WakeHandler) handleWake(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
// Auth check
if h.token != "" && !tokenMatch(extractBearerToken(r), h.token) {
// Auth + RBAC check (gateway token or API key, operator required for POST)
auth := resolveAuth(r, h.token)
if !auth.Authenticated {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
if !permissions.HasMinRole(auth.Role, permissions.RoleOperator) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, r.URL.Path)})
return
}
agentID := r.PathValue("id")
if agentID == "" {
+25 -2
View File
@@ -39,6 +39,20 @@ const (
ScopePairing Scope = "operator.pairing"
)
// AllScopes is the set of all valid API key scopes.
var AllScopes = map[Scope]bool{
ScopeAdmin: true,
ScopeRead: true,
ScopeWrite: true,
ScopeApprovals: true,
ScopePairing: true,
}
// ValidScope reports whether s is a recognised API key scope.
func ValidScope(s string) bool {
return AllScopes[Scope(s)]
}
// PolicyEngine evaluates user permissions for gateway method access.
type PolicyEngine struct {
ownerIDs map[string]bool // sender IDs that are considered "owner"
@@ -94,8 +108,9 @@ func RoleFromScopes(scopes []Scope) Role {
if slices.Contains(scopes, ScopeAdmin) {
return RoleAdmin
}
hasWrite := slices.Contains(scopes, ScopeWrite)
if hasWrite {
if slices.Contains(scopes, ScopeWrite) ||
slices.Contains(scopes, ScopeApprovals) ||
slices.Contains(scopes, ScopePairing) {
return RoleOperator
}
if slices.Contains(scopes, ScopeRead) {
@@ -159,6 +174,9 @@ func isAdminMethod(method string) bool {
protocol.MethodTeamsTaskGet,
protocol.MethodTeamsTaskComments,
protocol.MethodTeamsTaskEvents,
protocol.MethodAPIKeysList,
protocol.MethodAPIKeysCreate,
protocol.MethodAPIKeysRevoke,
}
return slices.Contains(adminMethods, method)
}
@@ -194,6 +212,11 @@ func isWriteMethod(method string) bool {
return false
}
// HasMinRole checks if the given role meets the minimum required level.
func HasMinRole(role, required Role) bool {
return roleLevel(role) >= roleLevel(required)
}
func roleLevel(r Role) int {
switch r {
case RoleAdmin:
+8 -1
View File
@@ -139,7 +139,8 @@ func newDockerSandbox(ctx context.Context, name string, cfg Config, workspace st
}
// Exec runs a command inside the container.
func (s *DockerSandbox) Exec(ctx context.Context, command []string, workDir string) (*ExecResult, error) {
// Optional ExecOption (e.g. WithEnv) injects per-call env vars via docker exec -e.
func (s *DockerSandbox) Exec(ctx context.Context, command []string, workDir string, opts ...ExecOption) (*ExecResult, error) {
s.mu.Lock()
s.lastUsed = time.Now()
s.mu.Unlock()
@@ -152,7 +153,13 @@ func (s *DockerSandbox) Exec(ctx context.Context, command []string, workDir stri
execCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
o := ApplyExecOpts(opts)
args := []string{"exec"}
// Inject env vars as -e flags before containerID (credentialed exec)
for k, v := range o.Env {
args = append(args, "-e", k+"="+v)
}
if workDir != "" {
args = append(args, "-w", workDir)
}
+25 -1
View File
@@ -153,10 +153,34 @@ type ExecResult struct {
Stderr string `json:"stderr"`
}
// ExecOption configures optional behavior for sandbox Exec calls.
type ExecOption func(*ExecOpts)
// ExecOpts holds optional settings applied via ExecOption.
type ExecOpts struct {
Env map[string]string // extra env vars injected into the container exec
}
// WithEnv injects additional environment variables into the sandbox exec call.
// Used by credentialed exec to pass credentials without shell interpretation.
func WithEnv(env map[string]string) ExecOption {
return func(o *ExecOpts) { o.Env = env }
}
// ApplyExecOpts resolves variadic ExecOption into ExecOpts.
func ApplyExecOpts(opts []ExecOption) ExecOpts {
var o ExecOpts
for _, opt := range opts {
opt(&o)
}
return o
}
// Sandbox is the interface for sandboxed code execution.
type Sandbox interface {
// Exec runs a command inside the sandbox and returns the result.
Exec(ctx context.Context, command []string, workDir string) (*ExecResult, error)
// Optional ExecOption (e.g. WithEnv) configures per-call behavior.
Exec(ctx context.Context, command []string, workDir string, opts ...ExecOption) (*ExecResult, error)
// Destroy removes the sandbox container and cleans up resources.
Destroy(ctx context.Context) error
+44
View File
@@ -0,0 +1,44 @@
package store
import (
"context"
"time"
"github.com/google/uuid"
)
// APIKeyData represents a gateway API key with scoped permissions.
type APIKeyData struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Prefix string `json:"prefix"` // first 8 chars for display
KeyHash string `json:"-"` // SHA-256 hex, never serialized
Scopes []string `json:"scopes"` // e.g. ["operator.admin","operator.read"]
ExpiresAt *time.Time `json:"expires_at"` // nil = never
LastUsedAt *time.Time `json:"last_used_at"`
Revoked bool `json:"revoked"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// APIKeyStore manages gateway API keys.
type APIKeyStore interface {
// Create inserts a new API key.
Create(ctx context.Context, key *APIKeyData) error
// GetByHash looks up an active (non-revoked, non-expired) key by its SHA-256 hash.
GetByHash(ctx context.Context, keyHash string) (*APIKeyData, error)
// List returns all API keys (for admin display; key hashes are not included).
List(ctx context.Context) ([]APIKeyData, error)
// Revoke marks a key as revoked.
Revoke(ctx context.Context, id uuid.UUID) error
// Delete permanently removes a key.
Delete(ctx context.Context, id uuid.UUID) error
// TouchLastUsed updates the last_used_at timestamp.
TouchLastUsed(ctx context.Context, id uuid.UUID) error
}
+124
View File
@@ -0,0 +1,124 @@
package pg
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
"github.com/lib/pq"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// PGAPIKeyStore implements store.APIKeyStore using PostgreSQL.
type PGAPIKeyStore struct {
db *sql.DB
}
// NewPGAPIKeyStore creates a new PostgreSQL-backed API key store.
func NewPGAPIKeyStore(db *sql.DB) *PGAPIKeyStore {
return &PGAPIKeyStore{db: db}
}
func (s *PGAPIKeyStore) Create(ctx context.Context, key *store.APIKeyData) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO api_keys (id, name, prefix, key_hash, scopes, expires_at, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
key.ID, key.Name, key.Prefix, key.KeyHash, pq.Array(key.Scopes),
key.ExpiresAt, nilStr(key.CreatedBy), key.CreatedAt, key.UpdatedAt,
)
return err
}
func (s *PGAPIKeyStore) GetByHash(ctx context.Context, keyHash string) (*store.APIKeyData, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, name, prefix, key_hash, scopes, expires_at, last_used_at, revoked, created_by, created_at, updated_at
FROM api_keys
WHERE key_hash = $1 AND NOT revoked AND (expires_at IS NULL OR expires_at > now())`,
keyHash,
)
var k store.APIKeyData
var createdBy *string
err := row.Scan(
&k.ID, &k.Name, &k.Prefix, &k.KeyHash, pq.Array(&k.Scopes),
&k.ExpiresAt, &k.LastUsedAt, &k.Revoked, &createdBy,
&k.CreatedAt, &k.UpdatedAt,
)
if err != nil {
return nil, err
}
if createdBy != nil {
k.CreatedBy = *createdBy
}
return &k, nil
}
func (s *PGAPIKeyStore) List(ctx context.Context) ([]store.APIKeyData, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, prefix, scopes, expires_at, last_used_at, revoked, created_by, created_at, updated_at
FROM api_keys
ORDER BY created_at DESC`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []store.APIKeyData
for rows.Next() {
var k store.APIKeyData
var createdBy *string
if err := rows.Scan(
&k.ID, &k.Name, &k.Prefix, pq.Array(&k.Scopes),
&k.ExpiresAt, &k.LastUsedAt, &k.Revoked, &createdBy,
&k.CreatedAt, &k.UpdatedAt,
); err != nil {
return nil, err
}
if createdBy != nil {
k.CreatedBy = *createdBy
}
keys = append(keys, k)
}
return keys, rows.Err()
}
func (s *PGAPIKeyStore) Revoke(ctx context.Context, id uuid.UUID) error {
res, err := s.db.ExecContext(ctx,
`UPDATE api_keys SET revoked = true, updated_at = $2 WHERE id = $1`,
id, time.Now(),
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *PGAPIKeyStore) Delete(ctx context.Context, id uuid.UUID) error {
res, err := s.db.ExecContext(ctx,
`DELETE FROM api_keys WHERE id = $1`, id,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *PGAPIKeyStore) TouchLastUsed(ctx context.Context, id uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`UPDATE api_keys SET last_used_at = $2 WHERE id = $1`,
id, time.Now(),
)
return err
}
+2
View File
@@ -44,5 +44,7 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) {
Contacts: NewPGContactStore(db),
Activity: NewPGActivityStore(db),
Snapshots: NewPGSnapshotStore(db),
SecureCLI: NewPGSecureCLIStore(db, cfg.EncryptionKey),
APIKeys: NewPGAPIKeyStore(db),
}, nil
}
+8
View File
@@ -71,6 +71,13 @@ func jsonOrEmpty(data []byte) []byte {
return data
}
func jsonOrEmptyArray(data []byte) []byte {
if data == nil {
return []byte("[]")
}
return data
}
func jsonOrNull(data json.RawMessage) any {
if data == nil {
return nil
@@ -138,6 +145,7 @@ var tablesWithUpdatedAt = map[string]bool{
"agent_context_files": true, "user_context_files": true,
"user_agent_overrides": true, "config_secrets": true,
"memory_documents": true, "memory_chunks": true, "embedding_cache": true,
"secure_cli_binaries": true,
}
func tableHasUpdatedAt(table string) bool {
+240
View File
@@ -0,0 +1,240 @@
package pg
import (
"context"
"database/sql"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// PGSecureCLIStore implements store.SecureCLIStore backed by Postgres.
type PGSecureCLIStore struct {
db *sql.DB
encKey string
}
func NewPGSecureCLIStore(db *sql.DB, encryptionKey string) *PGSecureCLIStore {
return &PGSecureCLIStore{db: db, encKey: encryptionKey}
}
const secureCLISelectCols = `id, binary_name, binary_path, description, encrypted_env,
deny_args, deny_verbose, timeout_seconds, tips, agent_id, enabled, created_by, created_at, updated_at`
func (s *PGSecureCLIStore) Create(ctx context.Context, b *store.SecureCLIBinary) error {
if err := store.ValidateUserID(b.CreatedBy); err != nil {
return err
}
if b.ID == uuid.Nil {
b.ID = store.GenNewID()
}
// Encrypt env if provided
var envBytes []byte
if len(b.EncryptedEnv) > 0 && s.encKey != "" {
encrypted, err := crypto.Encrypt(string(b.EncryptedEnv), s.encKey)
if err != nil {
return fmt.Errorf("encrypt env: %w", err)
}
envBytes = []byte(encrypted)
} else {
envBytes = b.EncryptedEnv
}
now := time.Now()
b.CreatedAt = now
b.UpdatedAt = now
_, err := s.db.ExecContext(ctx,
`INSERT INTO secure_cli_binaries (id, binary_name, binary_path, description, encrypted_env,
deny_args, deny_verbose, timeout_seconds, tips, agent_id, enabled, created_by, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
b.ID, b.BinaryName, nilStr(derefStr(b.BinaryPath)), b.Description,
envBytes,
jsonOrEmptyArray(b.DenyArgs), jsonOrEmptyArray(b.DenyVerbose),
b.TimeoutSeconds, b.Tips,
nilUUID(b.AgentID), b.Enabled,
b.CreatedBy, now, now,
)
return err
}
func (s *PGSecureCLIStore) Get(ctx context.Context, id uuid.UUID) (*store.SecureCLIBinary, error) {
row := s.db.QueryRowContext(ctx,
`SELECT `+secureCLISelectCols+` FROM secure_cli_binaries WHERE id = $1`, id)
return s.scanRow(row)
}
func (s *PGSecureCLIStore) scanRow(row *sql.Row) (*store.SecureCLIBinary, error) {
var b store.SecureCLIBinary
var binaryPath *string
var agentID *uuid.UUID
var denyArgs, denyVerbose *[]byte
var env []byte
err := row.Scan(
&b.ID, &b.BinaryName, &binaryPath, &b.Description, &env,
&denyArgs, &denyVerbose,
&b.TimeoutSeconds, &b.Tips, &agentID,
&b.Enabled, &b.CreatedBy, &b.CreatedAt, &b.UpdatedAt,
)
if err != nil {
return nil, err
}
b.BinaryPath = binaryPath
b.AgentID = agentID
if denyArgs != nil {
b.DenyArgs = *denyArgs
}
if denyVerbose != nil {
b.DenyVerbose = *denyVerbose
}
// Decrypt env
if len(env) > 0 && s.encKey != "" {
decrypted, err := crypto.Decrypt(string(env), s.encKey)
if err != nil {
slog.Warn("secure_cli: failed to decrypt env", "binary", b.BinaryName, "error", err)
} else {
b.EncryptedEnv = []byte(decrypted)
}
} else {
b.EncryptedEnv = env
}
return &b, nil
}
func (s *PGSecureCLIStore) scanRows(rows *sql.Rows) ([]store.SecureCLIBinary, error) {
defer rows.Close()
var result []store.SecureCLIBinary
for rows.Next() {
var b store.SecureCLIBinary
var binaryPath *string
var agentID *uuid.UUID
var denyArgs, denyVerbose *[]byte
var env []byte
if err := rows.Scan(
&b.ID, &b.BinaryName, &binaryPath, &b.Description, &env,
&denyArgs, &denyVerbose,
&b.TimeoutSeconds, &b.Tips, &agentID,
&b.Enabled, &b.CreatedBy, &b.CreatedAt, &b.UpdatedAt,
); err != nil {
continue
}
b.BinaryPath = binaryPath
b.AgentID = agentID
if denyArgs != nil {
b.DenyArgs = *denyArgs
}
if denyVerbose != nil {
b.DenyVerbose = *denyVerbose
}
if len(env) > 0 && s.encKey != "" {
if decrypted, err := crypto.Decrypt(string(env), s.encKey); err == nil {
b.EncryptedEnv = []byte(decrypted)
}
} else {
b.EncryptedEnv = env
}
result = append(result, b)
}
return result, nil
}
// secureCLIAllowedFields is the allowlist of columns that can be updated via execMapUpdate.
// Defense-in-depth: prevents column name injection even if caller skips validation.
var secureCLIAllowedFields = map[string]bool{
"binary_name": true, "binary_path": true, "description": true,
"encrypted_env": true, "deny_args": true, "deny_verbose": true,
"timeout_seconds": true, "tips": true, "agent_id": true, "enabled": true,
"updated_at": true,
}
func (s *PGSecureCLIStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
// Filter unknown fields to prevent column name injection
for k := range updates {
if !secureCLIAllowedFields[k] {
delete(updates, k)
}
}
// Encrypt env if present in updates
if envVal, ok := updates["encrypted_env"]; ok {
if envStr, isStr := envVal.(string); isStr && envStr != "" && s.encKey != "" {
encrypted, err := crypto.Encrypt(envStr, s.encKey)
if err != nil {
return fmt.Errorf("encrypt env: %w", err)
}
updates["encrypted_env"] = []byte(encrypted)
}
}
updates["updated_at"] = time.Now()
return execMapUpdate(ctx, s.db, "secure_cli_binaries", id, updates)
}
func (s *PGSecureCLIStore) Delete(ctx context.Context, id uuid.UUID) error {
_, err := s.db.ExecContext(ctx, "DELETE FROM secure_cli_binaries WHERE id = $1", id)
return err
}
func (s *PGSecureCLIStore) List(ctx context.Context) ([]store.SecureCLIBinary, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT `+secureCLISelectCols+` FROM secure_cli_binaries ORDER BY binary_name, agent_id NULLS LAST`)
if err != nil {
return nil, err
}
return s.scanRows(rows)
}
func (s *PGSecureCLIStore) ListByAgent(ctx context.Context, agentID uuid.UUID) ([]store.SecureCLIBinary, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT `+secureCLISelectCols+` FROM secure_cli_binaries
WHERE (agent_id = $1 OR agent_id IS NULL) AND enabled = true
ORDER BY binary_name, agent_id NULLS LAST`, agentID)
if err != nil {
return nil, err
}
return s.scanRows(rows)
}
// LookupByBinary finds the best credential config for a binary name.
// Agent-specific config takes priority over global (agent_id IS NULL).
func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string, agentID *uuid.UUID) (*store.SecureCLIBinary, error) {
var row *sql.Row
if agentID != nil {
row = s.db.QueryRowContext(ctx,
`SELECT `+secureCLISelectCols+` FROM secure_cli_binaries
WHERE binary_name = $1 AND (agent_id = $2 OR agent_id IS NULL) AND enabled = true
ORDER BY agent_id NULLS LAST LIMIT 1`, binaryName, *agentID)
} else {
row = s.db.QueryRowContext(ctx,
`SELECT `+secureCLISelectCols+` FROM secure_cli_binaries
WHERE binary_name = $1 AND agent_id IS NULL AND enabled = true
LIMIT 1`, binaryName)
}
b, err := s.scanRow(row)
if err == sql.ErrNoRows {
return nil, nil
}
return b, err
}
func (s *PGSecureCLIStore) ListEnabled(ctx context.Context) ([]store.SecureCLIBinary, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT `+secureCLISelectCols+` FROM secure_cli_binaries
WHERE enabled = true ORDER BY binary_name`)
if err != nil {
return nil, err
}
return s.scanRows(rows)
}
+42
View File
@@ -0,0 +1,42 @@
package store
import (
"context"
"encoding/json"
"github.com/google/uuid"
)
// SecureCLIBinary represents a CLI binary with auto-injected credentials.
// Credentials are encrypted at rest and injected into child processes via Direct Exec Mode.
type SecureCLIBinary struct {
BaseModel
BinaryName string `json:"binary_name"`
BinaryPath *string `json:"binary_path,omitempty"`
Description string `json:"description"`
EncryptedEnv []byte `json:"-"` // AES-256-GCM encrypted JSON — never serialized to API
DenyArgs json.RawMessage `json:"deny_args"` // regex patterns for blocked subcommands
DenyVerbose json.RawMessage `json:"deny_verbose"` // blocked verbose/debug flags
TimeoutSeconds int `json:"timeout_seconds"`
Tips string `json:"tips"` // hint injected into TOOLS.md context
AgentID *uuid.UUID `json:"agent_id,omitempty"`
Enabled bool `json:"enabled"`
CreatedBy string `json:"created_by"`
}
// SecureCLIStore manages secure CLI binary credential configurations.
type SecureCLIStore interface {
Create(ctx context.Context, b *SecureCLIBinary) error
Get(ctx context.Context, id uuid.UUID) (*SecureCLIBinary, error)
Update(ctx context.Context, id uuid.UUID, updates map[string]any) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context) ([]SecureCLIBinary, error)
ListByAgent(ctx context.Context, agentID uuid.UUID) ([]SecureCLIBinary, error)
// LookupByBinary finds the best-matching credential config for a binary name.
// Priority: agent-specific > global (agent_id IS NULL). Returns nil if not found.
LookupByBinary(ctx context.Context, binaryName string, agentID *uuid.UUID) (*SecureCLIBinary, error)
// ListEnabled returns all enabled configs (for TOOLS.md context generation).
ListEnabled(ctx context.Context) ([]SecureCLIBinary, error)
}
+2
View File
@@ -25,4 +25,6 @@ type Stores struct {
Contacts ContactStore
Activity ActivityStore
Snapshots SnapshotStore
SecureCLI SecureCLIStore
APIKeys APIKeyStore
}
+68
View File
@@ -0,0 +1,68 @@
package tools
import (
"encoding/json"
"fmt"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// GenerateCredentialContext builds a TOOLS.md supplement from enabled secure CLI configs.
// This context is injected into the agent's system prompt so the LLM knows:
// - Which CLIs are available with pre-configured auth
// - That these CLIs run in Direct Exec Mode (no shell operators)
// - Which operations are blocked per CLI
// Returns empty string if no credentialed CLIs are configured.
func GenerateCredentialContext(creds []store.SecureCLIBinary) string {
if len(creds) == 0 {
return ""
}
var b strings.Builder
b.WriteString("\n\n## Credentialed CLI Tools\n\n")
b.WriteString("The following CLI tools have pre-configured authentication.\n")
b.WriteString("Credentials are injected automatically — do NOT attempt to provide or read credentials.\n\n")
b.WriteString("⚠️ CRITICAL: These tools run in DIRECT EXEC MODE (no shell).\n")
b.WriteString("- Do NOT use shell operators: ; && || | > >> < $() ``\n")
b.WriteString("- Do NOT use environment variables: $VAR, ${VAR}\n")
b.WriteString("- Each exec() call runs ONE command only\n")
b.WriteString("- Use --json or --format=json for structured output\n")
b.WriteString("- Parse JSON output directly — do NOT pipe to jq\n\n")
b.WriteString("### Available CLIs:\n\n")
for _, c := range creds {
b.WriteString(fmt.Sprintf("**%s** — %s\n", c.BinaryName, c.Description))
if blocked := summarizeDenyPatterns(c.DenyArgs); blocked != "" {
b.WriteString(fmt.Sprintf(" Blocked: %s\n", blocked))
}
if c.Tips != "" {
b.WriteString(fmt.Sprintf(" Tip: %s\n", c.Tips))
}
b.WriteString("\n")
}
b.WriteString("### When a command is blocked:\n")
b.WriteString("Tell the user: \"This operation requires admin approval and cannot be performed automatically.\"\n")
b.WriteString("Do NOT attempt workarounds to bypass blocked commands.\n")
return b.String()
}
// summarizeDenyPatterns converts regex deny patterns to a human-readable summary.
// E.g. ["auth\\s+", "ssh-key", "repo\\s+delete"] -> "auth, ssh-key, repo delete"
func summarizeDenyPatterns(patternsJSON json.RawMessage) string {
if len(patternsJSON) == 0 {
return ""
}
var patterns []string
if err := json.Unmarshal(patternsJSON, &patterns); err != nil || len(patterns) == 0 {
return ""
}
// Convert regex patterns to readable form by stripping common regex syntax
readable := make([]string, 0, len(patterns))
replacer := strings.NewReplacer(`\s+`, " ", `\s*`, " ", `\b`, "", `\w+`, "*")
for _, p := range patterns {
readable = append(readable, replacer.Replace(p))
}
return strings.Join(readable, ", ")
}
+102
View File
@@ -0,0 +1,102 @@
package tools
import "sort"
// CLIPreset defines a built-in configuration template for a common CLI tool.
// Presets eliminate admin research friction by pre-filling env var names,
// deny patterns, timeout, and usage tips.
type CLIPreset struct {
BinaryName string `json:"binary_name"`
Description string `json:"description"`
EnvVars []EnvVarDef `json:"env_vars"`
DenyArgs []string `json:"deny_args"`
DenyVerbose []string `json:"deny_verbose"`
Timeout int `json:"timeout"`
Tips string `json:"tips"`
}
// EnvVarDef describes an environment variable required by a CLI tool.
type EnvVarDef struct {
Name string `json:"name"`
Desc string `json:"desc"`
IsFile bool `json:"is_file,omitempty"` // credential is a file path (e.g. GOOGLE_APPLICATION_CREDENTIALS)
Optional bool `json:"optional,omitempty"`
}
// CLIPresets contains built-in presets for common CLI tools.
var CLIPresets = map[string]CLIPreset{
"gh": {
BinaryName: "gh",
Description: "GitHub CLI",
EnvVars: []EnvVarDef{{Name: "GH_TOKEN", Desc: "GitHub PAT or App token"}},
DenyArgs: []string{`auth\s+`, `ssh-key`, `gpg-key`, `repo\s+delete`, `secret\s+`},
DenyVerbose: []string{`--verbose`, `-v`},
Timeout: 30,
Tips: "Use --json flag for structured output",
},
"gcloud": {
BinaryName: "gcloud",
Description: "Google Cloud CLI",
EnvVars: []EnvVarDef{
{Name: "GOOGLE_APPLICATION_CREDENTIALS", Desc: "Service account JSON", IsFile: true},
},
DenyArgs: []string{`iam\s+`, `auth\s+`, `projects\s+delete`, `services\s+disable`, `kms\s+`},
DenyVerbose: []string{`--verbosity=debug`, `--log-http`},
Timeout: 120,
Tips: "Use --format=json for structured output",
},
"aws": {
BinaryName: "aws",
Description: "AWS CLI",
EnvVars: []EnvVarDef{
{Name: "AWS_ACCESS_KEY_ID", Desc: "AWS access key"},
{Name: "AWS_SECRET_ACCESS_KEY", Desc: "AWS secret key"},
{Name: "AWS_DEFAULT_REGION", Desc: "AWS region", Optional: true},
},
DenyArgs: []string{`iam\s+`, `organizations\s+`, `sts\s+assume`, `ec2\s+terminate`},
DenyVerbose: []string{`--debug`},
Timeout: 60,
Tips: "Use --output json for structured output",
},
"kubectl": {
BinaryName: "kubectl",
Description: "Kubernetes CLI",
EnvVars: []EnvVarDef{
{Name: "KUBECONFIG", Desc: "Path to kubeconfig", IsFile: true},
},
DenyArgs: []string{`delete\s+namespace`, `delete\s+node`, `drain\s+`, `cordon\s+`},
DenyVerbose: nil,
Timeout: 60,
Tips: "Use -o json for structured output",
},
"terraform": {
BinaryName: "terraform",
Description: "Terraform CLI",
EnvVars: []EnvVarDef{
{Name: "TF_TOKEN_app_terraform_io", Desc: "Terraform Cloud token", Optional: true},
},
DenyArgs: []string{`destroy`, `force-unlock`},
DenyVerbose: nil,
Timeout: 300,
Tips: "Use -json flag for structured output",
},
}
// GetPreset returns a preset by name, or nil if not found.
func GetPreset(name string) *CLIPreset {
p, ok := CLIPresets[name]
if !ok {
return nil
}
return &p
}
// ListPresetNames returns all available preset names sorted alphabetically.
func ListPresetNames() []string {
names := make([]string, 0, len(CLIPresets))
for name := range CLIPresets {
names = append(names, name)
}
sort.Strings(names)
return names
}
+334
View File
@@ -0,0 +1,334 @@
package tools
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/exec"
"regexp"
"strings"
"time"
"github.com/google/uuid"
shellwords "github.com/mattn/go-shellwords"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// shellOperatorPattern detects shell metacharacters that indicate command chaining.
// These are unsafe in credentialed mode because they allow reading injected env vars.
var shellOperatorPattern = regexp.MustCompile(`[;|&<>\n\r` + "`" + `]|\$\(|\$\{`)
// parseCommandBinary splits a command string into binary name and arguments.
// Uses shell-word parsing to correctly handle quoted arguments with spaces.
func parseCommandBinary(command string) (binary string, args []string, err error) {
parser := shellwords.NewParser()
parser.ParseBacktick = false
parser.ParseEnv = false
words, err := parser.Parse(command)
if err != nil {
return "", nil, fmt.Errorf("parse command: %w", err)
}
if len(words) == 0 {
return "", nil, fmt.Errorf("empty command")
}
return words[0], words[1:], nil
}
// detectShellOperators scans a raw command string for shell metacharacters.
// Returns the list of detected operators, or nil if the command is clean.
func detectShellOperators(command string) []string {
matches := shellOperatorPattern.FindAllString(command, -1)
if len(matches) == 0 {
return nil
}
// Deduplicate
seen := make(map[string]bool, len(matches))
var unique []string
for _, m := range matches {
if !seen[m] {
seen[m] = true
unique = append(unique, m)
}
}
return unique
}
// resolveAndMatchBinary resolves a binary name to an absolute path and
// optionally verifies it matches the stored config path. This prevents
// binary spoofing (e.g. ./gh in workspace instead of /usr/bin/gh).
func resolveAndMatchBinary(binaryName string, configPath *string) (string, error) {
absPath, err := exec.LookPath(binaryName)
if err != nil {
return "", fmt.Errorf("binary %q not found in PATH: %w", binaryName, err)
}
// If config specifies an absolute path, verify it matches
if configPath != nil && *configPath != "" && absPath != *configPath {
return "", fmt.Errorf("binary path mismatch: resolved %q but config expects %q", absPath, *configPath)
}
return absPath, nil
}
// matchesBinaryDeny checks if the joined args string matches any per-binary deny pattern.
// Returns the matched pattern string, or empty if allowed.
func matchesBinaryDeny(args []string, denyPatternsJSON json.RawMessage) string {
if len(denyPatternsJSON) == 0 {
return ""
}
var patterns []string
if err := json.Unmarshal(denyPatternsJSON, &patterns); err != nil || len(patterns) == 0 {
return ""
}
argsStr := strings.Join(args, " ")
for _, p := range patterns {
re, err := regexp.Compile(p)
if err != nil {
slog.Warn("secure_cli.invalid_deny_pattern", "pattern", p, "error", err)
continue
}
if re.MatchString(argsStr) {
return p
}
}
return ""
}
// executeCredentialed runs a CLI command in Direct Exec Mode (no shell).
// Credentials are injected as env vars into the child process only.
func (t *ExecTool) executeCredentialed(ctx context.Context, cred *store.SecureCLIBinary,
binary string, args []string, cwd string, sandboxKey string) *Result {
// Step 1: Check for shell operators (early detection for clear error)
rawCommand := binary + " " + strings.Join(args, " ")
if ops := detectShellOperators(rawCommand); len(ops) > 0 {
return credentialedShellOperatorError(rawCommand, ops)
}
// Step 2: Resolve binary to absolute path and verify against config
absPath, err := resolveAndMatchBinary(binary, cred.BinaryPath)
if err != nil {
return credentialedPathError(binary, err)
}
// Step 3: Per-binary deny check (deny_args)
if p := matchesBinaryDeny(args, cred.DenyArgs); p != "" {
return credentialedDenyError(binary, args, p)
}
// Per-binary verbose deny check (deny_verbose)
if p := matchesBinaryDeny(args, cred.DenyVerbose); p != "" {
return credentialedDenyError(binary, args, p)
}
// Step 4: Decrypt env vars from store (already decrypted by store layer)
envMap := make(map[string]string)
if len(cred.EncryptedEnv) > 0 {
if err := json.Unmarshal(cred.EncryptedEnv, &envMap); err != nil {
return ErrorResult(fmt.Sprintf("credentialed exec: invalid env JSON for %q: %v", binary, err))
}
}
// Step 5: Register credential values for output scrubbing
for _, v := range envMap {
AddCredentialScrubValues(v)
}
// Step 6: Determine timeout
timeout := time.Duration(cred.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 30 * time.Second
}
// Step 7: Execute — sandbox or host
if t.sandboxMgr != nil && sandboxKey != "" {
return t.executeCredentialedSandbox(ctx, absPath, args, cwd, sandboxKey, envMap, timeout)
}
return t.executeCredentialedHost(ctx, absPath, args, cwd, envMap, timeout)
}
// executeCredentialedHost runs a credentialed command directly on the host.
// Uses exec.Command (no shell) with credentials as env vars.
func (t *ExecTool) executeCredentialedHost(ctx context.Context, absPath string, args []string,
cwd string, envMap map[string]string, timeout time.Duration) *Result {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(ctx, absPath, args...)
cmd.Dir = cwd
// Build env: inherit minimal PATH + HOME, add credentials
cmd.Env = buildCredentialedEnv(envMap)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
return formatCredentialedResult(absPath, args, stdout.String(), stderr.String(), err, ctx, timeout)
}
// executeCredentialedSandbox runs a credentialed command inside a Docker sandbox.
// Uses sandbox.WithEnv to inject credentials via docker exec -e (no shell).
func (t *ExecTool) executeCredentialedSandbox(ctx context.Context, absPath string, args []string,
cwd string, sandboxKey string, envMap map[string]string, timeout time.Duration) *Result {
sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workingDir, SandboxConfigFromCtx(ctx))
if err != nil {
slog.Warn("security.credentialed_exec_sandbox_unavailable",
"binary", absPath, "error", err)
return ErrorResult("credentialed exec requires sandbox but sandbox is unavailable: " + err.Error())
}
// Direct exec inside sandbox: [absPath, args...] with env injection
command := append([]string{absPath}, args...)
result, err := sb.Exec(ctx, command, cwd, sandbox.WithEnv(envMap))
if err != nil {
return ErrorResult(fmt.Sprintf("credentialed sandbox exec: %v", err))
}
output := result.Stdout
if result.Stderr != "" {
if output != "" {
output += "\n"
}
output += "STDERR:\n" + result.Stderr
}
if result.ExitCode != 0 {
return credentialedExecFailError(absPath, args, result.ExitCode, ScrubCredentials(output))
}
if output == "" {
output = "(command completed with no output)"
}
return SilentResult(ScrubCredentials(output))
}
// buildCredentialedEnv creates a minimal environment with injected credentials.
// Inherits PATH and HOME from parent process, adds credential env vars.
func buildCredentialedEnv(envMap map[string]string) []string {
env := []string{
"PATH=" + getenvDefault("PATH", "/usr/local/bin:/usr/bin:/bin"),
"HOME=" + getenvDefault("HOME", "/tmp"),
"LANG=" + getenvDefault("LANG", "en_US.UTF-8"),
"USER=" + getenvDefault("USER", "goclaw"),
}
for k, v := range envMap {
env = append(env, k+"="+v)
}
return env
}
// formatCredentialedResult formats the output of a credentialed exec call.
func formatCredentialedResult(binary string, args []string,
stdout, stderr string, err error, ctx context.Context, timeout time.Duration) *Result {
var output string
if stdout != "" {
output = stdout
}
if stderr != "" {
if output != "" {
output += "\n"
}
output += "STDERR:\n" + stderr
}
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return ErrorResult(fmt.Sprintf("[CREDENTIALED EXEC] Command timed out after %s.\nBinary: %s", timeout, binary))
}
exitCode := -1
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
}
return credentialedExecFailError(binary, args, exitCode, ScrubCredentials(output))
}
if output == "" {
output = "(command completed with no output)"
}
return SilentResult(ScrubCredentials(output))
}
// lookupCredentialedBinary checks if a command's binary has credential config.
// Returns the credential config and parsed args, or nil if not credentialed.
func (t *ExecTool) lookupCredentialedBinary(ctx context.Context, command string) (*store.SecureCLIBinary, string, []string) {
if t.secureCLIStore == nil {
return nil, "", nil
}
binary, args, err := parseCommandBinary(command)
if err != nil {
return nil, "", nil
}
// Get agent ID from context for scoped lookup
agentID := store.AgentIDFromContext(ctx)
var agentIDPtr *uuid.UUID
if agentID != uuid.Nil {
agentIDPtr = &agentID
}
cred, err := t.secureCLIStore.LookupByBinary(ctx, binary, agentIDPtr)
if err != nil || cred == nil {
return nil, "", nil
}
return cred, binary, args
}
// getenvDefault returns the value of an env var, or a default if not set.
func getenvDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// --- Structured error helpers ---
func credentialedShellOperatorError(command string, ops []string) *Result {
return &Result{
ForLLM: fmt.Sprintf("[CREDENTIALED EXEC] Shell operators not supported.\n"+
"Detected: %s\n"+
"This CLI runs in Direct Exec Mode — no shell operators (; && || | > < $() ``).\n"+
"Run the command without operators. Use --json or --format=json for structured output.",
strings.Join(ops, ", ")),
ForUser: "Command contains shell operators not supported in credentialed mode.",
IsError: true,
}
}
func credentialedPathError(binary string, err error) *Result {
return &Result{
ForLLM: fmt.Sprintf("[CREDENTIALED EXEC] Binary resolution failed.\n"+
"Binary: %s\nError: %v\n"+
"The binary may not be installed or the path doesn't match the configured path.",
binary, err),
ForUser: fmt.Sprintf("CLI binary %q not found or path mismatch.", binary),
IsError: true,
}
}
func credentialedDenyError(binary string, args []string, pattern string) *Result {
return &Result{
ForLLM: fmt.Sprintf("[CREDENTIALED EXEC] Command blocked by security policy.\n"+
"Binary: %s\nArgs: %s\nMatched deny pattern: %s\n"+
"This operation requires admin approval and cannot be performed automatically.",
binary, strings.Join(args, " "), pattern),
ForUser: fmt.Sprintf("Operation '%s %s' is blocked by security policy.", binary, strings.Join(args, " ")),
IsError: true,
}
}
func credentialedExecFailError(binary string, args []string, exitCode int, output string) *Result {
return &Result{
ForLLM: fmt.Sprintf("[CREDENTIALED EXEC] Command failed (exit code %d).\n"+
"Binary: %s\nArgs: %s\n"+
"Note: This runs in Direct Exec Mode — shell operators are NOT supported.\n"+
"If you used shell operators, remove them and try again.\n\n%s",
exitCode, binary, strings.Join(args, " "), output),
ForUser: fmt.Sprintf("Command failed with exit code %d.", exitCode),
IsError: true,
}
}
+44
View File
@@ -45,6 +45,13 @@ var (
dynamicScrubValues []string
)
// credentialScrubValues holds credential values to scrub (replaced with [REDACTED]).
// Separate from dynamicScrubValues which use [SERVER_IP] placeholder.
var (
credentialScrubMu sync.RWMutex
credentialScrubValues []string
)
// AddDynamicScrubValues adds exact string values to the dynamic scrub list.
// Thread-safe. Deduplicates. Empty strings are ignored.
func AddDynamicScrubValues(values ...string) {
@@ -77,12 +84,49 @@ func ResetDynamicScrubValues() {
dynamicScrubValues = nil
}
// ResetCredentialScrubValues clears all credential scrub values.
// Called when credentials are updated to prevent stale values from accumulating.
func ResetCredentialScrubValues() {
credentialScrubMu.Lock()
defer credentialScrubMu.Unlock()
credentialScrubValues = nil
}
// AddCredentialScrubValues adds credential values to the scrub list.
// These are replaced with [REDACTED] (not [SERVER_IP]).
// Thread-safe. Deduplicates. Empty/short strings ignored.
func AddCredentialScrubValues(values ...string) {
credentialScrubMu.Lock()
defer credentialScrubMu.Unlock()
existing := make(map[string]bool, len(credentialScrubValues))
for _, v := range credentialScrubValues {
existing[v] = true
}
for _, v := range values {
// Skip short values to avoid false-positive scrubbing
if len(v) >= 6 && !existing[v] {
credentialScrubValues = append(credentialScrubValues, v)
existing[v] = true
}
}
}
// ScrubCredentials replaces known credential patterns and dynamic values in text.
func ScrubCredentials(text string) string {
for _, pat := range credentialPatterns {
text = pat.ReplaceAllString(text, redactedPlaceholder)
}
// Credential values (from credentialed exec)
credentialScrubMu.RLock()
credVals := credentialScrubValues
credentialScrubMu.RUnlock()
for _, v := range credVals {
text = strings.ReplaceAll(text, v, redactedPlaceholder)
}
// Dynamic values (server IPs, etc.)
dynamicScrubMu.RLock()
vals := dynamicScrubValues
+29 -1
View File
@@ -13,6 +13,7 @@ import (
"time"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// Dangerous command patterns to deny by default.
@@ -139,6 +140,7 @@ type ExecTool struct {
sandboxMgr sandbox.Manager // nil = no sandbox, execute on host
approvalMgr *ExecApprovalManager // nil = no approval needed
agentID string // for approval request context
secureCLIStore store.SecureCLIStore // nil = no credentialed exec
}
// NewExecTool creates an exec tool that runs commands directly on the host.
@@ -187,6 +189,12 @@ func (t *ExecTool) SetApprovalManager(mgr *ExecApprovalManager, agentID string)
t.agentID = agentID
}
// SetSecureCLIStore sets the credential store for credentialed exec.
// When set, commands matching a configured binary use Direct Exec Mode.
func (t *ExecTool) SetSecureCLIStore(s store.SecureCLIStore) {
t.secureCLIStore = s
}
func (t *ExecTool) Name() string { return "exec" }
func (t *ExecTool) Description() string { return "Execute a shell command and return its output" }
func (t *ExecTool) Parameters() map[string]any {
@@ -229,6 +237,26 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result {
}
}
// Credentialed exec: if command matches a configured binary, use Direct Exec Mode.
// This bypasses approval (admin trust) and shell (security).
if cred, binary, cmdArgs := t.lookupCredentialedBinary(ctx, command); cred != nil {
cwd := ToolWorkspaceFromCtx(ctx)
if cwd == "" {
cwd = t.workingDir
}
if wd, _ := args["working_dir"].(string); wd != "" {
if effectiveRestrict(ctx, t.restrict) {
if resolved, err := resolvePath(wd, t.workingDir, true); err == nil {
cwd = resolved
}
} else {
cwd = wd
}
}
sandboxKey := ToolSandboxKeyFromCtx(ctx)
return t.executeCredentialed(ctx, cred, binary, cmdArgs, cwd, sandboxKey)
}
// Exec approval check (matching TS exec-approval.ts pipeline)
if t.approvalMgr != nil {
switch t.approvalMgr.CheckCommand(command) {
@@ -338,7 +366,7 @@ func (t *ExecTool) executeInSandbox(ctx context.Context, command, cwd, sandboxKe
}
}
result, err := sb.Exec(ctx, []string{"sh", "-c", command}, containerCwd)
result, err := sb.Exec(ctx, []string{"sh", "-c", command}, containerCwd) //nolint: no ExecOption for normal exec
if err != nil {
return ErrorResult(fmt.Sprintf("sandbox exec: %v", err))
}
+1 -1
View File
@@ -2,4 +2,4 @@ package upgrade
// RequiredSchemaVersion is the schema migration version this binary requires.
// Bump this whenever adding a new SQL migration file.
const RequiredSchemaVersion uint = 19
const RequiredSchemaVersion uint = 20
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS api_keys;
DROP TABLE IF EXISTS secure_cli_binaries;
@@ -0,0 +1,45 @@
-- Secure CLI binaries: credential injection for exec tool (Direct Exec Mode).
-- Admin maps binary -> env vars; GoClaw auto-injects into child process.
CREATE TABLE secure_cli_binaries (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
binary_name TEXT NOT NULL, -- display name: "gh", "gcloud"
binary_path TEXT, -- resolved absolute path (nullable, auto-resolved at runtime)
description TEXT NOT NULL DEFAULT '',
encrypted_env BYTEA NOT NULL, -- AES-256-GCM encrypted JSON: {"GH_TOKEN":"xxx"}
deny_args JSONB NOT NULL DEFAULT '[]', -- regex patterns: ["auth\\s+", "ssh-key"]
deny_verbose JSONB NOT NULL DEFAULT '[]', -- verbose flag patterns: ["--verbose", "-v"]
timeout_seconds INTEGER NOT NULL DEFAULT 30,
tips TEXT NOT NULL DEFAULT '', -- hint injected into TOOLS.md context
agent_id UUID REFERENCES agents(id) ON DELETE CASCADE, -- null = global (all agents)
enabled BOOLEAN NOT NULL DEFAULT true,
created_by TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_secure_cli_binary_name ON secure_cli_binaries(binary_name);
CREATE INDEX idx_secure_cli_agent_id ON secure_cli_binaries(agent_id) WHERE agent_id IS NOT NULL;
-- Unique constraint: one binary per agent (with null = global treated as a distinct scope)
CREATE UNIQUE INDEX idx_secure_cli_unique_binary_agent
ON secure_cli_binaries(binary_name, COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'::uuid));
-- API key management: multiple keys with fine-grained scopes
CREATE TABLE api_keys (
id UUID PRIMARY KEY,
name VARCHAR(100) NOT NULL,
prefix VARCHAR(8) NOT NULL, -- first 8 chars for display identification
key_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 hex digest
scopes TEXT[] NOT NULL DEFAULT '{}', -- e.g. {'operator.admin','operator.read'}
expires_at TIMESTAMPTZ, -- NULL = never expires
last_used_at TIMESTAMPTZ,
revoked BOOLEAN NOT NULL DEFAULT false,
created_by VARCHAR(255), -- user ID who created the key
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Fast lookup by hash (only active keys)
CREATE INDEX idx_api_keys_key_hash ON api_keys (key_hash) WHERE NOT revoked;
-- Fast lookup by prefix (for display/search)
CREATE INDEX idx_api_keys_prefix ON api_keys (prefix);
+7
View File
@@ -140,6 +140,13 @@ const (
MethodDelegationsGet = "delegations.get"
)
// API key management
const (
MethodAPIKeysList = "api_keys.list"
MethodAPIKeysCreate = "api_keys.create"
MethodAPIKeysRevoke = "api_keys.revoke"
)
// Phase 3+ - NICE TO HAVE methods
const (
MethodLogsTail = "logs.tail"
+36 -12
View File
@@ -8,6 +8,7 @@ interface SidebarItemProps {
label: string;
badge?: number;
collapsed?: boolean;
external?: boolean;
}
export function SidebarItem({
@@ -16,21 +17,20 @@ export function SidebarItem({
label,
badge,
collapsed,
external,
}: SidebarItemProps) {
const location = useLocation();
const active = location.pathname === to || location.pathname.startsWith(to + "/");
const active = !external && (location.pathname === to || location.pathname.startsWith(to + "/"));
return (
<Link
to={to}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
"hover:bg-accent hover:text-accent-foreground",
active && "bg-accent text-accent-foreground font-medium",
collapsed && "justify-center px-2",
)}
title={collapsed ? label : undefined}
>
const className = cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
"hover:bg-accent hover:text-accent-foreground",
active && "bg-accent text-accent-foreground font-medium",
collapsed && "justify-center px-2",
);
const content = (
<>
<Icon className="h-4 w-4 shrink-0" />
{!collapsed && <span className="truncate">{label}</span>}
{!collapsed && badge != null && badge > 0 && (
@@ -38,6 +38,30 @@ export function SidebarItem({
{badge}
</span>
)}
</>
);
if (external) {
return (
<a
href={to}
target="_blank"
rel="noopener noreferrer"
className={className}
title={collapsed ? label : undefined}
>
{content}
</a>
);
}
return (
<Link
to={to}
className={className}
title={collapsed ? label : undefined}
>
{content}
</Link>
);
}
+5
View File
@@ -24,6 +24,8 @@ import {
Brain,
Network,
Contact,
KeyRound,
FileText,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { SidebarGroup } from "./sidebar-group";
@@ -111,8 +113,11 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
<SidebarGroup label={t("groups.system")} collapsed={collapsed}>
<SidebarItem to={ROUTES.PROVIDERS} icon={Cpu} label={t("nav.providers")} collapsed={collapsed} />
<SidebarItem to={ROUTES.CLI_CREDENTIALS} icon={KeyRound} label={t("nav.cliCredentials")} collapsed={collapsed} />
<SidebarItem to={ROUTES.API_KEYS} icon={KeyRound} label={t("nav.apiKeys")} collapsed={collapsed} />
<SidebarItem to={ROUTES.CONFIG} icon={Settings} label={t("nav.config")} collapsed={collapsed} />
<SidebarItem to={ROUTES.APPROVALS} icon={ShieldCheck} label={t("nav.approvals")} collapsed={collapsed} />
<SidebarItem to="/docs" icon={FileText} label={t("nav.apiDocs")} collapsed={collapsed} external />
</SidebarGroup>
</nav>
+7 -4
View File
@@ -32,6 +32,7 @@ import enStorage from "./locales/en/storage.json";
import enPendingMessages from "./locales/en/pending-messages.json";
import enContacts from "./locales/en/contacts.json";
import enActivity from "./locales/en/activity.json";
import enApiKeys from "./locales/en/api-keys.json";
// --- VI namespaces ---
import viCommon from "./locales/vi/common.json";
@@ -64,6 +65,7 @@ import viStorage from "./locales/vi/storage.json";
import viPendingMessages from "./locales/vi/pending-messages.json";
import viContacts from "./locales/vi/contacts.json";
import viActivity from "./locales/vi/activity.json";
import viApiKeys from "./locales/vi/api-keys.json";
// --- ZH namespaces ---
import zhCommon from "./locales/zh/common.json";
@@ -96,6 +98,7 @@ import zhStorage from "./locales/zh/storage.json";
import zhPendingMessages from "./locales/zh/pending-messages.json";
import zhContacts from "./locales/zh/contacts.json";
import zhActivity from "./locales/zh/activity.json";
import zhApiKeys from "./locales/zh/api-keys.json";
const STORAGE_KEY = "goclaw:language";
@@ -113,7 +116,7 @@ const ns = [
"agents", "teams", "sessions", "skills", "cron", "config",
"channels", "providers", "traces", "events", "delegations",
"usage", "approvals", "nodes", "logs", "tools", "mcp", "tts",
"setup", "memory", "storage", "pending-messages", "contacts", "activity",
"setup", "memory", "storage", "pending-messages", "contacts", "activity", "api-keys",
] as const;
i18n.use(initReactI18next).init({
@@ -127,7 +130,7 @@ i18n.use(initReactI18next).init({
approvals: enApprovals, nodes: enNodes, logs: enLogs, tools: enTools,
mcp: enMcp, tts: enTts, setup: enSetup, memory: enMemory, storage: enStorage,
"pending-messages": enPendingMessages,
contacts: enContacts, activity: enActivity,
contacts: enContacts, activity: enActivity, "api-keys": enApiKeys,
},
vi: {
common: viCommon, sidebar: viSidebar, topbar: viTopbar, login: viLogin,
@@ -138,7 +141,7 @@ i18n.use(initReactI18next).init({
approvals: viApprovals, nodes: viNodes, logs: viLogs, tools: viTools,
mcp: viMcp, tts: viTts, setup: viSetup, memory: viMemory, storage: viStorage,
"pending-messages": viPendingMessages,
contacts: viContacts, activity: viActivity,
contacts: viContacts, activity: viActivity, "api-keys": viApiKeys,
},
zh: {
common: zhCommon, sidebar: zhSidebar, topbar: zhTopbar, login: zhLogin,
@@ -149,7 +152,7 @@ i18n.use(initReactI18next).init({
approvals: zhApprovals, nodes: zhNodes, logs: zhLogs, tools: zhTools,
mcp: zhMcp, tts: zhTts, setup: zhSetup, memory: zhMemory, storage: zhStorage,
"pending-messages": zhPendingMessages,
contacts: zhContacts, activity: zhActivity,
contacts: zhContacts, activity: zhActivity, "api-keys": zhApiKeys,
},
},
ns: [...ns],
+63
View File
@@ -0,0 +1,63 @@
{
"title": "API Keys",
"description": "Manage gateway API keys with fine-grained permissions.",
"addKey": "Create API Key",
"searchPlaceholder": "Search keys...",
"emptyTitle": "No API keys",
"emptyDescription": "Create your first API key to authenticate with the gateway.",
"columns": {
"name": "Name",
"prefix": "Key",
"scopes": "Scopes",
"lastUsed": "Last Used",
"created": "Created",
"status": "Status",
"actions": "Actions"
},
"status": {
"active": "Active",
"revoked": "Revoked",
"expired": "Expired"
},
"form": {
"title": "Create API Key",
"name": "Name",
"namePlaceholder": "e.g. ci-deploy, dashboard-readonly",
"scopes": "Scopes",
"scopeDescriptions": {
"operator.admin": "Full admin access",
"operator.read": "Read-only access",
"operator.write": "Read + write access",
"operator.approvals": "Manage exec approvals",
"operator.pairing": "Manage device pairing"
},
"expiry": "Expiry",
"expiryOptions": {
"never": "Never",
"7d": "7 days",
"30d": "30 days",
"90d": "90 days"
},
"create": "Create",
"creating": "Creating..."
},
"created": {
"title": "API Key Created",
"description": "Copy this key now — it won't be shown again.",
"copied": "Copied!",
"copy": "Copy to clipboard",
"done": "Done"
},
"revoke": {
"title": "Revoke API Key",
"description": "Are you sure you want to revoke \"{{name}}\"? This cannot be undone.",
"confirmLabel": "Revoke"
},
"toast": {
"created": "API key created",
"revoked": "API key revoked",
"failedCreate": "Failed to create API key",
"failedRevoke": "Failed to revoke API key"
},
"never": "Never"
}
+4 -1
View File
@@ -35,6 +35,9 @@
"approvals": "Approvals",
"nodes": "Nodes",
"tts": "TTS",
"activity": "Activity"
"activity": "Activity",
"cliCredentials": "CLI Credentials",
"apiKeys": "API Keys",
"apiDocs": "API Docs"
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"title": "API Keys",
"description": "Quản lý khóa API gateway với phân quyền chi tiết.",
"addKey": "Tạo API Key",
"searchPlaceholder": "Tìm khóa...",
"emptyTitle": "Chưa có API key",
"emptyDescription": "Tạo API key đầu tiên để xác thực với gateway.",
"columns": {
"name": "Tên",
"prefix": "Khóa",
"scopes": "Phạm vi",
"lastUsed": "Lần dùng cuối",
"created": "Ngày tạo",
"status": "Trạng thái",
"actions": "Thao tác"
},
"status": {
"active": "Hoạt động",
"revoked": "Đã thu hồi",
"expired": "Hết hạn"
},
"form": {
"title": "Tạo API Key",
"name": "Tên",
"namePlaceholder": "vd. ci-deploy, dashboard-readonly",
"scopes": "Phạm vi quyền",
"scopeDescriptions": {
"operator.admin": "Toàn quyền quản trị",
"operator.read": "Chỉ đọc",
"operator.write": "Đọc + ghi",
"operator.approvals": "Quản lý phê duyệt",
"operator.pairing": "Quản lý ghép nối"
},
"expiry": "Hạn sử dụng",
"expiryOptions": {
"never": "Không giới hạn",
"7d": "7 ngày",
"30d": "30 ngày",
"90d": "90 ngày"
},
"create": "Tạo",
"creating": "Đang tạo..."
},
"created": {
"title": "Đã tạo API Key",
"description": "Sao chép khóa ngay — sẽ không hiển thị lại.",
"copied": "Đã sao chép!",
"copy": "Sao chép",
"done": "Xong"
},
"revoke": {
"title": "Thu hồi API Key",
"description": "Bạn chắc chắn muốn thu hồi \"{{name}}\"? Không thể hoàn tác.",
"confirmLabel": "Thu hồi"
},
"toast": {
"created": "Đã tạo API key",
"revoked": "Đã thu hồi API key",
"failedCreate": "Không thể tạo API key",
"failedRevoke": "Không thể thu hồi API key"
},
"never": "Không giới hạn"
}
+4 -1
View File
@@ -35,6 +35,9 @@
"approvals": "Phê duyệt",
"nodes": "Nodes",
"tts": "TTS",
"activity": "Hoạt động"
"activity": "Hoạt động",
"cliCredentials": "Xác thực CLI",
"apiKeys": "Khóa API",
"apiDocs": "Tài liệu API"
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"title": "API 密钥",
"description": "管理网关 API 密钥及细粒度权限。",
"addKey": "创建 API 密钥",
"searchPlaceholder": "搜索密钥...",
"emptyTitle": "暂无 API 密钥",
"emptyDescription": "创建第一个 API 密钥以对网关进行身份验证。",
"columns": {
"name": "名称",
"prefix": "密钥",
"scopes": "权限范围",
"lastUsed": "最后使用",
"created": "创建时间",
"status": "状态",
"actions": "操作"
},
"status": {
"active": "活跃",
"revoked": "已撤销",
"expired": "已过期"
},
"form": {
"title": "创建 API 密钥",
"name": "名称",
"namePlaceholder": "例如 ci-deploy、dashboard-readonly",
"scopes": "权限范围",
"scopeDescriptions": {
"operator.admin": "完全管理员权限",
"operator.read": "只读权限",
"operator.write": "读写权限",
"operator.approvals": "管理执行审批",
"operator.pairing": "管理设备配对"
},
"expiry": "有效期",
"expiryOptions": {
"never": "永不过期",
"7d": "7 天",
"30d": "30 天",
"90d": "90 天"
},
"create": "创建",
"creating": "创建中..."
},
"created": {
"title": "API 密钥已创建",
"description": "请立即复制密钥 — 之后将不再显示。",
"copied": "已复制!",
"copy": "复制到剪贴板",
"done": "完成"
},
"revoke": {
"title": "撤销 API 密钥",
"description": "确定要撤销 \"{{name}}\" 吗?此操作不可撤销。",
"confirmLabel": "撤销"
},
"toast": {
"created": "API 密钥已创建",
"revoked": "API 密钥已撤销",
"failedCreate": "创建 API 密钥失败",
"failedRevoke": "撤销 API 密钥失败"
},
"never": "永不"
}
+4 -1
View File
@@ -35,6 +35,9 @@
"traces": "追踪",
"tts": "TTS",
"usage": "用量",
"activity": "活动日志"
"activity": "活动日志",
"cliCredentials": "CLI 凭证",
"apiKeys": "API 密钥",
"apiDocs": "API 文档"
}
}
+2
View File
@@ -28,6 +28,7 @@ export const ROUTES = {
TEAM_DETAIL: "/teams/:id",
CUSTOM_TOOLS: "/custom-tools",
BUILTIN_TOOLS: "/builtin-tools",
CLI_CREDENTIALS: "/cli-credentials",
MCP: "/mcp",
TTS: "/tts",
STORAGE: "/storage",
@@ -35,6 +36,7 @@ export const ROUTES = {
MEMORY: "/memory",
KNOWLEDGE_GRAPH: "/knowledge-graph",
ACTIVITY: "/activity",
API_KEYS: "/api-keys",
SETUP: "/setup",
} as const;
+6
View File
@@ -1,4 +1,7 @@
export const queryKeys = {
apiKeys: {
all: ["apiKeys"] as const,
},
providers: {
all: ["providers"] as const,
models: (providerId: string) => ["providers", providerId, "models"] as const,
@@ -22,6 +25,9 @@ export const queryKeys = {
all: ["customTools"] as const,
list: (params: Record<string, unknown>) => ["customTools", params] as const,
},
cliCredentials: {
all: ["cliCredentials"] as const,
},
mcp: {
all: ["mcp"] as const,
},
@@ -0,0 +1,140 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ApiKeyCreateInput } from "@/types/api-key";
const ALL_SCOPES = [
"operator.admin",
"operator.read",
"operator.write",
"operator.approvals",
"operator.pairing",
] as const;
const EXPIRY_OPTIONS = [
{ value: "never", seconds: 0 },
{ value: "7d", seconds: 7 * 86400 },
{ value: "30d", seconds: 30 * 86400 },
{ value: "90d", seconds: 90 * 86400 },
] as const;
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreate: (input: ApiKeyCreateInput) => Promise<void>;
}
export function ApiKeyCreateDialog({ open, onOpenChange, onCreate }: Props) {
const { t } = useTranslation("api-keys");
const [name, setName] = useState("");
const [scopes, setScopes] = useState<string[]>([]);
const [expiry, setExpiry] = useState("never");
const [saving, setSaving] = useState(false);
const toggleScope = (scope: string) => {
setScopes((prev) =>
prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope],
);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || scopes.length === 0) return;
setSaving(true);
try {
const expiryOption = EXPIRY_OPTIONS.find((o) => o.value === expiry);
await onCreate({
name: name.trim(),
scopes,
expires_in: expiryOption && expiryOption.seconds > 0 ? expiryOption.seconds : undefined,
});
// Reset form
setName("");
setScopes([]);
setExpiry("never");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-sm:inset-0 max-sm:translate-x-0 max-sm:translate-y-0 sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("form.title")}</DialogTitle>
<DialogDescription>{t("description")}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="key-name">{t("form.name")}</Label>
<Input
id="key-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("form.namePlaceholder")}
className="text-base md:text-sm"
required
/>
</div>
<div className="space-y-2">
<Label>{t("form.scopes")}</Label>
<div className="space-y-2">
{ALL_SCOPES.map((scope) => (
<label key={scope} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={scopes.includes(scope)}
onChange={() => toggleScope(scope)}
className="h-4 w-4 rounded border-gray-300"
/>
<span className="text-base md:text-sm">
<span className="font-medium">{scope.replace("operator.", "")}</span>
<span className="text-muted-foreground ml-1">
— {t(`form.scopeDescriptions.${scope}`)}
</span>
</span>
</label>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="key-expiry">{t("form.expiry")}</Label>
<select
id="key-expiry"
value={expiry}
onChange={(e) => setExpiry(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base md:text-sm shadow-sm"
>
{EXPIRY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{t(`form.expiryOptions.${opt.value}`)}
</option>
))}
</select>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t("created.done")}
</Button>
<Button type="submit" disabled={saving || !name.trim() || scopes.length === 0}>
{saving ? t("form.creating") : t("form.create")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+213
View File
@@ -0,0 +1,213 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Plus, RefreshCw, Key, Ban, Copy, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/shared/page-header";
import { EmptyState } from "@/components/shared/empty-state";
import { SearchInput } from "@/components/shared/search-input";
import { Pagination } from "@/components/shared/pagination";
import { TableSkeleton } from "@/components/shared/loading-skeleton";
import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { usePagination } from "@/hooks/use-pagination";
import { useApiKeys } from "./hooks/use-api-keys";
import { ApiKeyCreateDialog } from "./api-key-create-dialog";
import type { ApiKeyData } from "@/types/api-key";
function formatDate(iso: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
function keyStatus(key: ApiKeyData, t: (k: string) => string): { label: string; variant: "default" | "secondary" | "destructive" } {
if (key.revoked) return { label: t("status.revoked"), variant: "destructive" };
if (key.expires_at && new Date(key.expires_at) < new Date()) return { label: t("status.expired"), variant: "secondary" };
return { label: t("status.active"), variant: "default" };
}
export function ApiKeysPage() {
const { t } = useTranslation("api-keys");
const { t: tc } = useTranslation("common");
const { apiKeys, loading, refresh, createApiKey, revokeApiKey } = useApiKeys();
const spinning = useMinLoading(loading);
const showSkeleton = useDeferredLoading(loading && apiKeys.length === 0);
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [revokeTarget, setRevokeTarget] = useState<ApiKeyData | null>(null);
const [revokeLoading, setRevokeLoading] = useState(false);
const [newKeyRaw, setNewKeyRaw] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const filtered = apiKeys.filter(
(k) => k.name.toLowerCase().includes(search.toLowerCase()) || k.prefix.includes(search),
);
const { pageItems, pagination, setPage, setPageSize, resetPage } = usePagination(filtered);
useEffect(() => { resetPage(); }, [search, resetPage]);
const handleRevoke = async () => {
if (!revokeTarget) return;
setRevokeLoading(true);
try {
await revokeApiKey(revokeTarget.id);
setRevokeTarget(null);
} finally {
setRevokeLoading(false);
}
};
const handleCopy = async () => {
if (!newKeyRaw) return;
await navigator.clipboard.writeText(newKeyRaw);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="p-4 sm:p-6">
<PageHeader
title={t("title")}
description={t("description")}
actions={
<div className="flex gap-2">
<Button size="sm" onClick={() => setCreateOpen(true)} className="gap-1">
<Plus className="h-3.5 w-3.5" /> {t("addKey")}
</Button>
<Button variant="outline" size="sm" onClick={refresh} disabled={spinning} className="gap-1">
<RefreshCw className={spinning ? "animate-spin h-3.5 w-3.5" : "h-3.5 w-3.5"} /> {tc("refresh")}
</Button>
</div>
}
/>
<div className="mt-4">
<SearchInput value={search} onChange={setSearch} placeholder={t("searchPlaceholder")} className="max-w-sm" />
</div>
<div className="mt-4">
{showSkeleton ? (
<TableSkeleton rows={5} />
) : filtered.length === 0 ? (
<EmptyState icon={Key} title={t("emptyTitle")} description={t("emptyDescription")} />
) : (
<>
<div className="rounded-md border overflow-x-auto">
<table className="w-full min-w-[700px] text-base md:text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-2 text-left font-medium">{t("columns.name")}</th>
<th className="px-4 py-2 text-left font-medium">{t("columns.prefix")}</th>
<th className="px-4 py-2 text-left font-medium">{t("columns.scopes")}</th>
<th className="px-4 py-2 text-left font-medium">{t("columns.lastUsed")}</th>
<th className="px-4 py-2 text-left font-medium">{t("columns.created")}</th>
<th className="px-4 py-2 text-left font-medium">{t("columns.status")}</th>
<th className="px-4 py-2 text-right font-medium">{t("columns.actions")}</th>
</tr>
</thead>
<tbody>
{pageItems.map((key) => {
const status = keyStatus(key, t);
return (
<tr key={key.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2 font-medium">{key.name}</td>
<td className="px-4 py-2">
<code className="rounded bg-muted px-1.5 py-0.5 text-xs">{key.prefix}...***</code>
</td>
<td className="px-4 py-2">
<div className="flex flex-wrap gap-1">
{key.scopes.map((s) => (
<Badge key={s} variant="outline" className="text-xs">
{s.replace("operator.", "")}
</Badge>
))}
</div>
</td>
<td className="px-4 py-2 text-muted-foreground">{formatDate(key.last_used_at)}</td>
<td className="px-4 py-2 text-muted-foreground">{formatDate(key.created_at)}</td>
<td className="px-4 py-2">
<Badge variant={status.variant}>{status.label}</Badge>
</td>
<td className="px-4 py-2 text-right">
{!key.revoked && (
<Button
variant="ghost"
size="sm"
onClick={() => setRevokeTarget(key)}
className="gap-1 text-destructive hover:text-destructive"
>
<Ban className="h-3.5 w-3.5" />
</Button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<Pagination {...pagination} onPageChange={setPage} onPageSizeChange={setPageSize} />
</>
)}
</div>
<ApiKeyCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreate={async (input) => {
const res = await createApiKey(input);
setCreateOpen(false);
setNewKeyRaw(res.key);
}}
/>
{/* Show-once key dialog */}
<Dialog open={!!newKeyRaw} onOpenChange={(open) => !open && setNewKeyRaw(null)}>
<DialogContent className="max-sm:inset-0 max-sm:translate-x-0 max-sm:translate-y-0 sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("created.title")}</DialogTitle>
<DialogDescription>{t("created.description")}</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-muted px-3 py-2 text-base md:text-sm font-mono break-all">
{newKeyRaw}
</code>
<Button variant="outline" size="sm" onClick={handleCopy} className="gap-1 shrink-0">
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? t("created.copied") : t("created.copy")}
</Button>
</div>
<DialogFooter>
<Button onClick={() => setNewKeyRaw(null)}>{t("created.done")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDeleteDialog
open={!!revokeTarget}
onOpenChange={(v) => !v && setRevokeTarget(null)}
title={t("revoke.title")}
description={t("revoke.description", { name: revokeTarget?.name })}
confirmValue={revokeTarget?.name || ""}
confirmLabel={t("revoke.confirmLabel")}
onConfirm={handleRevoke}
loading={revokeLoading}
/>
</div>
);
}
@@ -0,0 +1,54 @@
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import i18next from "i18next";
import { useHttp } from "@/hooks/use-ws";
import { queryKeys } from "@/lib/query-keys";
import { toast } from "@/stores/use-toast-store";
import type { ApiKeyData, ApiKeyCreateInput, ApiKeyCreateResponse } from "@/types/api-key";
export function useApiKeys() {
const http = useHttp();
const queryClient = useQueryClient();
const { data: apiKeys = [], isLoading: loading } = useQuery({
queryKey: queryKeys.apiKeys.all,
queryFn: () => http.get<ApiKeyData[]>("/v1/api-keys"),
staleTime: 60_000,
});
const invalidate = useCallback(
() => queryClient.invalidateQueries({ queryKey: queryKeys.apiKeys.all }),
[queryClient],
);
const createApiKey = useCallback(
async (data: ApiKeyCreateInput): Promise<ApiKeyCreateResponse> => {
try {
const res = await http.post<ApiKeyCreateResponse>("/v1/api-keys", data);
await invalidate();
toast.success(i18next.t("api-keys:toast.created"));
return res;
} catch (err) {
toast.error(i18next.t("api-keys:toast.failedCreate"), err instanceof Error ? err.message : "");
throw err;
}
},
[http, invalidate],
);
const revokeApiKey = useCallback(
async (id: string) => {
try {
await http.post(`/v1/api-keys/${id}/revoke`, {});
await invalidate();
toast.success(i18next.t("api-keys:toast.revoked"));
} catch (err) {
toast.error(i18next.t("api-keys:toast.failedRevoke"), err instanceof Error ? err.message : "");
throw err;
}
},
[http, invalidate],
);
return { apiKeys, loading, refresh: invalidate, createApiKey, revokeApiKey };
}
@@ -0,0 +1,288 @@
import { useState, useEffect } from "react";
import {
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import type { SecureCLIBinary, CLICredentialInput, CLIPreset } from "./hooks/use-cli-credentials";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
credential?: SecureCLIBinary | null;
presets: Record<string, CLIPreset>;
onSubmit: (data: CLICredentialInput) => Promise<unknown>;
}
const NONE_PRESET = "__none__";
export function CliCredentialFormDialog({ open, onOpenChange, credential, presets, onSubmit }: Props) {
const [selectedPreset, setSelectedPreset] = useState(NONE_PRESET);
const [binaryName, setBinaryName] = useState("");
const [binaryPath, setBinaryPath] = useState("");
const [description, setDescription] = useState("");
const [denyArgs, setDenyArgs] = useState("");
const [denyVerbose, setDenyVerbose] = useState("");
const [timeout, setTimeout] = useState(30);
const [tips, setTips] = useState("");
const [agentId, setAgentId] = useState("");
const [enabled, setEnabled] = useState(true);
const [envValues, setEnvValues] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const isEdit = !!credential;
// Build typed entry list to avoid noUncheckedIndexedAccess issues
const presetEntries: Array<[string, CLIPreset]> = Object.entries(presets).filter(
(e): e is [string, CLIPreset] => e[1] !== undefined,
);
// Current preset definition (for env var fields)
const activePreset: CLIPreset | null =
selectedPreset !== NONE_PRESET ? (presets[selectedPreset] ?? null) : null;
useEffect(() => {
if (!open) return;
setSelectedPreset(NONE_PRESET);
setBinaryName(credential?.binary_name ?? "");
setBinaryPath(credential?.binary_path ?? "");
setDescription(credential?.description ?? "");
setDenyArgs((credential?.deny_args ?? []).join(", "));
setDenyVerbose((credential?.deny_verbose ?? []).join(", "));
setTimeout(credential?.timeout_seconds ?? 30);
setTips(credential?.tips ?? "");
setAgentId(credential?.agent_id ?? "");
setEnabled(credential?.enabled ?? true);
setEnvValues({});
setError("");
}, [open, credential]);
const applyPreset = (key: string) => {
setSelectedPreset(key);
if (key === NONE_PRESET) return;
const p = presets[key];
if (!p) return;
setBinaryName(p.binary_name);
setDescription(p.description);
setDenyArgs(p.deny_args.join(", "));
setDenyVerbose(p.deny_verbose.join(", "));
setTimeout(p.timeout);
setTips(p.tips);
setEnvValues({});
};
const splitCommaList = (v: string): string[] =>
v.split(",").map((s) => s.trim()).filter(Boolean);
const handleSubmit = async () => {
if (!binaryName.trim()) {
setError("Binary name is required.");
return;
}
setLoading(true);
setError("");
try {
const payload: CLICredentialInput = {
binary_name: binaryName.trim(),
binary_path: binaryPath.trim() || undefined,
description: description.trim(),
deny_args: splitCommaList(denyArgs),
deny_verbose: splitCommaList(denyVerbose),
timeout_seconds: timeout,
tips: tips.trim(),
agent_id: agentId.trim() || undefined,
enabled,
};
if (selectedPreset !== NONE_PRESET) payload.preset = selectedPreset;
if (Object.keys(envValues).length > 0) payload.env = envValues;
await onSubmit(payload);
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save.");
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => !loading && onOpenChange(v)}>
<DialogContent className="max-h-[85vh] flex flex-col sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit CLI Credential" : "Add CLI Credential"}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2 px-0.5 -mx-0.5 overflow-y-auto min-h-0">
{/* Preset selector — only on create */}
{!isEdit && presetEntries.length > 0 && (
<div className="grid gap-1.5">
<Label>Preset</Label>
<Select value={selectedPreset} onValueChange={applyPreset}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue placeholder="Select a preset (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_PRESET}>No preset (manual)</SelectItem>
{presetEntries.map(([k, p]) => (
<SelectItem key={k} value={k}>
{p.binary_name} — {p.description}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Selecting a preset auto-fills fields and shows required env vars.
</p>
</div>
)}
{/* Existing credentials indicator (edit mode) */}
{isEdit && (
<p className="text-xs text-muted-foreground rounded-md border border-dashed p-2">
Credentials are encrypted and cannot be displayed. Leave environment variables empty to keep existing values, or re-enter to update.
</p>
)}
{/* Env var inputs from preset */}
{activePreset && activePreset.env_vars.length > 0 && (
<div className="grid gap-3 rounded-md border p-3">
<p className="text-sm font-medium">Environment Variables</p>
{activePreset.env_vars.map((ev) => (
<div key={ev.name} className="grid gap-1.5">
<Label htmlFor={`env-${ev.name}`}>
{ev.name}
{ev.optional && <span className="ml-1 text-xs text-muted-foreground">(optional)</span>}
</Label>
<Input
id={`env-${ev.name}`}
type="password"
autoComplete="off"
placeholder={ev.desc}
value={envValues[ev.name] ?? ""}
onChange={(e) => setEnvValues((prev) => ({ ...prev, [ev.name]: e.target.value }))}
className="text-base md:text-sm"
/>
{ev.desc && (
<p className="text-xs text-muted-foreground">{ev.desc}</p>
)}
</div>
))}
</div>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="cc-name">Binary Name</Label>
<Input
id="cc-name"
value={binaryName}
onChange={(e) => setBinaryName(e.target.value)}
placeholder="e.g. gh"
className="text-base md:text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cc-path">Binary Path <span className="text-xs text-muted-foreground">(optional)</span></Label>
<Input
id="cc-path"
value={binaryPath}
onChange={(e) => setBinaryPath(e.target.value)}
placeholder="/usr/local/bin/gh"
className="text-base md:text-sm"
/>
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cc-desc">Description</Label>
<Textarea
id="cc-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What this CLI tool does"
rows={2}
className="text-base md:text-sm"
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="cc-deny-args">Deny Args <span className="text-xs text-muted-foreground">(comma-separated)</span></Label>
<Input
id="cc-deny-args"
value={denyArgs}
onChange={(e) => setDenyArgs(e.target.value)}
placeholder="--admin, --force"
className="text-base md:text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cc-timeout">Timeout (s)</Label>
<Input
id="cc-timeout"
type="number"
min={1}
value={timeout}
onChange={(e) => setTimeout(Number(e.target.value))}
className="text-base md:text-sm"
/>
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cc-deny-verbose">Deny Verbose Args <span className="text-xs text-muted-foreground">(comma-separated)</span></Label>
<Input
id="cc-deny-verbose"
value={denyVerbose}
onChange={(e) => setDenyVerbose(e.target.value)}
placeholder="-v, --verbose"
className="text-base md:text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cc-tips">Tips</Label>
<Textarea
id="cc-tips"
value={tips}
onChange={(e) => setTips(e.target.value)}
placeholder="Usage tips for the agent"
rows={2}
className="text-base md:text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cc-agent">Agent ID <span className="text-xs text-muted-foreground">(optional — leave blank for global)</span></Label>
<Input
id="cc-agent"
value={agentId}
onChange={(e) => setAgentId(e.target.value)}
placeholder="agent-id"
className="text-base md:text-sm"
/>
</div>
<div className="flex items-center gap-2">
<Switch id="cc-enabled" checked={enabled} onCheckedChange={setEnabled} />
<Label htmlFor="cc-enabled">Enabled</Label>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>Cancel</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading ? "Saving..." : isEdit ? "Update" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,173 @@
import { useState } from "react";
import { KeyRound, Plus, RefreshCw, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/shared/page-header";
import { EmptyState } from "@/components/shared/empty-state";
import { TableSkeleton } from "@/components/shared/loading-skeleton";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { useCliCredentials, useCliCredentialPresets } from "./hooks/use-cli-credentials";
import { CliCredentialFormDialog } from "./cli-credential-form-dialog";
import type { SecureCLIBinary, CLICredentialInput } from "./hooks/use-cli-credentials";
export function CliCredentialsPage() {
const [formOpen, setFormOpen] = useState(false);
const [editItem, setEditItem] = useState<SecureCLIBinary | null>(null);
const [deleteTarget, setDeleteTarget] = useState<SecureCLIBinary | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const { items, loading, refresh, createCredential, updateCredential, deleteCredential } =
useCliCredentials();
const { presets } = useCliCredentialPresets();
const spinning = useMinLoading(loading);
const showSkeleton = useDeferredLoading(loading && items.length === 0);
const handleCreate = async (data: CLICredentialInput) => {
await createCredential(data);
};
const handleEdit = async (data: CLICredentialInput) => {
if (!editItem) return;
await updateCredential(editItem.id, data);
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleteLoading(true);
try {
await deleteCredential(deleteTarget.id);
setDeleteTarget(null);
} finally {
setDeleteLoading(false);
}
};
const openCreate = () => {
setEditItem(null);
setFormOpen(true);
};
const openEdit = (item: SecureCLIBinary) => {
setEditItem(item);
setFormOpen(true);
};
return (
<div className="p-4 sm:p-6">
<PageHeader
title="CLI Credentials"
description="Manage secure CLI binaries available to agents with injected credentials."
actions={
<div className="flex gap-2">
<Button size="sm" onClick={openCreate} className="gap-1">
<Plus className="h-3.5 w-3.5" /> Add Credential
</Button>
<Button variant="outline" size="sm" onClick={refresh} disabled={spinning} className="gap-1">
<RefreshCw className={"h-3.5 w-3.5" + (spinning ? " animate-spin" : "")} /> Refresh
</Button>
</div>
}
/>
<div className="mt-4">
{showSkeleton ? (
<TableSkeleton rows={5} />
) : items.length === 0 ? (
<EmptyState
icon={KeyRound}
title="No CLI credentials yet"
description="Add a CLI credential to allow agents to run authenticated CLI commands."
/>
) : (
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[600px] text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">Binary</th>
<th className="px-4 py-3 text-left font-medium">Description</th>
<th className="px-4 py-3 text-left font-medium">Scope</th>
<th className="px-4 py-3 text-left font-medium">Enabled</th>
<th className="px-4 py-3 text-left font-medium">Timeout</th>
<th className="px-4 py-3 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<div className="font-medium">{item.binary_name}</div>
{item.binary_path && (
<div className="text-xs text-muted-foreground font-mono">{item.binary_path}</div>
)}
</div>
</div>
</td>
<td className="px-4 py-3 text-muted-foreground max-w-[220px] truncate">
{item.description || "—"}
</td>
<td className="px-4 py-3">
<Badge variant={item.agent_id ? "secondary" : "outline"}>
{item.agent_id ? "Agent" : "Global"}
</Badge>
</td>
<td className="px-4 py-3">
<Badge variant={item.enabled ? "default" : "secondary"}>
{item.enabled ? "Enabled" : "Disabled"}
</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">{item.timeout_seconds}s</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => openEdit(item)}
className="gap-1"
>
<Pencil className="h-3.5 w-3.5" /> Edit
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(item)}
className="gap-1 text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<CliCredentialFormDialog
open={formOpen}
onOpenChange={setFormOpen}
credential={editItem}
presets={presets}
onSubmit={editItem ? handleEdit : handleCreate}
/>
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
title="Delete CLI Credential"
description={`Remove "${deleteTarget?.binary_name}"? Agents that depend on it will lose access.`}
confirmLabel="Delete"
variant="destructive"
onConfirm={handleDelete}
loading={deleteLoading}
/>
</div>
);
}
@@ -0,0 +1,91 @@
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useHttp } from "@/hooks/use-ws";
import { toast } from "@/stores/use-toast-store";
import type { SecureCLIBinary, CLICredentialInput, CLIPreset } from "@/types/cli-credential";
export type { SecureCLIBinary, CLICredentialInput, CLIPreset };
const QUERY_KEY = ["cliCredentials"] as const;
const PRESETS_KEY = ["cliCredentials", "presets"] as const;
export function useCliCredentials() {
const http = useHttp();
const queryClient = useQueryClient();
const { data, isLoading: loading } = useQuery({
queryKey: QUERY_KEY,
queryFn: async () => {
const res = await http.get<{ items: SecureCLIBinary[] }>("/v1/cli-credentials");
return res.items ?? [];
},
placeholderData: (prev) => prev,
});
const items = data ?? [];
const invalidate = useCallback(
() => queryClient.invalidateQueries({ queryKey: QUERY_KEY }),
[queryClient],
);
const createCredential = useCallback(
async (input: CLICredentialInput) => {
try {
const res = await http.post<SecureCLIBinary>("/v1/cli-credentials", input);
await invalidate();
toast.success("CLI credential created", `"${input.binary_name}" added successfully.`);
return res;
} catch (err) {
toast.error("Failed to create credential", err instanceof Error ? err.message : "");
throw err;
}
},
[http, invalidate],
);
const updateCredential = useCallback(
async (id: string, input: Partial<CLICredentialInput>) => {
try {
await http.put(`/v1/cli-credentials/${id}`, input);
await invalidate();
toast.success("CLI credential updated");
} catch (err) {
toast.error("Failed to update credential", err instanceof Error ? err.message : "");
throw err;
}
},
[http, invalidate],
);
const deleteCredential = useCallback(
async (id: string) => {
try {
await http.delete(`/v1/cli-credentials/${id}`);
await invalidate();
toast.success("CLI credential deleted");
} catch (err) {
toast.error("Failed to delete credential", err instanceof Error ? err.message : "");
throw err;
}
},
[http, invalidate],
);
return { items, loading, refresh: invalidate, createCredential, updateCredential, deleteCredential };
}
export function useCliCredentialPresets() {
const http = useHttp();
const { data, isLoading } = useQuery({
queryKey: PRESETS_KEY,
queryFn: async () => {
const res = await http.get<{ presets: Record<string, CLIPreset> }>("/v1/cli-credentials/presets");
return res.presets ?? {};
},
staleTime: 5 * 60 * 1000,
});
return { presets: data ?? {}, loading: isLoading };
}
+8
View File
@@ -90,6 +90,12 @@ const ContactsPage = lazy(() =>
const ActivityPage = lazy(() =>
import("@/pages/activity/activity-page").then((m) => ({ default: m.ActivityPage })),
);
const CliCredentialsPage = lazy(() =>
import("@/pages/cli-credentials/cli-credentials-page").then((m) => ({ default: m.CliCredentialsPage })),
);
const ApiKeysPage = lazy(() =>
import("@/pages/api-keys/api-keys-page").then((m) => ({ default: m.ApiKeysPage })),
);
function PageLoader() {
return (
@@ -161,6 +167,8 @@ export function AppRoutes() {
<Route path={ROUTES.PENDING_MESSAGES} element={<PendingMessagesPage />} />
<Route path={ROUTES.MEMORY} element={<MemoryPage />} />
<Route path={ROUTES.KNOWLEDGE_GRAPH} element={<KnowledgeGraphPage />} />
<Route path={ROUTES.CLI_CREDENTIALS} element={<CliCredentialsPage />} />
<Route path={ROUTES.API_KEYS} element={<ApiKeysPage />} />
</Route>
{/* Catch-all → overview */}
+22
View File
@@ -0,0 +1,22 @@
export interface ApiKeyData {
id: string;
name: string;
prefix: string;
scopes: string[];
expires_at: string | null;
last_used_at: string | null;
revoked: boolean;
created_by: string;
created_at: string;
updated_at: string;
}
export interface ApiKeyCreateInput {
name: string;
scopes: string[];
expires_in?: number; // seconds; undefined = never
}
export interface ApiKeyCreateResponse extends ApiKeyData {
key: string; // raw key, shown only once
}
+46
View File
@@ -0,0 +1,46 @@
export interface SecureCLIBinary {
id: string;
binary_name: string;
binary_path?: string;
description: string;
deny_args: string[];
deny_verbose: string[];
timeout_seconds: number;
tips: string;
agent_id?: string;
enabled: boolean;
created_by: string;
created_at: string;
updated_at: string;
}
export interface CLIPresetEnvVar {
name: string;
desc: string;
is_file?: boolean;
optional?: boolean;
}
export interface CLIPreset {
binary_name: string;
description: string;
env_vars: CLIPresetEnvVar[];
deny_args: string[];
deny_verbose: string[];
timeout: number;
tips: string;
}
export interface CLICredentialInput {
preset?: string;
binary_name: string;
binary_path?: string;
description?: string;
deny_args?: string[];
deny_verbose?: string[];
timeout_seconds?: number;
tips?: string;
agent_id?: string;
enabled?: boolean;
env?: Record<string, string>;
}