mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 00:16:38 +00:00
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:
94 files changed
+6332
-286
No files matched your search
@@ -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 }}'
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()...)
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ const (
|
||||
CacheKindSkillGrants = "skill_grants"
|
||||
CacheKindMCP = "mcp"
|
||||
CacheKindProvider = "provider"
|
||||
CacheKindAPIKeys = "api_keys"
|
||||
)
|
||||
|
||||
// Topic constants for msgBus.Subscribe() / Broadcast().
|
||||
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¶ms); 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, ¶ms); 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"}))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 ---
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>`
|
||||
@@ -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" } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 "***".
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -25,4 +25,6 @@ type Stores struct {
|
||||
Contacts ContactStore
|
||||
Activity ActivityStore
|
||||
Snapshots SnapshotStore
|
||||
SecureCLI SecureCLIStore
|
||||
APIKeys APIKeyStore
|
||||
}
|
||||
@@ -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, ", ")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -35,6 +35,9 @@
|
||||
"approvals": "Approvals",
|
||||
"nodes": "Nodes",
|
||||
"tts": "TTS",
|
||||
"activity": "Activity"
|
||||
"activity": "Activity",
|
||||
"cliCredentials": "CLI Credentials",
|
||||
"apiKeys": "API Keys",
|
||||
"apiDocs": "API Docs"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "永不"
|
||||
}
|
||||
@@ -35,6 +35,9 @@
|
||||
"traces": "追踪",
|
||||
"tts": "TTS",
|
||||
"usage": "用量",
|
||||
"activity": "活动日志"
|
||||
"activity": "活动日志",
|
||||
"cliCredentials": "CLI 凭证",
|
||||
"apiKeys": "API 密钥",
|
||||
"apiDocs": "API 文档"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in new issue
Block a user