mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-08-07 02:25:22 +00:00
Merge pull request #120 from digitopvn/codex/issue-117-agent-scoped-git-credentials-plan
feat(cli-credentials): add agent-scoped git credentials
This commit is contained in:
@@ -112,7 +112,7 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir
|
||||
}
|
||||
|
||||
if stores != nil && stores.SecureCLI != nil {
|
||||
secureCLIH = httpapi.NewSecureCLIHandler(stores.SecureCLI, msgBus)
|
||||
secureCLIH = httpapi.NewSecureCLIHandler(stores.SecureCLI, msgBus, stores.Tenants)
|
||||
}
|
||||
if stores != nil && stores.SecureCLIGrants != nil {
|
||||
secureCLIGrantH = httpapi.NewSecureCLIGrantHandler(stores.SecureCLIGrants, stores.Tenants, msgBus)
|
||||
|
||||
@@ -480,7 +480,14 @@ Pre-computed usage snapshots (hourly aggregations) for analytics dashboards. Tra
|
||||
|
||||
### SecureCLIStore
|
||||
|
||||
CLI binary credential configuration with encrypted environment variable injection. Credentials are auto-injected into child processes without exposing them to command output.
|
||||
CLI binary credential configuration with encrypted environment variable
|
||||
injection. Credentials are auto-injected into child processes without exposing
|
||||
them to command output.
|
||||
|
||||
Credential rows can live at binary, agent, channel/context, or user scope.
|
||||
Runtime resolution prefers user overrides, then context credentials, then agent
|
||||
credentials, then binary defaults. The `secure_cli_agent_credentials` table
|
||||
stores one encrypted PAT/SSH/env payload per `(binary_id, agent_id, tenant_id)`.
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
@@ -492,6 +499,9 @@ CLI binary credential configuration with encrypted environment variable injectio
|
||||
| `ListByAgent(agentID)` | Return configs for a specific agent |
|
||||
| `LookupByBinary(binaryName, agentID)` | Find best-matching config (agent-specific > global) |
|
||||
| `ListEnabled()` | Return enabled configs for TOOLS.md generation |
|
||||
| `ListAgentCredentials(binaryID)` | Return masked agent credential metadata |
|
||||
| `SetAgentCredentialsTyped(binaryID, agentID, env, type, hostScope)` | Store agent-scoped PAT/SSH/env payload |
|
||||
| `DeleteAgentCredentials(binaryID, agentID)` | Remove an agent-scoped credential |
|
||||
|
||||
### APIKeyStore
|
||||
|
||||
|
||||
+7
-1
@@ -231,7 +231,7 @@ AES-256-GCM encryption for secrets stored in PostgreSQL. Key provided via `GOCLA
|
||||
| LLM provider API keys | `llm_providers` | `api_key` |
|
||||
| MCP server API keys | `mcp_servers` | `api_key` |
|
||||
| Custom tool env vars | `custom_tools` | `env` |
|
||||
| Credentialed CLI env vars | `secure_cli_binaries`, `secure_cli_agent_grants`, `secure_cli_user_credentials` | `encrypted_env` |
|
||||
| Credentialed CLI env vars | `secure_cli_binaries`, `secure_cli_agent_grants`, `secure_cli_user_credentials`, `secure_cli_agent_credentials` | `encrypted_env` |
|
||||
|
||||
**Format**: `"aes-gcm:" + base64(12-byte nonce + ciphertext + GCM tag)`
|
||||
|
||||
@@ -534,6 +534,11 @@ Implementer guide: [credential-adapter-playbook.md](./credential-adapter-playboo
|
||||
Both paths coexist. A typo in `adapter_name` falls back to passthrough, which
|
||||
restores the legacy denylist-only behavior — no silent bypass.
|
||||
|
||||
Runtime credential precedence is explicit: user override, then channel/context
|
||||
credential, then agent credential, then binary-level env defaults. Agent
|
||||
credentials are the default git trust boundary; granting access to an agent
|
||||
also grants the ability to make that agent use its stored git credential.
|
||||
|
||||
### Audit log: `security.system_env_injection`
|
||||
|
||||
Every adapter injection emits **exactly one** structured slog line. Field
|
||||
@@ -547,6 +552,7 @@ the test and operator-facing log-search recipes.
|
||||
| `adapter` | string | e.g. `git`, `psql`, `passthrough` |
|
||||
| `binary` | string | binary name (`git`, `kubectl`, …) |
|
||||
| `user_id` | string | tenant user UUID (empty for global-only contexts) |
|
||||
| `credential_source` | string | `user`, `context`, `agent`, or empty when no scoped credential row was selected |
|
||||
| `env_keys` | []string | sorted env-var NAMES (never values) |
|
||||
| `argv_prefix_len` | int | number of argv elements prepended (NOT their content) |
|
||||
| `host_scope_hash` | string | SHA-256 first 8 hex chars of normalized host_scope, or `"none"` |
|
||||
|
||||
@@ -1173,8 +1173,45 @@ CLI authentication credentials for secure command execution. Requires **admin ro
|
||||
| `DELETE` | `/v1/cli-credentials/{id}` | Delete credential |
|
||||
| `POST` | `/v1/cli-credentials/{id}/test` | Test credential connection (dry-run) |
|
||||
|
||||
### Agent Credentials
|
||||
|
||||
Agent credentials store PAT/SSH/env material for one CLI credential and one
|
||||
agent. They are the default git setup path; agent access controls who can cause
|
||||
the credential to be used. Responses return metadata only and never include raw
|
||||
typed token/key blobs.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/v1/cli-credentials/{id}/agent-credentials` | List agent credentials for CLI cred |
|
||||
| `GET` | `/v1/cli-credentials/{id}/agent-credentials/{agentId}` | Get agent credential metadata |
|
||||
| `PUT` | `/v1/cli-credentials/{id}/agent-credentials/{agentId}` | Set agent credential |
|
||||
| `DELETE` | `/v1/cli-credentials/{id}/agent-credentials/{agentId}` | Delete agent credential |
|
||||
|
||||
Typed git request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"credential_type": "pat",
|
||||
"host_scope": "github.com",
|
||||
"blob": { "token": "ghp_..." }
|
||||
}
|
||||
```
|
||||
|
||||
Env request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"env": {
|
||||
"GH_TOKEN": { "kind": "sensitive", "value": "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Per-User Credentials
|
||||
|
||||
Advanced personal overrides. These remain for backward compatibility and have
|
||||
higher runtime precedence than channel/context and agent credentials.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/v1/cli-credentials/{id}/user-credentials` | List user credentials for CLI cred |
|
||||
|
||||
@@ -335,6 +335,10 @@ CREATE TABLE secure_cli_binaries (
|
||||
| `DELETE` | `/v1/cli-credentials/{id}` | Delete a SecureCLI config |
|
||||
| `POST` | `/v1/cli-credentials/{id}/test` | Dry-run test (requires admin) |
|
||||
| `GET` | `/v1/cli-credentials/presets` | List preset templates for common CLIs |
|
||||
| `GET` | `/v1/cli-credentials/{id}/agent-credentials` | List agent-scoped credentials |
|
||||
| `GET` | `/v1/cli-credentials/{id}/agent-credentials/{agentId}` | Get agent-scoped credential metadata |
|
||||
| `PUT` | `/v1/cli-credentials/{id}/agent-credentials/{agentId}` | Create or replace agent-scoped credential |
|
||||
| `DELETE` | `/v1/cli-credentials/{id}/agent-credentials/{agentId}` | Delete agent-scoped credential |
|
||||
|
||||
### Google Workspace CLI preset
|
||||
|
||||
|
||||
@@ -24,9 +24,31 @@ The typed `git` adapter accepts either a **Personal Access Token (PAT)** or an
|
||||
| **SSH** | Self-hosted git over SSH. You manage `~/.ssh/known_hosts` or accept TOFU risk. | Passphrase-protected keys are NOT supported (see below). |
|
||||
| **Env** | Legacy path — you have a custom env-var-driven workflow. | Loses host-scoped routing; same trust profile as other CLIs. |
|
||||
|
||||
## Adding a credential (UI)
|
||||
## Adding an agent credential (UI)
|
||||
|
||||
1. Open **Settings → CLI Credentials → User Credentials → Add**.
|
||||
Agent credentials are the default path for git auth. They avoid channel-user
|
||||
ID ambiguity: the selected agent owns the credential, and anyone allowed to use
|
||||
that agent can cause it to run git with the stored credential.
|
||||
|
||||
1. Open **Packages → CLI Credentials**.
|
||||
2. Pick the `git` row and open **Agent Credentials**.
|
||||
3. Select the agent.
|
||||
4. Choose **Credential Type**: `Personal Access Token` or `SSH Private Key`.
|
||||
5. Enter **Host Scope** (required for PAT/SSH): the hostname the credential
|
||||
authenticates to.
|
||||
- Examples: `github.com`, `gitlab.example.com`, `gitea.internal:8443`.
|
||||
- Case-insensitive. Punycode normalized via `idna.ToASCII`.
|
||||
- Port included only when non-default for the scheme.
|
||||
6. Paste the token (PAT) or the unencrypted PEM body (SSH).
|
||||
7. Save.
|
||||
|
||||
## Advanced user overrides
|
||||
|
||||
Per-user credentials remain available for personal overrides and backward
|
||||
compatibility. Use them only when a stable tenant user ID is the intended
|
||||
credential boundary.
|
||||
|
||||
1. Open **Packages → CLI Credentials → Advanced User Overrides → Add**.
|
||||
2. Select user.
|
||||
3. Choose **Credential Type**: `Personal Access Token` or `SSH Private Key`.
|
||||
4. Enter **Host Scope** (required for PAT/SSH): the hostname the credential
|
||||
@@ -41,6 +63,13 @@ The stored secret is encrypted (AES-256-GCM) and can never be read back through
|
||||
the API or UI. Editing the row shows a `••••••••` placeholder; leaving the
|
||||
secret field blank preserves the stored value, typing a new value replaces it.
|
||||
|
||||
Effective credential precedence is:
|
||||
|
||||
1. User override.
|
||||
2. Channel/context credential.
|
||||
3. Agent credential.
|
||||
4. Binary-level env defaults.
|
||||
|
||||
## What gets auto-injected
|
||||
|
||||
The adapter runs ONLY for these subcommands:
|
||||
@@ -140,7 +169,7 @@ Every successful credential injection emits exactly one structured log line:
|
||||
|
||||
```
|
||||
level=WARN msg=security.system_env_injection
|
||||
adapter=git binary=git user_id=<uuid>
|
||||
adapter=git binary=git user_id=<uuid> credential_source=agent
|
||||
env_keys=[GIT_CONFIG_COUNT,GIT_CONFIG_KEY_0,GIT_CONFIG_VALUE_0]
|
||||
argv_prefix_len=0
|
||||
host_scope_hash=3aeb0024
|
||||
@@ -159,12 +188,12 @@ See `docs/09-security.md` → "CLI credential adapters" for the full schema.
|
||||
## Migration from legacy env-paste
|
||||
|
||||
Existing rows in `secure_cli_user_credentials` with `credential_type IS NULL`
|
||||
or `= 'env'` continue to work via the passthrough adapter — they keep
|
||||
emitting their env vars exactly as before. There is no forced migration.
|
||||
or `= 'env'` continue to work via the passthrough adapter. Existing user
|
||||
overrides remain higher precedence than agent credentials. There is no forced
|
||||
migration.
|
||||
|
||||
To upgrade an existing git credential, open the user-credentials dialog, pick
|
||||
PAT or SSH, paste the secret, and save. The legacy env-paste row is replaced
|
||||
atomically.
|
||||
To move to the agent-scoped model, create a matching Agent Credential for the
|
||||
agent and remove the user override when the override is no longer needed.
|
||||
|
||||
## Operator notes
|
||||
|
||||
@@ -186,7 +215,8 @@ atomically.
|
||||
|
||||
## Known limitations (v1)
|
||||
|
||||
- One credential per (user, binary, host_scope) row.
|
||||
- One credential per (agent, binary) row, plus legacy one credential per
|
||||
(user, binary) override.
|
||||
- No multi-host wildcard (`*.github.com`).
|
||||
- No passphrase-protected SSH keys.
|
||||
- No persistent `known_hosts` per credential (TOFU only).
|
||||
|
||||
@@ -4,6 +4,29 @@ Significant changes, features, and fixes in reverse chronological order.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-31
|
||||
|
||||
### Agent-scoped git credentials (issue #117)
|
||||
|
||||
**New**
|
||||
|
||||
- Added agent-scoped Secure CLI credentials with PostgreSQL migration `000077`
|
||||
and SQLite schema version `46`.
|
||||
- Added HTTP APIs under
|
||||
`/v1/cli-credentials/{id}/agent-credentials/{agentId}` for listing,
|
||||
reading metadata, saving, and deleting agent credentials.
|
||||
- Web CLI Credentials now exposes Agent Credentials as the primary git PAT/SSH
|
||||
setup path, with User Credentials renamed to advanced personal overrides.
|
||||
|
||||
**Security**
|
||||
|
||||
- Runtime credential precedence is now user override, context credential, agent
|
||||
credential, then binary env defaults.
|
||||
- Git adapter audit logs include `credential_source` without logging raw
|
||||
secrets or plaintext host scopes.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-29
|
||||
|
||||
### Passive channel memory extraction (issue #64)
|
||||
|
||||
@@ -13,6 +13,7 @@ type Service struct {
|
||||
onJob JobHandler
|
||||
running bool
|
||||
stopChan chan struct{}
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
runLog []RunLogEntry // in-memory run history (last 200 entries)
|
||||
retryCfg RetryConfig // retry config for failed jobs
|
||||
@@ -89,7 +90,11 @@ func (cs *Service) Start() error {
|
||||
// after a previous Stop() returned but the runLoop goroutine hasn't yet
|
||||
// executed its ticker construction.
|
||||
tick := runLoopTickInterval
|
||||
go cs.runLoop(cs.stopChan, tick)
|
||||
cs.wg.Add(1)
|
||||
go func() {
|
||||
defer cs.wg.Done()
|
||||
cs.runLoop(cs.stopChan, tick)
|
||||
}()
|
||||
|
||||
slog.Info("cron service started", "jobs", len(cs.store.Jobs))
|
||||
return nil
|
||||
@@ -98,14 +103,17 @@ func (cs *Service) Start() error {
|
||||
// Stop halts the scheduling loop.
|
||||
func (cs *Service) Stop() {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
if !cs.running {
|
||||
cs.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
close(cs.stopChan)
|
||||
cs.running = false
|
||||
cs.mu.Unlock()
|
||||
|
||||
cs.wg.Wait()
|
||||
slog.Info("cron service stopped")
|
||||
}
|
||||
|
||||
|
||||
@@ -216,7 +216,9 @@ func (cs *Service) checkJobs() {
|
||||
// agent loop stuck), the entire cron scheduler would stop checking for new
|
||||
// due jobs. Now each job runs independently with panic recovery.
|
||||
for _, dj := range dueJobs {
|
||||
cs.wg.Add(1)
|
||||
go func(id string, scheduledAtMS int64) {
|
||||
defer cs.wg.Done()
|
||||
defer safego.Recover(nil, "job_id", id)
|
||||
cs.executeJobByID(id, scheduledAtMS)
|
||||
}(dj.id, dj.scheduledAtMS)
|
||||
|
||||
@@ -26,13 +26,22 @@ var safeBinaryNameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
|
||||
|
||||
// SecureCLIHandler handles secure CLI binary credential CRUD endpoints.
|
||||
type SecureCLIHandler struct {
|
||||
store store.SecureCLIStore
|
||||
msgBus *bus.MessageBus
|
||||
store store.SecureCLIStore
|
||||
agentCreds store.SecureCLIAgentCredentialStore
|
||||
tenants store.TenantStore
|
||||
msgBus *bus.MessageBus
|
||||
}
|
||||
|
||||
// NewSecureCLIHandler creates a handler for secure CLI credential management.
|
||||
func NewSecureCLIHandler(s store.SecureCLIStore, msgBus *bus.MessageBus) *SecureCLIHandler {
|
||||
return &SecureCLIHandler{store: s, msgBus: msgBus}
|
||||
func NewSecureCLIHandler(s store.SecureCLIStore, msgBus *bus.MessageBus, tenants ...store.TenantStore) *SecureCLIHandler {
|
||||
h := &SecureCLIHandler{store: s, msgBus: msgBus}
|
||||
if len(tenants) > 0 {
|
||||
h.tenants = tenants[0]
|
||||
}
|
||||
if agentCreds, ok := s.(store.SecureCLIAgentCredentialStore); ok {
|
||||
h.agentCreds = agentCreds
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// RegisterRoutes registers all secure CLI routes on the given mux.
|
||||
@@ -51,6 +60,13 @@ func (h *SecureCLIHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /v1/cli-credentials/{id}/user-credentials/{userId}", h.auth(h.handleGetUserCredentials))
|
||||
mux.HandleFunc("PUT /v1/cli-credentials/{id}/user-credentials/{userId}", h.auth(h.handleSetUserCredentials))
|
||||
mux.HandleFunc("DELETE /v1/cli-credentials/{id}/user-credentials/{userId}", h.auth(h.handleDeleteUserCredentials))
|
||||
|
||||
// Per-agent credential management. Credentials do not grant binary access;
|
||||
// non-global binaries still require agent-grants.
|
||||
mux.HandleFunc("GET /v1/cli-credentials/{id}/agent-credentials", h.auth(h.handleListAgentCredentials))
|
||||
mux.HandleFunc("GET /v1/cli-credentials/{id}/agent-credentials/{agentId}", h.auth(h.handleGetAgentCredentials))
|
||||
mux.HandleFunc("PUT /v1/cli-credentials/{id}/agent-credentials/{agentId}", h.auth(h.handleSetAgentCredentials))
|
||||
mux.HandleFunc("DELETE /v1/cli-credentials/{id}/agent-credentials/{agentId}", h.auth(h.handleDeleteAgentCredentials))
|
||||
}
|
||||
|
||||
func (h *SecureCLIHandler) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
type agentCredentialEntry struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
BinaryID uuid.UUID `json:"binary_id"`
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
AgentKey string `json:"agent_key,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
HasSecret bool `json:"has_secret"`
|
||||
EnvKeys []string `json:"env_keys,omitempty"`
|
||||
Env map[string]store.SecureCLIEnvResponseEntry `json:"env,omitempty"`
|
||||
CredentialType *string `json:"credential_type,omitempty"`
|
||||
HostScope *string `json:"host_scope,omitempty"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (h *SecureCLIHandler) requireAgentCredentialStore(w http.ResponseWriter, r *http.Request) (store.SecureCLIAgentCredentialStore, bool) {
|
||||
if h.agentCreds == nil {
|
||||
locale := store.LocaleFromContext(r.Context())
|
||||
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "agent credentials store unavailable")})
|
||||
return nil, false
|
||||
}
|
||||
return h.agentCreds, true
|
||||
}
|
||||
|
||||
func (h *SecureCLIHandler) requireAgentCredentialAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
return requireTenantAdmin(w, r, h.tenants)
|
||||
}
|
||||
|
||||
func agentCredentialResponse(c store.SecureCLIAgentCredential) agentCredentialEntry {
|
||||
isTyped := c.CredentialType != nil && *c.CredentialType != "" && *c.CredentialType != "env"
|
||||
e := agentCredentialEntry{
|
||||
ID: c.ID,
|
||||
BinaryID: c.BinaryID,
|
||||
AgentID: c.AgentID,
|
||||
AgentKey: c.AgentKey,
|
||||
Name: c.Name,
|
||||
HasSecret: len(c.EncryptedEnv) > 0,
|
||||
CredentialType: c.CredentialType,
|
||||
HostScope: c.HostScope,
|
||||
CreatedBy: c.CreatedBy,
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
}
|
||||
if !isTyped {
|
||||
e.EnvKeys = envKeysFromDecryptedJSON(c.EncryptedEnv)
|
||||
e.Env = store.SanitizeSecureCLIEnvJSON(c.EncryptedEnv)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func parseAgentCredentialPath(w http.ResponseWriter, r *http.Request, locale string) (uuid.UUID, uuid.UUID, bool) {
|
||||
binaryID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "credential")})
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
agentID, err := uuid.Parse(r.PathValue("agentId"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
return binaryID, agentID, true
|
||||
}
|
||||
|
||||
func (h *SecureCLIHandler) handleListAgentCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAgentCredentialAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
agentCreds, ok := h.requireAgentCredentialStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
locale := store.LocaleFromContext(r.Context())
|
||||
binaryID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "credential")})
|
||||
return
|
||||
}
|
||||
creds, err := agentCreds.ListAgentCredentials(r.Context(), binaryID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())})
|
||||
return
|
||||
}
|
||||
entries := make([]agentCredentialEntry, 0, len(creds))
|
||||
for _, c := range creds {
|
||||
entries = append(entries, agentCredentialResponse(c))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"agent_credentials": entries})
|
||||
}
|
||||
|
||||
func (h *SecureCLIHandler) handleGetAgentCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAgentCredentialAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
agentCreds, ok := h.requireAgentCredentialStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
locale := store.LocaleFromContext(r.Context())
|
||||
binaryID, agentID, ok := parseAgentCredentialPath(w, r, locale)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cred, err := agentCreds.GetAgentCredentials(r.Context(), binaryID, agentID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())})
|
||||
return
|
||||
}
|
||||
if cred == nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent credential", agentID.String())})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, agentCredentialResponse(*cred))
|
||||
}
|
||||
|
||||
func (h *SecureCLIHandler) handleSetAgentCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAgentCredentialAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
agentCreds, ok := h.requireAgentCredentialStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
locale := store.LocaleFromContext(r.Context())
|
||||
binaryID, agentID, ok := parseAgentCredentialPath(w, r, locale)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if exists, err := agentCreds.BinaryExists(r.Context(), binaryID); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "validate credential")})
|
||||
return
|
||||
} else if !exists {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "credential", binaryID.String())})
|
||||
return
|
||||
}
|
||||
if exists, err := agentCreds.AgentExists(r.Context(), agentID); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "validate agent")})
|
||||
return
|
||||
} else if !exists {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", agentID.String())})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Env json.RawMessage `json:"env"`
|
||||
typedCredentialBody
|
||||
}
|
||||
if !bindJSON(w, r, locale, &body) {
|
||||
return
|
||||
}
|
||||
envBytes, credType, hostScope, terr := prepareTypedCredentialEnv(locale, body.typedCredentialBody)
|
||||
if terr != nil {
|
||||
writeTypedCredentialError(w, terr)
|
||||
return
|
||||
}
|
||||
createdBy := store.UserIDFromContext(r.Context())
|
||||
if envBytes != nil {
|
||||
if err := agentCreds.SetAgentCredentialsTyped(r.Context(), binaryID, agentID, envBytes, credType, hostScope, createdBy); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())})
|
||||
return
|
||||
}
|
||||
emitAudit(h.msgBus, r, "secure_cli.agent_credentials.updated", "secure_cli_agent_credentials", binaryID.String()+"/"+agentID.String()+"#"+*credType)
|
||||
h.emitCacheInvalidate("")
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
return
|
||||
}
|
||||
if len(body.Env) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "env")})
|
||||
return
|
||||
}
|
||||
envJSON, ok := validateAndSerializeEnvVars(w, locale, body.Env)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := agentCreds.SetAgentCredentials(r.Context(), binaryID, agentID, envJSON, createdBy); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())})
|
||||
return
|
||||
}
|
||||
emitAudit(h.msgBus, r, "secure_cli.agent_credentials.updated", "secure_cli_agent_credentials", binaryID.String()+"/"+agentID.String())
|
||||
h.emitCacheInvalidate("")
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *SecureCLIHandler) handleDeleteAgentCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAgentCredentialAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
agentCreds, ok := h.requireAgentCredentialStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
locale := store.LocaleFromContext(r.Context())
|
||||
binaryID, agentID, ok := parseAgentCredentialPath(w, r, locale)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := agentCreds.DeleteAgentCredentials(r.Context(), binaryID, agentID); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())})
|
||||
return
|
||||
}
|
||||
emitAudit(h.msgBus, r, "secure_cli.agent_credentials.deleted", "secure_cli_agent_credentials", binaryID.String()+"/"+agentID.String())
|
||||
h.emitCacheInvalidate("")
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
@@ -31,9 +31,16 @@ type recordingSecureCLIStore struct {
|
||||
lastTypedType *string
|
||||
lastTypedScope *string
|
||||
lastLegacyEnv []byte
|
||||
lastAgentTypedEnv []byte
|
||||
lastAgentType *string
|
||||
lastAgentScope *string
|
||||
lastAgentLegacy []byte
|
||||
typedCalls int
|
||||
legacyCalls int
|
||||
agentTypedCalls int
|
||||
agentLegacyCalls int
|
||||
existingForGet *store.SecureCLIUserCredential
|
||||
existingAgent *store.SecureCLIAgentCredential
|
||||
}
|
||||
|
||||
func (s *recordingSecureCLIStore) SetUserCredentialsTyped(_ context.Context, _ uuid.UUID, _ string, encryptedEnv []byte, credentialType, hostScope *string) error {
|
||||
@@ -58,6 +65,61 @@ func (s *recordingSecureCLIStore) GetUserCredentials(context.Context, uuid.UUID,
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (s *recordingSecureCLIStore) BinaryExists(context.Context, uuid.UUID) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *recordingSecureCLIStore) AgentExists(context.Context, uuid.UUID) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *recordingSecureCLIStore) SetAgentCredentialsTyped(_ context.Context, _ uuid.UUID, _ uuid.UUID, encryptedEnv []byte, credentialType, hostScope *string, _ string) error {
|
||||
s.lastAgentTypedEnv = append([]byte(nil), encryptedEnv...)
|
||||
s.lastAgentType = credentialType
|
||||
s.lastAgentScope = hostScope
|
||||
s.agentTypedCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingSecureCLIStore) SetAgentCredentials(_ context.Context, _ uuid.UUID, _ uuid.UUID, encryptedEnv []byte, _ string) error {
|
||||
s.lastAgentLegacy = append([]byte(nil), encryptedEnv...)
|
||||
s.agentLegacyCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingSecureCLIStore) GetAgentCredentials(context.Context, uuid.UUID, uuid.UUID) (*store.SecureCLIAgentCredential, error) {
|
||||
if s.existingAgent == nil {
|
||||
return nil, nil
|
||||
}
|
||||
cp := *s.existingAgent
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (s *recordingSecureCLIStore) DeleteAgentCredentials(context.Context, uuid.UUID, uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingSecureCLIStore) ListAgentCredentials(context.Context, uuid.UUID) ([]store.SecureCLIAgentCredential, error) {
|
||||
if s.existingAgent == nil {
|
||||
return nil, nil
|
||||
}
|
||||
cp := *s.existingAgent
|
||||
return []store.SecureCLIAgentCredential{cp}, nil
|
||||
}
|
||||
|
||||
func newAgentCredentialTestHandler(st *recordingSecureCLIStore) *SecureCLIHandler {
|
||||
ts := newMockTenantStore()
|
||||
ts.addTenant(store.MasterTenantID, "master")
|
||||
ts.setUserRole(store.MasterTenantID, "system", store.TenantRoleAdmin)
|
||||
return NewSecureCLIHandler(st, nil, ts)
|
||||
}
|
||||
|
||||
func withTenantAdminContext(req *http.Request) *http.Request {
|
||||
ctx := store.WithTenantID(req.Context(), store.MasterTenantID)
|
||||
ctx = store.WithUserID(ctx, "system")
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func putUserCred(t *testing.T, h *SecureCLIHandler, binaryID uuid.UUID, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
buf, _ := json.Marshal(body)
|
||||
@@ -69,6 +131,17 @@ func putUserCred(t *testing.T, h *SecureCLIHandler, binaryID uuid.UUID, body any
|
||||
return rec
|
||||
}
|
||||
|
||||
func putAgentCred(t *testing.T, h *SecureCLIHandler, binaryID, agentID uuid.UUID, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
buf, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(http.MethodPut, "/v1/cli-credentials/"+binaryID.String()+"/agent-credentials/"+agentID.String(), bytes.NewReader(buf))
|
||||
req.SetPathValue("id", binaryID.String())
|
||||
req.SetPathValue("agentId", agentID.String())
|
||||
rec := httptest.NewRecorder()
|
||||
h.handleSetAgentCredentials(rec, withTenantAdminContext(req))
|
||||
return rec
|
||||
}
|
||||
|
||||
// 9. Handler accepts new PAT payload and routes through SetUserCredentialsTyped.
|
||||
func TestPutUserCredential_PATPayload(t *testing.T) {
|
||||
st := &recordingSecureCLIStore{}
|
||||
@@ -106,6 +179,109 @@ func TestPutUserCredential_PATPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutAgentCredential_PATPayload(t *testing.T) {
|
||||
st := &recordingSecureCLIStore{}
|
||||
h := newAgentCredentialTestHandler(st)
|
||||
binaryID := uuid.New()
|
||||
agentID := uuid.New()
|
||||
|
||||
rec := putAgentCred(t, h, binaryID, agentID, map[string]any{
|
||||
"credential_type": "pat",
|
||||
"host_scope": "github.com",
|
||||
"blob": map[string]string{"token": "ghp_agentABC123456"},
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if st.agentTypedCalls != 1 || st.agentLegacyCalls != 0 {
|
||||
t.Fatalf("expected agent typed=1 legacy=0, got typed=%d legacy=%d", st.agentTypedCalls, st.agentLegacyCalls)
|
||||
}
|
||||
if st.lastAgentType == nil || *st.lastAgentType != "pat" {
|
||||
t.Fatalf("type mismatch: %#v", st.lastAgentType)
|
||||
}
|
||||
if st.lastAgentScope == nil || *st.lastAgentScope != "github.com" {
|
||||
t.Fatalf("scope mismatch: %#v", st.lastAgentScope)
|
||||
}
|
||||
var got map[string]string
|
||||
if err := json.Unmarshal(st.lastAgentTypedEnv, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["token"] != "ghp_agentABC123456" {
|
||||
t.Fatalf("stored token mismatch: %#v", got)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "ghp_agent") {
|
||||
t.Fatalf("response leaked token: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentCredential_TypedDoesNotReturnSecretBlob(t *testing.T) {
|
||||
credType := "pat"
|
||||
hostScope := "github.com"
|
||||
binaryID := uuid.New()
|
||||
agentID := uuid.New()
|
||||
st := &recordingSecureCLIStore{existingAgent: &store.SecureCLIAgentCredential{
|
||||
ID: uuid.New(),
|
||||
BinaryID: binaryID,
|
||||
AgentID: agentID,
|
||||
EncryptedEnv: []byte(`{"token":"ghp_secret_agent_token"}`),
|
||||
CredentialType: &credType,
|
||||
HostScope: &hostScope,
|
||||
}}
|
||||
h := newAgentCredentialTestHandler(st)
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/cli-credentials/"+binaryID.String()+"/agent-credentials/"+agentID.String(), nil)
|
||||
req.SetPathValue("id", binaryID.String())
|
||||
req.SetPathValue("agentId", agentID.String())
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.handleGetAgentCredentials(rec, withTenantAdminContext(req))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "ghp_secret") || strings.Contains(rec.Body.String(), "token") {
|
||||
t.Fatalf("typed agent credential leaked blob material: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentCredentialsRouteRequiresTenantAdmin(t *testing.T) {
|
||||
oldToken := pkgGatewayToken
|
||||
oldFallback := pkgNoAuthFallbackAllowed
|
||||
pkgGatewayToken = ""
|
||||
pkgNoAuthFallbackAllowed = true
|
||||
defer func() {
|
||||
pkgGatewayToken = oldToken
|
||||
pkgNoAuthFallbackAllowed = oldFallback
|
||||
}()
|
||||
|
||||
st := &recordingSecureCLIStore{}
|
||||
ts := newMockTenantStore()
|
||||
ts.addTenant(store.MasterTenantID, "master")
|
||||
ts.setUserRole(store.MasterTenantID, "system", store.TenantRoleViewer)
|
||||
h := NewSecureCLIHandler(st, nil, ts)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
binaryID := uuid.New()
|
||||
agentID := uuid.New()
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"credential_type": "pat",
|
||||
"host_scope": "github.com",
|
||||
"blob": map[string]string{"token": "ghp_routeABC123456"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/v1/cli-credentials/"+binaryID.String()+"/agent-credentials/"+agentID.String(), bytes.NewReader(body))
|
||||
req.Header.Set("X-GoClaw-User-Id", "browser-user")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if st.agentTypedCalls != 0 || st.agentLegacyCalls != 0 {
|
||||
t.Fatalf("tenant viewer must not write credentials, typed=%d legacy=%d", st.agentTypedCalls, st.agentLegacyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Handler rejects passphrase-protected SSH key with error_key.
|
||||
func TestPutUserCredential_RejectsPassphraseKey(t *testing.T) {
|
||||
st := &recordingSecureCLIStore{}
|
||||
@@ -236,10 +412,7 @@ func base64Wrap(b []byte) string {
|
||||
s := base64.StdEncoding.EncodeToString(b)
|
||||
var out strings.Builder
|
||||
for i := 0; i < len(s); i += 70 {
|
||||
end := i + 70
|
||||
if end > len(s) {
|
||||
end = len(s)
|
||||
}
|
||||
end := min(i+70, len(s))
|
||||
if i > 0 {
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
|
||||
@@ -355,6 +355,11 @@ func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string
|
||||
} else {
|
||||
selectCols += ", NULL AS user_env, NULL AS user_cred_type, NULL AS user_host_scope"
|
||||
}
|
||||
if agentID != nil {
|
||||
selectCols += ", ac.encrypted_env AS agent_env, ac.credential_type AS agent_cred_type, ac.host_scope AS agent_host_scope"
|
||||
} else {
|
||||
selectCols += ", NULL AS agent_env, NULL AS agent_cred_type, NULL AS agent_host_scope"
|
||||
}
|
||||
|
||||
var args []any
|
||||
argIdx := 1
|
||||
@@ -370,6 +375,16 @@ func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string
|
||||
} else {
|
||||
query += ` LEFT JOIN secure_cli_agent_grants g ON FALSE` // never match
|
||||
}
|
||||
if agentID != nil {
|
||||
query += fmt.Sprintf(` LEFT JOIN secure_cli_agent_credentials ac ON ac.binary_id = b.id AND ac.agent_id = $%d`, argIdx)
|
||||
args = append(args, *agentID)
|
||||
argIdx++
|
||||
if !isCross {
|
||||
query += fmt.Sprintf(` AND ac.tenant_id = $%d`, argIdx)
|
||||
args = append(args, tid)
|
||||
argIdx++
|
||||
}
|
||||
}
|
||||
|
||||
// LEFT JOIN user credentials
|
||||
if userID != "" {
|
||||
@@ -432,6 +447,8 @@ func (s *PGSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.Secu
|
||||
var grantEncEnv []byte
|
||||
var userEnv []byte
|
||||
var userCredType, userHostScope *string
|
||||
var agentEnv []byte
|
||||
var agentCredType, agentHostScope *string
|
||||
|
||||
err := row.Scan(
|
||||
&b.ID, &b.BinaryName, &binaryPath, &b.Description, &env,
|
||||
@@ -442,6 +459,8 @@ func (s *PGSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.Secu
|
||||
&grantDenyArgs, &grantDenyVerbose, &grantTimeout, &grantTips, &grantEnabled, &grantID, &grantEncEnv,
|
||||
// User credential columns
|
||||
&userEnv, &userCredType, &userHostScope,
|
||||
// Agent credential columns
|
||||
&agentEnv, &agentCredType, &agentHostScope,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -481,23 +500,45 @@ func (s *PGSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.Secu
|
||||
grant.TimeoutSeconds = grantTimeout
|
||||
grant.Tips = grantTips
|
||||
// Decrypt grant env override (fail-closed: skip if decrypt fails).
|
||||
if len(grantEncEnv) > 0 && s.encKey != "" {
|
||||
if decrypted, err := crypto.Decrypt(string(grantEncEnv), s.encKey); err == nil {
|
||||
grant.EncryptedEnv = []byte(decrypted)
|
||||
if len(grantEncEnv) > 0 {
|
||||
if s.encKey != "" {
|
||||
if decrypted, err := crypto.Decrypt(string(grantEncEnv), s.encKey); err == nil {
|
||||
grant.EncryptedEnv = []byte(decrypted)
|
||||
}
|
||||
} else {
|
||||
grant.EncryptedEnv = grantEncEnv
|
||||
}
|
||||
}
|
||||
b.MergeGrantOverrides(grant)
|
||||
}
|
||||
|
||||
// Decrypt per-user env
|
||||
if len(userEnv) > 0 && s.encKey != "" {
|
||||
if decrypted, err := crypto.Decrypt(string(userEnv), s.encKey); err == nil {
|
||||
b.UserEnv = []byte(decrypted)
|
||||
if len(userEnv) > 0 {
|
||||
if s.encKey != "" {
|
||||
if decrypted, err := crypto.Decrypt(string(userEnv), s.encKey); err == nil {
|
||||
b.UserEnv = []byte(decrypted)
|
||||
}
|
||||
} else {
|
||||
b.UserEnv = userEnv
|
||||
}
|
||||
}
|
||||
// Project per-user credential metadata so adapters can branch on it.
|
||||
b.UserCredentialType = userCredType
|
||||
b.UserHostScope = userHostScope
|
||||
if len(b.UserEnv) > 0 || userCredType != nil || userHostScope != nil {
|
||||
b.SetEffectiveCredential(b.UserEnv, userCredType, userHostScope, "user", "")
|
||||
}
|
||||
if len(agentEnv) > 0 || agentCredType != nil || agentHostScope != nil {
|
||||
decrypted := agentEnv
|
||||
if len(agentEnv) > 0 && s.encKey != "" {
|
||||
if d, err := crypto.Decrypt(string(agentEnv), s.encKey); err == nil {
|
||||
decrypted = []byte(d)
|
||||
}
|
||||
}
|
||||
if b.CredentialSource == "" {
|
||||
b.SetEffectiveCredential(decrypted, agentCredType, agentHostScope, "agent", "")
|
||||
}
|
||||
}
|
||||
|
||||
return &b, nil
|
||||
}
|
||||
@@ -507,7 +548,7 @@ func (s *PGSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *store.S
|
||||
if len(scopes) == 0 || b == nil {
|
||||
return b, nil
|
||||
}
|
||||
hasUserCredential := len(b.UserEnv) > 0 || b.UserCredentialType != nil || b.UserHostScope != nil
|
||||
hasUserCredential := b.CredentialSource == "user" || len(b.UserEnv) > 0 || b.UserCredentialType != nil || b.UserHostScope != nil
|
||||
disabledByContext := false
|
||||
for _, scope := range scopes {
|
||||
grants, err := s.ListContextGrantsForScope(ctx, scope)
|
||||
@@ -546,9 +587,7 @@ func (s *PGSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *store.S
|
||||
continue
|
||||
}
|
||||
if !hasUserCredential {
|
||||
b.UserEnv = creds.EncryptedEnv
|
||||
b.UserCredentialType = creds.CredentialType
|
||||
b.UserHostScope = creds.HostScope
|
||||
b.SetEffectiveCredential(creds.EncryptedEnv, creds.CredentialType, creds.HostScope, "context", scope.ScopeType+":"+scope.ScopeKey)
|
||||
} else {
|
||||
b.EncryptedEnv = creds.EncryptedEnv
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package pg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/crypto"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
const agentCredSelectCols = `c.id, c.binary_id, c.agent_id, c.encrypted_env, c.metadata,
|
||||
c.credential_type, c.host_scope, c.created_by, c.created_at, c.updated_at`
|
||||
|
||||
func agentCredentialTenantID(ctx context.Context) (uuid.UUID, error) {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
return uuid.Nil, fmt.Errorf("tenant_id required for agent credentials")
|
||||
}
|
||||
return tid, nil
|
||||
}
|
||||
|
||||
func (s *PGSecureCLIStore) BinaryExists(ctx context.Context, binaryID uuid.UUID) (bool, error) {
|
||||
tid, err := agentCredentialTenantID(ctx)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
var exists bool
|
||||
err = s.db.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM secure_cli_binaries WHERE id = $1 AND tenant_id = $2)`,
|
||||
binaryID, tid,
|
||||
).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (s *PGSecureCLIStore) AgentExists(ctx context.Context, agentID uuid.UUID) (bool, error) {
|
||||
tid, err := agentCredentialTenantID(ctx)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
var exists bool
|
||||
err = s.db.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM agents WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL)`,
|
||||
agentID, tid,
|
||||
).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (s *PGSecureCLIStore) GetAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) (*store.SecureCLIAgentCredential, error) {
|
||||
tid, err := agentCredentialTenantID(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var c store.SecureCLIAgentCredential
|
||||
var env []byte
|
||||
err = s.db.QueryRowContext(ctx,
|
||||
`SELECT `+agentCredSelectCols+`, COALESCE(a.agent_key, ''), COALESCE(a.display_name, '')
|
||||
FROM secure_cli_agent_credentials c
|
||||
LEFT JOIN agents a ON a.id = c.agent_id AND a.tenant_id = c.tenant_id
|
||||
WHERE c.binary_id = $1 AND c.agent_id = $2 AND c.tenant_id = $3`,
|
||||
binaryID, agentID, tid,
|
||||
).Scan(&c.ID, &c.BinaryID, &c.AgentID, &env, &c.Metadata,
|
||||
&c.CredentialType, &c.HostScope, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt,
|
||||
&c.AgentKey, &c.Name)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.EncryptedEnv = s.decryptAgentCredentialEnv(env)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (s *PGSecureCLIStore) SetAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, createdBy string) error {
|
||||
return s.SetAgentCredentialsTyped(ctx, binaryID, agentID, encryptedEnv, nil, nil, createdBy)
|
||||
}
|
||||
|
||||
func (s *PGSecureCLIStore) SetAgentCredentialsTyped(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, credentialType, hostScope *string, createdBy string) error {
|
||||
tid, err := agentCredentialTenantID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
envBytes, err := s.encryptAgentCredentialEnv(encryptedEnv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO secure_cli_agent_credentials
|
||||
(binary_id, agent_id, encrypted_env, metadata, tenant_id,
|
||||
credential_type, host_scope, created_by, created_at, updated_at)
|
||||
SELECT b.id, a.id, $3, '{}', $4, $5, $6, $7, $8, $8
|
||||
FROM secure_cli_binaries b
|
||||
JOIN agents a ON a.id = $2 AND a.tenant_id = $4 AND a.deleted_at IS NULL
|
||||
WHERE b.id = $1 AND b.tenant_id = $4
|
||||
ON CONFLICT (binary_id, agent_id, tenant_id) DO UPDATE SET
|
||||
encrypted_env = EXCLUDED.encrypted_env,
|
||||
credential_type = EXCLUDED.credential_type,
|
||||
host_scope = EXCLUDED.host_scope,
|
||||
created_by = EXCLUDED.created_by,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
binaryID, agentID, envBytes, tid, credentialType, hostScope, createdBy, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err == nil && n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PGSecureCLIStore) DeleteAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) error {
|
||||
tid, err := agentCredentialTenantID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
`DELETE FROM secure_cli_agent_credentials WHERE binary_id = $1 AND agent_id = $2 AND tenant_id = $3`,
|
||||
binaryID, agentID, tid,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PGSecureCLIStore) ListAgentCredentials(ctx context.Context, binaryID uuid.UUID) ([]store.SecureCLIAgentCredential, error) {
|
||||
tid, err := agentCredentialTenantID(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+agentCredSelectCols+`, COALESCE(a.agent_key, ''), COALESCE(a.display_name, '')
|
||||
FROM secure_cli_agent_credentials c
|
||||
LEFT JOIN agents a ON a.id = c.agent_id AND a.tenant_id = c.tenant_id
|
||||
WHERE c.binary_id = $1 AND c.tenant_id = $2
|
||||
ORDER BY c.created_at`, binaryID, tid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []store.SecureCLIAgentCredential
|
||||
for rows.Next() {
|
||||
var c store.SecureCLIAgentCredential
|
||||
var env []byte
|
||||
if err := rows.Scan(&c.ID, &c.BinaryID, &c.AgentID, &env, &c.Metadata,
|
||||
&c.CredentialType, &c.HostScope, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt,
|
||||
&c.AgentKey, &c.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.EncryptedEnv = s.decryptAgentCredentialEnv(env)
|
||||
result = append(result, c)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PGSecureCLIStore) encryptAgentCredentialEnv(env []byte) ([]byte, error) {
|
||||
if len(env) == 0 || s.encKey == "" {
|
||||
return env, nil
|
||||
}
|
||||
encrypted, err := crypto.Encrypt(string(env), s.encKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt agent credential env: %w", err)
|
||||
}
|
||||
return []byte(encrypted), nil
|
||||
}
|
||||
|
||||
func (s *PGSecureCLIStore) decryptAgentCredentialEnv(env []byte) []byte {
|
||||
if len(env) > 0 && s.encKey != "" {
|
||||
if decrypted, err := crypto.Decrypt(string(env), s.encKey); err == nil {
|
||||
return []byte(decrypted)
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
@@ -44,6 +44,13 @@ type SecureCLIBinary struct {
|
||||
// credential is legacy env-only.
|
||||
UserCredentialType *string `json:"-" db:"-"`
|
||||
UserHostScope *string `json:"-" db:"-"`
|
||||
// CredentialEnv + metadata carry the effective typed credential selected
|
||||
// for runtime injection. Source can be "user", "context", "agent", or "".
|
||||
CredentialEnv []byte `json:"-" db:"-"`
|
||||
CredentialType *string `json:"-" db:"-"`
|
||||
CredentialHostScope *string `json:"-" db:"-"`
|
||||
CredentialSource string `json:"credential_source,omitempty" db:"-"`
|
||||
CredentialSubjectID string `json:"credential_subject_id,omitempty" db:"-"`
|
||||
// EnvKeys is set by HTTP handlers only (names from decrypted env, no values); not a DB column.
|
||||
EnvKeys []string `json:"env_keys,omitempty" db:"-"`
|
||||
// Env is set by HTTP handlers only. Sensitive values are masked; value entries are visible.
|
||||
@@ -52,6 +59,25 @@ type SecureCLIBinary struct {
|
||||
AgentGrantsSummary []AgentGrantSummary `json:"agent_grants_summary" db:"-"`
|
||||
}
|
||||
|
||||
// SetEffectiveCredential records the credential material selected for runtime
|
||||
// injection. The legacy User* fields remain populated for older call sites that
|
||||
// still synthesize adapter credentials from the binary row.
|
||||
func (b *SecureCLIBinary) SetEffectiveCredential(env []byte, credentialType, hostScope *string, source, subjectID string) {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
b.CredentialEnv = env
|
||||
b.CredentialType = credentialType
|
||||
b.CredentialHostScope = hostScope
|
||||
b.CredentialSource = source
|
||||
b.CredentialSubjectID = subjectID
|
||||
if source == "user" || source == "context" {
|
||||
b.UserEnv = env
|
||||
b.UserCredentialType = credentialType
|
||||
b.UserHostScope = hostScope
|
||||
}
|
||||
}
|
||||
|
||||
// MergeGrantOverrides applies agent grant overrides onto a binary config.
|
||||
// Non-nil grant fields replace binary defaults; nil fields keep binary values.
|
||||
func (b *SecureCLIBinary) MergeGrantOverrides(g *SecureCLIAgentGrant) {
|
||||
@@ -94,6 +120,25 @@ type SecureCLIUserCredential struct {
|
||||
HostScope *string `json:"host_scope,omitempty" db:"host_scope"`
|
||||
}
|
||||
|
||||
// SecureCLIAgentCredential holds per-agent encrypted env overrides for a binary.
|
||||
type SecureCLIAgentCredential struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
BinaryID uuid.UUID `json:"binary_id" db:"binary_id"`
|
||||
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
|
||||
AgentKey string `json:"agent_key,omitempty" db:"-"`
|
||||
Name string `json:"name,omitempty" db:"-"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty" db:"metadata"`
|
||||
CreatedBy string `json:"created_by" db:"created_by"`
|
||||
CreatedAt string `json:"created_at" db:"created_at"`
|
||||
UpdatedAt string `json:"updated_at" db:"updated_at"`
|
||||
// EncryptedEnv is decrypted JSON — never serialized to API.
|
||||
EncryptedEnv []byte `json:"-" db:"encrypted_env"`
|
||||
// CredentialType selects the wire shape carried in EncryptedEnv.
|
||||
CredentialType *string `json:"credential_type,omitempty" db:"credential_type"`
|
||||
// HostScope binds the credential to a specific hostname.
|
||||
HostScope *string `json:"host_scope,omitempty" db:"host_scope"`
|
||||
}
|
||||
|
||||
// SecureCLIAgentGrant represents a per-agent grant with optional setting overrides.
|
||||
type SecureCLIAgentGrant struct {
|
||||
BaseModel
|
||||
@@ -158,6 +203,18 @@ type SecureCLIStore interface {
|
||||
ListUserCredentials(ctx context.Context, binaryID uuid.UUID) ([]SecureCLIUserCredential, error)
|
||||
}
|
||||
|
||||
// SecureCLIAgentCredentialStore manages per-agent credential material for
|
||||
// secure CLI binaries. It intentionally does not grant binary access.
|
||||
type SecureCLIAgentCredentialStore interface {
|
||||
BinaryExists(ctx context.Context, binaryID uuid.UUID) (bool, error)
|
||||
AgentExists(ctx context.Context, agentID uuid.UUID) (bool, error)
|
||||
GetAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) (*SecureCLIAgentCredential, error)
|
||||
SetAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, createdBy string) error
|
||||
SetAgentCredentialsTyped(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, credentialType, hostScope *string, createdBy string) error
|
||||
DeleteAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) error
|
||||
ListAgentCredentials(ctx context.Context, binaryID uuid.UUID) ([]SecureCLIAgentCredential, error)
|
||||
}
|
||||
|
||||
// SecureCLIAgentGrantStore manages per-agent grants for secure CLI binaries.
|
||||
type SecureCLIAgentGrantStore interface {
|
||||
BinaryExists(ctx context.Context, binaryID uuid.UUID) (bool, error)
|
||||
|
||||
@@ -16,7 +16,7 @@ var schemaSQL string
|
||||
|
||||
// SchemaVersion is the current SQLite schema version.
|
||||
// Bump this when adding new migration steps below.
|
||||
const SchemaVersion = 45
|
||||
const SchemaVersion = 46
|
||||
|
||||
// migrations maps version → SQL to apply when upgrading FROM that version.
|
||||
// schema.sql always represents the LATEST full schema (for fresh DBs).
|
||||
@@ -807,6 +807,30 @@ CREATE INDEX IF NOT EXISTS idx_run_timeline_trace
|
||||
43: addChannelContextCapabilityTables,
|
||||
// Version 44 → 45: passive channel memory extraction run and review queue.
|
||||
44: addChannelMemoryExtractionTables,
|
||||
// Version 45 → 46: per-agent typed Secure CLI credentials.
|
||||
45: `CREATE UNIQUE INDEX IF NOT EXISTS idx_secure_cli_binaries_id_tenant
|
||||
ON secure_cli_binaries(id, tenant_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_id_tenant
|
||||
ON agents(id, tenant_id);
|
||||
CREATE TABLE IF NOT EXISTS secure_cli_agent_credentials (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
binary_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
encrypted_env BLOB NOT NULL,
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id),
|
||||
credential_type TEXT,
|
||||
host_scope TEXT,
|
||||
created_by VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
UNIQUE(binary_id, agent_id, tenant_id),
|
||||
FOREIGN KEY (binary_id, tenant_id) REFERENCES secure_cli_binaries(id, tenant_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (agent_id, tenant_id) REFERENCES agents(id, tenant_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_scac_tenant ON secure_cli_agent_credentials(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_scac_binary ON secure_cli_agent_credentials(binary_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_scac_agent ON secure_cli_agent_credentials(agent_id);`,
|
||||
}
|
||||
|
||||
const addChannelMemoryExtractionTables = `
|
||||
|
||||
@@ -1710,6 +1710,36 @@ CREATE TABLE IF NOT EXISTS secure_cli_user_credentials (
|
||||
CREATE INDEX IF NOT EXISTS idx_scuc_tenant ON secure_cli_user_credentials(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_scuc_binary ON secure_cli_user_credentials(binary_id);
|
||||
|
||||
-- ============================================================
|
||||
-- Table: secure_cli_agent_credentials (per-agent encrypted env)
|
||||
-- ============================================================
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_secure_cli_binaries_id_tenant
|
||||
ON secure_cli_binaries(id, tenant_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_id_tenant
|
||||
ON agents(id, tenant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS secure_cli_agent_credentials (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
binary_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
encrypted_env BLOB NOT NULL,
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id),
|
||||
credential_type TEXT,
|
||||
host_scope TEXT,
|
||||
created_by VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
UNIQUE(binary_id, agent_id, tenant_id),
|
||||
FOREIGN KEY (binary_id, tenant_id) REFERENCES secure_cli_binaries(id, tenant_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (agent_id, tenant_id) REFERENCES agents(id, tenant_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_scac_tenant ON secure_cli_agent_credentials(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_scac_binary ON secure_cli_agent_credentials(binary_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_scac_agent ON secure_cli_agent_credentials(agent_id);
|
||||
|
||||
-- ============================================================
|
||||
-- Table: vault_documents (V3 Knowledge Vault registry)
|
||||
-- ============================================================
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//go:build sqlite || sqliteonly
|
||||
|
||||
package sqlitestore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/crypto"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
const agentCredSelectCols = `c.id, c.binary_id, c.agent_id, c.encrypted_env, COALESCE(c.metadata, '{}'),
|
||||
c.credential_type, c.host_scope, c.created_by, c.created_at, c.updated_at`
|
||||
|
||||
func agentCredentialTenantID(ctx context.Context) (uuid.UUID, error) {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
return uuid.Nil, fmt.Errorf("tenant_id required for agent credentials")
|
||||
}
|
||||
return tid, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteSecureCLIStore) BinaryExists(ctx context.Context, binaryID uuid.UUID) (bool, error) {
|
||||
tid, err := agentCredentialTenantID(ctx)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
var exists bool
|
||||
err = s.db.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM secure_cli_binaries WHERE id = ? AND tenant_id = ?)`,
|
||||
binaryID, tid,
|
||||
).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (s *SQLiteSecureCLIStore) AgentExists(ctx context.Context, agentID uuid.UUID) (bool, error) {
|
||||
tid, err := agentCredentialTenantID(ctx)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
var exists bool
|
||||
err = s.db.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM agents WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL)`,
|
||||
agentID, tid,
|
||||
).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (s *SQLiteSecureCLIStore) GetAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) (*store.SecureCLIAgentCredential, error) {
|
||||
tid, err := agentCredentialTenantID(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var c store.SecureCLIAgentCredential
|
||||
var env []byte
|
||||
var metaBytes []byte
|
||||
var createdAt, updatedAt string
|
||||
err = s.db.QueryRowContext(ctx,
|
||||
`SELECT `+agentCredSelectCols+`, COALESCE(a.agent_key, ''), COALESCE(a.display_name, '')
|
||||
FROM secure_cli_agent_credentials c
|
||||
LEFT JOIN agents a ON a.id = c.agent_id AND a.tenant_id = c.tenant_id
|
||||
WHERE c.binary_id = ? AND c.agent_id = ? AND c.tenant_id = ?`,
|
||||
binaryID, agentID, tid,
|
||||
).Scan(&c.ID, &c.BinaryID, &c.AgentID, &env, &metaBytes,
|
||||
&c.CredentialType, &c.HostScope, &c.CreatedBy, &createdAt, &updatedAt,
|
||||
&c.AgentKey, &c.Name)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.CreatedAt = createdAt
|
||||
c.UpdatedAt = updatedAt
|
||||
c.Metadata = metaBytes
|
||||
c.EncryptedEnv = s.decryptAgentCredentialEnv(env)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteSecureCLIStore) SetAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, createdBy string) error {
|
||||
return s.SetAgentCredentialsTyped(ctx, binaryID, agentID, encryptedEnv, nil, nil, createdBy)
|
||||
}
|
||||
|
||||
func (s *SQLiteSecureCLIStore) SetAgentCredentialsTyped(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, credentialType, hostScope *string, createdBy string) error {
|
||||
tid, err := agentCredentialTenantID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
envBytes, err := s.encryptAgentCredentialEnv(encryptedEnv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
id := store.GenNewID()
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO secure_cli_agent_credentials
|
||||
(id, binary_id, agent_id, encrypted_env, metadata, tenant_id,
|
||||
credential_type, host_scope, created_by, created_at, updated_at)
|
||||
SELECT ?, b.id, a.id, ?, '{}', ?, ?, ?, ?, ?, ?
|
||||
FROM secure_cli_binaries b
|
||||
JOIN agents a ON a.id = ? AND a.tenant_id = ? AND a.deleted_at IS NULL
|
||||
WHERE b.id = ? AND b.tenant_id = ?
|
||||
ON CONFLICT (binary_id, agent_id, tenant_id) DO UPDATE SET
|
||||
encrypted_env = excluded.encrypted_env,
|
||||
credential_type = excluded.credential_type,
|
||||
host_scope = excluded.host_scope,
|
||||
created_by = excluded.created_by,
|
||||
updated_at = excluded.updated_at`,
|
||||
id, envBytes, tid, credentialType, hostScope, createdBy, now, now,
|
||||
agentID, tid, binaryID, tid,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err == nil && n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLiteSecureCLIStore) DeleteAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) error {
|
||||
tid, err := agentCredentialTenantID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
`DELETE FROM secure_cli_agent_credentials WHERE binary_id = ? AND agent_id = ? AND tenant_id = ?`,
|
||||
binaryID, agentID, tid,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteSecureCLIStore) ListAgentCredentials(ctx context.Context, binaryID uuid.UUID) ([]store.SecureCLIAgentCredential, error) {
|
||||
tid, err := agentCredentialTenantID(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+agentCredSelectCols+`, COALESCE(a.agent_key, ''), COALESCE(a.display_name, '')
|
||||
FROM secure_cli_agent_credentials c
|
||||
LEFT JOIN agents a ON a.id = c.agent_id AND a.tenant_id = c.tenant_id
|
||||
WHERE c.binary_id = ? AND c.tenant_id = ?
|
||||
ORDER BY c.created_at`, binaryID, tid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []store.SecureCLIAgentCredential
|
||||
for rows.Next() {
|
||||
var c store.SecureCLIAgentCredential
|
||||
var env []byte
|
||||
var metaBytes []byte
|
||||
var createdAt, updatedAt string
|
||||
if err := rows.Scan(&c.ID, &c.BinaryID, &c.AgentID, &env, &metaBytes,
|
||||
&c.CredentialType, &c.HostScope, &c.CreatedBy, &createdAt, &updatedAt,
|
||||
&c.AgentKey, &c.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.CreatedAt = createdAt
|
||||
c.UpdatedAt = updatedAt
|
||||
c.Metadata = metaBytes
|
||||
c.EncryptedEnv = s.decryptAgentCredentialEnv(env)
|
||||
result = append(result, c)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteSecureCLIStore) encryptAgentCredentialEnv(env []byte) ([]byte, error) {
|
||||
if len(env) == 0 || s.encKey == "" {
|
||||
return env, nil
|
||||
}
|
||||
encrypted, err := crypto.Encrypt(string(env), s.encKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt agent credential env: %w", err)
|
||||
}
|
||||
return []byte(encrypted), nil
|
||||
}
|
||||
|
||||
func (s *SQLiteSecureCLIStore) decryptAgentCredentialEnv(env []byte) []byte {
|
||||
if len(env) > 0 && s.encKey != "" {
|
||||
if decrypted, err := crypto.Decrypt(string(env), s.encKey); err == nil {
|
||||
return []byte(decrypted)
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
@@ -381,8 +381,6 @@ func (s *SQLiteSecureCLIStore) LookupByBinary(ctx context.Context, binaryName st
|
||||
|
||||
var args []any
|
||||
|
||||
query := `SELECT ` + selectCols
|
||||
|
||||
// Project user-credential columns (encrypted_env + credential_type + host_scope).
|
||||
// When userID is empty we cannot reference uc_user.* — emit NULL placeholders
|
||||
// so the scan column count stays stable.
|
||||
@@ -390,10 +388,17 @@ func (s *SQLiteSecureCLIStore) LookupByBinary(ctx context.Context, binaryName st
|
||||
hasUserJoinNoAgent := userID != "" && agentID == nil
|
||||
|
||||
if hasUserJoin || hasUserJoinNoAgent {
|
||||
query += `, uc_user.encrypted_env AS user_env, uc_user.credential_type AS user_cred_type, uc_user.host_scope AS user_host_scope FROM secure_cli_binaries b`
|
||||
selectCols += `, uc_user.encrypted_env AS user_env, uc_user.credential_type AS user_cred_type, uc_user.host_scope AS user_host_scope`
|
||||
} else {
|
||||
query += `, NULL AS user_env, NULL AS user_cred_type, NULL AS user_host_scope FROM secure_cli_binaries b`
|
||||
selectCols += `, NULL AS user_env, NULL AS user_cred_type, NULL AS user_host_scope`
|
||||
}
|
||||
if agentID != nil {
|
||||
selectCols += `, ac.encrypted_env AS agent_env, ac.credential_type AS agent_cred_type, ac.host_scope AS agent_host_scope`
|
||||
} else {
|
||||
selectCols += `, NULL AS agent_env, NULL AS agent_cred_type, NULL AS agent_host_scope`
|
||||
}
|
||||
|
||||
query := `SELECT ` + selectCols + ` FROM secure_cli_binaries b`
|
||||
|
||||
// LEFT JOIN agent grant
|
||||
if agentID != nil {
|
||||
@@ -402,6 +407,14 @@ func (s *SQLiteSecureCLIStore) LookupByBinary(ctx context.Context, binaryName st
|
||||
} else {
|
||||
query += ` LEFT JOIN secure_cli_agent_grants g ON 0`
|
||||
}
|
||||
if agentID != nil {
|
||||
query += ` LEFT JOIN secure_cli_agent_credentials ac ON ac.binary_id = b.id AND ac.agent_id = ?`
|
||||
args = append(args, *agentID)
|
||||
if !isCross {
|
||||
query += ` AND ac.tenant_id = ?`
|
||||
args = append(args, tid)
|
||||
}
|
||||
}
|
||||
|
||||
// LEFT JOIN user credentials (only when we project uc_user.*)
|
||||
if hasUserJoin || hasUserJoinNoAgent {
|
||||
@@ -457,6 +470,8 @@ func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.
|
||||
var grantEncEnv []byte
|
||||
var userEnv []byte
|
||||
var userCredType, userHostScope *string
|
||||
var agentEnv []byte
|
||||
var agentCredType, agentHostScope *string
|
||||
var createdAt, updatedAt sqliteTime
|
||||
|
||||
err := row.Scan(
|
||||
@@ -466,6 +481,7 @@ func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.
|
||||
&b.Enabled, &b.CreatedBy, &b.AdapterName, &createdAt, &updatedAt,
|
||||
&grantDenyArgs, &grantDenyVerbose, &grantTimeout, &grantTips, &grantEnabled, &grantID, &grantEncEnv,
|
||||
&userEnv, &userCredType, &userHostScope,
|
||||
&agentEnv, &agentCredType, &agentHostScope,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -506,24 +522,46 @@ func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.
|
||||
}
|
||||
grant.TimeoutSeconds = grantTimeout
|
||||
grant.Tips = grantTips
|
||||
if len(grantEncEnv) > 0 && s.encKey != "" {
|
||||
if decrypted, err := crypto.Decrypt(string(grantEncEnv), s.encKey); err == nil {
|
||||
grant.EncryptedEnv = []byte(decrypted)
|
||||
if len(grantEncEnv) > 0 {
|
||||
if s.encKey != "" {
|
||||
if decrypted, err := crypto.Decrypt(string(grantEncEnv), s.encKey); err == nil {
|
||||
grant.EncryptedEnv = []byte(decrypted)
|
||||
}
|
||||
} else {
|
||||
grant.EncryptedEnv = grantEncEnv
|
||||
}
|
||||
}
|
||||
b.MergeGrantOverrides(grant)
|
||||
}
|
||||
|
||||
// Decrypt per-user env
|
||||
if len(userEnv) > 0 && s.encKey != "" {
|
||||
if decrypted, err := crypto.Decrypt(string(userEnv), s.encKey); err == nil {
|
||||
b.UserEnv = []byte(decrypted)
|
||||
if len(userEnv) > 0 {
|
||||
if s.encKey != "" {
|
||||
if decrypted, err := crypto.Decrypt(string(userEnv), s.encKey); err == nil {
|
||||
b.UserEnv = []byte(decrypted)
|
||||
}
|
||||
} else {
|
||||
b.UserEnv = userEnv
|
||||
}
|
||||
}
|
||||
|
||||
// Typed-credential metadata from the joined user-credential row.
|
||||
b.UserCredentialType = userCredType
|
||||
b.UserHostScope = userHostScope
|
||||
if len(b.UserEnv) > 0 || userCredType != nil || userHostScope != nil {
|
||||
b.SetEffectiveCredential(b.UserEnv, userCredType, userHostScope, "user", "")
|
||||
}
|
||||
if len(agentEnv) > 0 || agentCredType != nil || agentHostScope != nil {
|
||||
decrypted := agentEnv
|
||||
if len(agentEnv) > 0 && s.encKey != "" {
|
||||
if d, err := crypto.Decrypt(string(agentEnv), s.encKey); err == nil {
|
||||
decrypted = []byte(d)
|
||||
}
|
||||
}
|
||||
if b.CredentialSource == "" {
|
||||
b.SetEffectiveCredential(decrypted, agentCredType, agentHostScope, "agent", "")
|
||||
}
|
||||
}
|
||||
|
||||
return &b, nil
|
||||
}
|
||||
@@ -533,7 +571,7 @@ func (s *SQLiteSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *sto
|
||||
if len(scopes) == 0 || b == nil {
|
||||
return b, nil
|
||||
}
|
||||
hasUserCredential := len(b.UserEnv) > 0 || b.UserCredentialType != nil || b.UserHostScope != nil
|
||||
hasUserCredential := b.CredentialSource == "user" || len(b.UserEnv) > 0 || b.UserCredentialType != nil || b.UserHostScope != nil
|
||||
disabledByContext := false
|
||||
for _, scope := range scopes {
|
||||
grants, err := s.ListContextGrantsForScope(ctx, scope)
|
||||
@@ -572,9 +610,7 @@ func (s *SQLiteSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *sto
|
||||
continue
|
||||
}
|
||||
if !hasUserCredential {
|
||||
b.UserEnv = creds.EncryptedEnv
|
||||
b.UserCredentialType = creds.CredentialType
|
||||
b.UserHostScope = creds.HostScope
|
||||
b.SetEffectiveCredential(creds.EncryptedEnv, creds.CredentialType, creds.HostScope, "context", scope.ScopeType+":"+scope.ScopeKey)
|
||||
} else {
|
||||
b.EncryptedEnv = creds.EncryptedEnv
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package sqlitestore
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -75,11 +76,11 @@ func TestSQLite_CreateBinary_AdapterNameRoundTrip(t *testing.T) {
|
||||
|
||||
adapter := "git"
|
||||
bin := &store.SecureCLIBinary{
|
||||
BinaryName: "git",
|
||||
Description: "git with PAT adapter",
|
||||
IsGlobal: true,
|
||||
Enabled: true,
|
||||
CreatedBy: "u-tester",
|
||||
BinaryName: "git",
|
||||
Description: "git with PAT adapter",
|
||||
IsGlobal: true,
|
||||
Enabled: true,
|
||||
CreatedBy: "u-tester",
|
||||
AdapterName: &adapter,
|
||||
EncryptedEnv: []byte(`{}`),
|
||||
}
|
||||
@@ -206,11 +207,11 @@ func TestSQLite_LookupByBinary_ProjectsNewColumns(t *testing.T) {
|
||||
|
||||
adapter := "git"
|
||||
bin := &store.SecureCLIBinary{
|
||||
BinaryName: "git",
|
||||
Description: "git adapter binary",
|
||||
IsGlobal: true,
|
||||
Enabled: true,
|
||||
CreatedBy: "u-tester",
|
||||
BinaryName: "git",
|
||||
Description: "git adapter binary",
|
||||
IsGlobal: true,
|
||||
Enabled: true,
|
||||
CreatedBy: "u-tester",
|
||||
AdapterName: &adapter,
|
||||
EncryptedEnv: []byte(`{}`),
|
||||
}
|
||||
@@ -268,3 +269,169 @@ func TestSQLite_LookupByBinary_BackwardCompatibleNulls(t *testing.T) {
|
||||
t.Fatalf("expected UserHostScope NULL, got %q", *got.UserHostScope)
|
||||
}
|
||||
}
|
||||
|
||||
func seedAgent(t *testing.T, db *sql.DB, tenantID uuid.UUID, key string) uuid.UUID {
|
||||
t.Helper()
|
||||
id := uuid.New()
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO agents (id, tenant_id, agent_key, display_name, owner_id, provider, model, agent_type, status)
|
||||
VALUES (?, ?, ?, ?, 'owner', 'openai', 'gpt-5', 'predefined', 'active')`,
|
||||
id, tenantID, key, key,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed agent %s: %v", key, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestSQLite_SetAgentCredentialsTyped_RoundTrip(t *testing.T) {
|
||||
s, db := newPhase1Store(t)
|
||||
tid := seedTenant(t, db, "t-agent-typed")
|
||||
ctx := store.WithTenantID(context.Background(), tid)
|
||||
agentID := seedAgent(t, db, tid, "builder")
|
||||
|
||||
seedBinary(t, db, tid, "git", true, true)
|
||||
var binID uuid.UUID
|
||||
if err := db.QueryRow(`SELECT id FROM secure_cli_binaries WHERE binary_name = ? AND tenant_id = ?`, "git", tid).Scan(&binID); err != nil {
|
||||
t.Fatalf("lookup seeded binary: %v", err)
|
||||
}
|
||||
|
||||
credType := "pat"
|
||||
hostScope := "github.com"
|
||||
plaintext := []byte(`{"token":"ghp_agent"}`)
|
||||
if err := s.SetAgentCredentialsTyped(ctx, binID, agentID, plaintext, &credType, &hostScope, "admin"); err != nil {
|
||||
t.Fatalf("SetAgentCredentialsTyped: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetAgentCredentials(ctx, binID, agentID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAgentCredentials: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("expected credential, got nil")
|
||||
}
|
||||
if got.CredentialType == nil || *got.CredentialType != "pat" {
|
||||
t.Fatalf("expected credential_type=pat, got %v", got.CredentialType)
|
||||
}
|
||||
if got.HostScope == nil || *got.HostScope != "github.com" {
|
||||
t.Fatalf("expected host_scope=github.com, got %v", got.HostScope)
|
||||
}
|
||||
if string(got.EncryptedEnv) != string(plaintext) {
|
||||
t.Fatalf("decrypted env mismatch: got %q want %q", got.EncryptedEnv, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_LookupByBinary_UsesAgentCredentialWithoutUserID(t *testing.T) {
|
||||
s, db := newPhase1Store(t)
|
||||
tid := seedTenant(t, db, "t-agent-lookup")
|
||||
ctx := store.WithTenantID(context.Background(), tid)
|
||||
agentID := seedAgent(t, db, tid, "builder")
|
||||
|
||||
adapter := "git"
|
||||
bin := &store.SecureCLIBinary{
|
||||
BinaryName: "git",
|
||||
Description: "git adapter binary",
|
||||
IsGlobal: true,
|
||||
Enabled: true,
|
||||
CreatedBy: "u-tester",
|
||||
AdapterName: &adapter,
|
||||
EncryptedEnv: []byte(`{}`),
|
||||
}
|
||||
if err := s.Create(ctx, bin); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
credType := "pat"
|
||||
hostScope := "github.com"
|
||||
if err := s.SetAgentCredentialsTyped(ctx, bin.ID, agentID, []byte(`{"token":"agent"}`), &credType, &hostScope, "admin"); err != nil {
|
||||
t.Fatalf("SetAgentCredentialsTyped: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.LookupByBinary(ctx, "git", &agentID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("LookupByBinary: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("expected binary, got nil")
|
||||
}
|
||||
if got.CredentialSource != "agent" {
|
||||
t.Fatalf("expected agent source, got %q", got.CredentialSource)
|
||||
}
|
||||
if got.CredentialType == nil || *got.CredentialType != "pat" {
|
||||
t.Fatalf("expected CredentialType=pat, got %v", got.CredentialType)
|
||||
}
|
||||
if string(got.CredentialEnv) != `{"token":"agent"}` {
|
||||
t.Fatalf("expected agent credential env, got %q", got.CredentialEnv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_LookupByBinary_UserCredentialOverridesAgentCredential(t *testing.T) {
|
||||
s, db := newPhase1Store(t)
|
||||
tid := seedTenant(t, db, "t-user-over-agent")
|
||||
ctx := store.WithTenantID(context.Background(), tid)
|
||||
agentID := seedAgent(t, db, tid, "builder")
|
||||
|
||||
adapter := "git"
|
||||
bin := &store.SecureCLIBinary{
|
||||
BinaryName: "git",
|
||||
Description: "git adapter binary",
|
||||
IsGlobal: true,
|
||||
Enabled: true,
|
||||
CreatedBy: "u-tester",
|
||||
AdapterName: &adapter,
|
||||
EncryptedEnv: []byte(`{}`),
|
||||
}
|
||||
if err := s.Create(ctx, bin); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
credType := "pat"
|
||||
hostScope := "github.com"
|
||||
if err := s.SetAgentCredentialsTyped(ctx, bin.ID, agentID, []byte(`{"token":"agent"}`), &credType, &hostScope, "admin"); err != nil {
|
||||
t.Fatalf("SetAgentCredentialsTyped: %v", err)
|
||||
}
|
||||
if err := s.SetUserCredentialsTyped(ctx, bin.ID, "u-1", []byte(`{"token":"user"}`), &credType, &hostScope); err != nil {
|
||||
t.Fatalf("SetUserCredentialsTyped: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.LookupByBinary(ctx, "git", &agentID, "u-1")
|
||||
if err != nil {
|
||||
t.Fatalf("LookupByBinary: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("expected binary, got nil")
|
||||
}
|
||||
if got.CredentialSource != "user" {
|
||||
t.Fatalf("expected user source, got %q", got.CredentialSource)
|
||||
}
|
||||
if string(got.CredentialEnv) != `{"token":"user"}` {
|
||||
t.Fatalf("expected user credential env, got %q", got.CredentialEnv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_SetAgentCredentialsRejectsCrossTenantAgent(t *testing.T) {
|
||||
s, db := newPhase1Store(t)
|
||||
tenantA := seedTenant(t, db, "t-agent-cred-a")
|
||||
tenantB := seedTenant(t, db, "t-agent-cred-b")
|
||||
ctxA := store.WithTenantID(context.Background(), tenantA)
|
||||
agentB := seedAgent(t, db, tenantB, "other-tenant-agent")
|
||||
|
||||
bin := &store.SecureCLIBinary{
|
||||
BinaryName: "git",
|
||||
Description: "git adapter binary",
|
||||
IsGlobal: true,
|
||||
Enabled: true,
|
||||
CreatedBy: "u-tester",
|
||||
EncryptedEnv: []byte(`{}`),
|
||||
}
|
||||
if err := s.Create(ctxA, bin); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
credType := "pat"
|
||||
hostScope := "github.com"
|
||||
err := s.SetAgentCredentialsTyped(ctxA, bin.ID, agentB, []byte(`{"token":"agent"}`), &credType, &hostScope, "admin")
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("expected sql.ErrNoRows for cross-tenant agent, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,8 +80,8 @@ func AdapterFor(name string) CredentialAdapter {
|
||||
// env-injection-only path so every existing preset behaves identically.
|
||||
type passthroughAdapter struct{}
|
||||
|
||||
func (passthroughAdapter) Name() string { return "passthrough" }
|
||||
func (passthroughAdapter) ShouldInject([]string) bool { return false }
|
||||
func (passthroughAdapter) Name() string { return "passthrough" }
|
||||
func (passthroughAdapter) ShouldInject([]string) bool { return false }
|
||||
func (passthroughAdapter) Prepare(context.Context, *store.SecureCLIBinary, *store.SecureCLIUserCredential, []string) (*Injection, error) {
|
||||
return &Injection{}, nil
|
||||
}
|
||||
@@ -120,16 +120,18 @@ func sortedKeys(m map[string]string) []string {
|
||||
// - adapter: adapter name (e.g. "git", "passthrough")
|
||||
// - binary: binary name (e.g. "git", "gh")
|
||||
// - user_id: tenant user UUID (or empty for global-only contexts)
|
||||
// - credential_source: source selected by runtime ("user", "context", "agent", or empty)
|
||||
// - env_keys: sorted env-var NAMES (never values)
|
||||
// - argv_prefix_len: number of argv elements prepended (NOT their content)
|
||||
// - host_scope_hash: SHA-256 first 8 hex chars of host_scope (or "none")
|
||||
func emitSystemEnvInjectionAudit(adapter, binary, userID string, inj *Injection, hostScope *string) {
|
||||
func emitSystemEnvInjectionAudit(adapter, binary, userID, credentialSource string, inj *Injection, hostScope *string) {
|
||||
if inj == nil {
|
||||
return
|
||||
}
|
||||
slog.Warn("security.system_env_injection",
|
||||
"adapter", adapter,
|
||||
"user_id", userID,
|
||||
"credential_source", credentialSource,
|
||||
"binary", binary,
|
||||
"env_keys", sortedKeys(inj.Env),
|
||||
"argv_prefix_len", len(inj.ArgvPrefix),
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestEmitSystemEnvInjectionAudit_PAT(t *testing.T) {
|
||||
}
|
||||
|
||||
records, raw := capturedAudit(t, func() {
|
||||
emitSystemEnvInjectionAudit("git", "git", "user-42", inj, &scope)
|
||||
emitSystemEnvInjectionAudit("git", "git", "user-42", "agent", inj, &scope)
|
||||
})
|
||||
|
||||
if len(records) != 1 {
|
||||
@@ -81,6 +81,9 @@ func TestEmitSystemEnvInjectionAudit_PAT(t *testing.T) {
|
||||
if rec["user_id"] != "user-42" {
|
||||
t.Errorf("user_id=%v, want user-42", rec["user_id"])
|
||||
}
|
||||
if rec["credential_source"] != "agent" {
|
||||
t.Errorf("credential_source=%v, want agent", rec["credential_source"])
|
||||
}
|
||||
if rec["argv_prefix_len"] != float64(0) {
|
||||
t.Errorf("argv_prefix_len=%v, want 0", rec["argv_prefix_len"])
|
||||
}
|
||||
@@ -132,7 +135,7 @@ func TestEmitSystemEnvInjectionAudit_SSH(t *testing.T) {
|
||||
}
|
||||
|
||||
records, raw := capturedAudit(t, func() {
|
||||
emitSystemEnvInjectionAudit("git", "git", "u1", inj, &scope)
|
||||
emitSystemEnvInjectionAudit("git", "git", "u1", "user", inj, &scope)
|
||||
})
|
||||
|
||||
if len(records) != 1 {
|
||||
@@ -161,7 +164,7 @@ func TestEmitSystemEnvInjectionAudit_SSH(t *testing.T) {
|
||||
// 3. Nil injection: emitter is a no-op (audit only fires on actual injection).
|
||||
func TestEmitSystemEnvInjectionAudit_NilInjection(t *testing.T) {
|
||||
records, _ := capturedAudit(t, func() {
|
||||
emitSystemEnvInjectionAudit("git", "git", "u1", nil, nil)
|
||||
emitSystemEnvInjectionAudit("git", "git", "u1", "user", nil, nil)
|
||||
})
|
||||
if len(records) != 0 {
|
||||
t.Fatalf("expected 0 records for nil injection, got %d", len(records))
|
||||
@@ -172,7 +175,7 @@ func TestEmitSystemEnvInjectionAudit_NilInjection(t *testing.T) {
|
||||
func TestEmitSystemEnvInjectionAudit_NilHostScope(t *testing.T) {
|
||||
inj := &Injection{Env: map[string]string{"FOO": "bar"}}
|
||||
records, _ := capturedAudit(t, func() {
|
||||
emitSystemEnvInjectionAudit("passthrough", "gh", "u1", inj, nil)
|
||||
emitSystemEnvInjectionAudit("passthrough", "gh", "u1", "", inj, nil)
|
||||
})
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("expected 1 record, got %d", len(records))
|
||||
|
||||
@@ -490,14 +490,12 @@ func (t *ExecTool) executeCredentialed(ctx context.Context, cred *store.SecureCL
|
||||
if len(inj.ArgvPrefix) > 0 {
|
||||
args = append(append([]string{}, inj.ArgvPrefix...), args...)
|
||||
}
|
||||
for k, v := range inj.Env {
|
||||
envMap[k] = v
|
||||
}
|
||||
maps.Copy(envMap, inj.Env)
|
||||
if len(inj.ScrubValues) > 0 {
|
||||
AddScrubValuesCtx(ctx, inj.ScrubValues...)
|
||||
}
|
||||
emitSystemEnvInjectionAudit(adapter.Name(), binary,
|
||||
store.CredentialUserIDFromContext(ctx), inj, cred.UserHostScope)
|
||||
store.CredentialUserIDFromContext(ctx), cred.CredentialSource, inj, effectiveHostScope(cred))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,13 +506,23 @@ func (t *ExecTool) executeCredentialed(ctx context.Context, cred *store.SecureCL
|
||||
return t.executeCredentialedHost(ctx, absPath, args, cwd, envMap, timeout)
|
||||
}
|
||||
|
||||
// userCredFromBinary synthesizes a *SecureCLIUserCredential from the fields
|
||||
// LookupByBinary's LEFT JOIN populated on the binary row. Returns nil when
|
||||
// no user credential exists (UserEnv empty + no typed metadata).
|
||||
// userCredFromBinary synthesizes adapter credential input from LookupByBinary's
|
||||
// effective credential fields. The adapter contract still uses
|
||||
// SecureCLIUserCredential, but the material can come from user, context, or
|
||||
// agent scoped rows.
|
||||
func userCredFromBinary(ctx context.Context, bin *store.SecureCLIBinary) *store.SecureCLIUserCredential {
|
||||
if bin == nil {
|
||||
return nil
|
||||
}
|
||||
if len(bin.CredentialEnv) > 0 || bin.CredentialType != nil || bin.CredentialHostScope != nil {
|
||||
return &store.SecureCLIUserCredential{
|
||||
BinaryID: bin.ID,
|
||||
UserID: credentialSubjectForAdapter(ctx, bin),
|
||||
EncryptedEnv: bin.CredentialEnv,
|
||||
CredentialType: bin.CredentialType,
|
||||
HostScope: bin.CredentialHostScope,
|
||||
}
|
||||
}
|
||||
if len(bin.UserEnv) == 0 && bin.UserCredentialType == nil && bin.UserHostScope == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -527,6 +535,23 @@ func userCredFromBinary(ctx context.Context, bin *store.SecureCLIBinary) *store.
|
||||
}
|
||||
}
|
||||
|
||||
func credentialSubjectForAdapter(ctx context.Context, bin *store.SecureCLIBinary) string {
|
||||
if bin != nil && bin.CredentialSubjectID != "" {
|
||||
return bin.CredentialSubjectID
|
||||
}
|
||||
return store.CredentialUserIDFromContext(ctx)
|
||||
}
|
||||
|
||||
func effectiveHostScope(bin *store.SecureCLIBinary) *string {
|
||||
if bin == nil {
|
||||
return nil
|
||||
}
|
||||
if bin.CredentialHostScope != nil {
|
||||
return bin.CredentialHostScope
|
||||
}
|
||||
return bin.UserHostScope
|
||||
}
|
||||
|
||||
func mergeCredentialedEnv(cred *store.SecureCLIBinary) (map[string]string, error) {
|
||||
envMap := make(map[string]string)
|
||||
if cred == nil {
|
||||
@@ -539,7 +564,18 @@ func mergeCredentialedEnv(cred *store.SecureCLIBinary) (map[string]string, error
|
||||
}
|
||||
maps.Copy(envMap, baseEnv)
|
||||
}
|
||||
if len(cred.CredentialEnv) > 0 && isEnvCredentialType(cred.CredentialType) {
|
||||
scopedEnv, err := store.FlattenSecureCLIEnv(cred.CredentialEnv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maps.Copy(envMap, scopedEnv)
|
||||
return envMap, nil
|
||||
}
|
||||
if len(cred.UserEnv) > 0 {
|
||||
if !isEnvCredentialType(cred.UserCredentialType) {
|
||||
return envMap, nil
|
||||
}
|
||||
userEnvMap, err := store.FlattenSecureCLIEnv(cred.UserEnv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -549,6 +585,10 @@ func mergeCredentialedEnv(cred *store.SecureCLIBinary) (map[string]string, error
|
||||
return envMap, nil
|
||||
}
|
||||
|
||||
func isEnvCredentialType(typ *string) bool {
|
||||
return typ == nil || *typ == "" || *typ == "env"
|
||||
}
|
||||
|
||||
func missingRequiredCredentialEnv(binary string, envMap map[string]string) []string {
|
||||
required := requiredCredentialEnvVars(binary)
|
||||
if len(required) == 0 {
|
||||
|
||||
@@ -73,6 +73,44 @@ func TestMergeCredentialedEnvFlattensSensitiveValueEntries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCredentialedEnvUsesAgentEnvCredential(t *testing.T) {
|
||||
typ := "env"
|
||||
binary := &store.SecureCLIBinary{
|
||||
EncryptedEnv: []byte(`{"SHARED_KEY":"binary","BINARY_ONLY":"base"}`),
|
||||
}
|
||||
binary.SetEffectiveCredential([]byte(`{"SHARED_KEY":"agent","AGENT_ONLY":"scoped"}`), &typ, nil, "agent", "")
|
||||
|
||||
env, err := mergeCredentialedEnv(binary)
|
||||
if err != nil {
|
||||
t.Fatalf("mergeCredentialedEnv() error = %v", err)
|
||||
}
|
||||
if env["SHARED_KEY"] != "agent" {
|
||||
t.Fatalf("SHARED_KEY = %q, want agent override", env["SHARED_KEY"])
|
||||
}
|
||||
if env["AGENT_ONLY"] != "scoped" {
|
||||
t.Fatalf("AGENT_ONLY = %q", env["AGENT_ONLY"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCredentialedEnvDoesNotFlattenTypedCredentialBlob(t *testing.T) {
|
||||
typ := "pat"
|
||||
binary := &store.SecureCLIBinary{
|
||||
EncryptedEnv: []byte(`{"PUBLIC_BASE_URL":"https://goclaw.sh"}`),
|
||||
}
|
||||
binary.SetEffectiveCredential([]byte(`{"token":"ghp_not_real_token"}`), &typ, nil, "agent", "")
|
||||
|
||||
env, err := mergeCredentialedEnv(binary)
|
||||
if err != nil {
|
||||
t.Fatalf("mergeCredentialedEnv() error = %v", err)
|
||||
}
|
||||
if _, ok := env["token"]; ok {
|
||||
t.Fatalf("typed credential blob was flattened into child env: %#v", env)
|
||||
}
|
||||
if env["PUBLIC_BASE_URL"] != "https://goclaw.sh" {
|
||||
t.Fatalf("PUBLIC_BASE_URL = %q", env["PUBLIC_BASE_URL"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExec_RapidAPIMissingRequiredEnvFailsBeforeBinaryResolution(t *testing.T) {
|
||||
stub := newStubSecureCLIStore()
|
||||
stub.byName["rapidapi"] = &store.SecureCLIBinary{
|
||||
|
||||
@@ -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 = 76
|
||||
const RequiredSchemaVersion uint = 77
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS secure_cli_agent_credentials;
|
||||
DROP INDEX IF EXISTS idx_agents_id_tenant;
|
||||
DROP INDEX IF EXISTS idx_secure_cli_binaries_id_tenant;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Per-agent credentials for secure CLI binaries.
|
||||
-- Stores typed secret material separately from secure_cli_agent_grants policy.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_secure_cli_binaries_id_tenant
|
||||
ON secure_cli_binaries(id, tenant_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_id_tenant
|
||||
ON agents(id, tenant_id);
|
||||
|
||||
CREATE TABLE secure_cli_agent_credentials (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
binary_id UUID NOT NULL,
|
||||
agent_id UUID NOT NULL,
|
||||
encrypted_env BYTEA NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
credential_type TEXT NULL,
|
||||
host_scope TEXT NULL,
|
||||
created_by VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(binary_id, agent_id, tenant_id),
|
||||
CONSTRAINT fk_scac_binary_tenant
|
||||
FOREIGN KEY (binary_id, tenant_id) REFERENCES secure_cli_binaries(id, tenant_id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_scac_agent_tenant
|
||||
FOREIGN KEY (agent_id, tenant_id) REFERENCES agents(id, tenant_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_scac_tenant ON secure_cli_agent_credentials(tenant_id);
|
||||
CREATE INDEX idx_scac_binary ON secure_cli_agent_credentials(binary_id);
|
||||
CREATE INDEX idx_scac_agent ON secure_cli_agent_credentials(agent_id);
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
---
|
||||
phase: 1
|
||||
title: Research and contract tests
|
||||
status: completed
|
||||
effort: ''
|
||||
---
|
||||
|
||||
# Phase 1: Research and contract tests
|
||||
|
||||
## Context Links
|
||||
|
||||
- Issue: https://github.com/digitopvn/goclaw/issues/117
|
||||
- Current user ID resolver: `internal/agent/user_identity_resolver.go:40`
|
||||
- Tool execution context injection: `internal/agent/loop_pipeline_tool_callbacks.go:47`
|
||||
- Secure CLI store contract: `internal/store/secure_cli_store.go:120`
|
||||
- Current per-user HTTP API: `internal/http/secure_cli_user_credentials.go:13`
|
||||
- Current git UI entry point: `ui/web/src/pages/cli-credentials/cli-credentials-table.tsx:80`
|
||||
|
||||
## Overview
|
||||
|
||||
Write characterization and contract tests before schema or UI changes. The goal is to pin the current failure mode: git typed credentials depend on a user credential row, but cross-channel usage often cannot map to the same credential user ID.
|
||||
|
||||
Priority: P1.
|
||||
|
||||
Status: pending.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- Agent identity is stable for the runtime path; external user identity is not stable across channels.
|
||||
- User credentials should stay supported, but they should not be the primary git credential setup path.
|
||||
- Existing context credentials already show that credential resolution is not purely per-user; agent credentials should join that explicit precedence chain.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Preserve existing per-user credential behavior.
|
||||
- Add tests that fail under the current implementation when no matching `userID` exists but an agent credential exists.
|
||||
- Make API and UI requirements explicit before implementation.
|
||||
|
||||
## Architecture
|
||||
|
||||
Effective credential source should become a small explicit enum in tests and later code:
|
||||
|
||||
1. `user` for explicit per-user override.
|
||||
2. `context` for group/member/channel scoped credential.
|
||||
3. `agent` for the new agent-scoped credential.
|
||||
4. `binary` for legacy/global binary env.
|
||||
5. `none` when no typed credential is available.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify tests under `internal/store/pg/`, `internal/store/sqlitestore/`, `internal/tools/`, and `internal/http/`.
|
||||
- Add or extend UI tests under `ui/web/src/pages/cli-credentials/__tests__/`.
|
||||
- No production code changes in this phase except test fixtures if needed.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add store contract tests proving the intended precedence: user > context > agent > binary.
|
||||
2. Add a runtime test where the same agent executes `git clone` from two different credential user IDs and resolves the same agent credential.
|
||||
3. Add an HTTP route contract test for planned agent credential endpoints:
|
||||
- `GET /v1/cli-credentials/{id}/agent-credentials`
|
||||
- `GET /v1/cli-credentials/{id}/agent-credentials/{agentId}`
|
||||
- `PUT /v1/cli-credentials/{id}/agent-credentials/{agentId}`
|
||||
- `DELETE /v1/cli-credentials/{id}/agent-credentials/{agentId}`
|
||||
4. Add negative API tests:
|
||||
- invalid `binaryID`
|
||||
- invalid `agentID`
|
||||
- missing `host_scope` for `pat` and `ssh_key`
|
||||
- unsupported `credential_type`
|
||||
- response never includes raw token/key/blob
|
||||
5. Add Web UI tests that the git credential action defaults to Agent Credentials, with User Credentials shown as advanced/personal override.
|
||||
6. Document which tests fail before implementation.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] Store precedence tests written.
|
||||
- [ ] Runtime cross-channel agent credential test written.
|
||||
- [ ] HTTP endpoint contract tests written.
|
||||
- [ ] UI default-flow test written.
|
||||
- [ ] Initial failing test set documented in the phase notes.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Tests prove the current user-id keyed design cannot satisfy the target behavior.
|
||||
- [ ] Tests define the exact endpoint contract and response masking.
|
||||
- [ ] No implementation-only code is added before the contract tests exist.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- Risk: tests could encode a wrong precedence order. Mitigation: keep user override highest for compatibility, but make agent credential the default UI path.
|
||||
- Risk: agent credential could accidentally grant binary execution access. Mitigation: test that credential rows do not bypass non-global agent grants.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Contract tests must assert no plaintext credential values appear in list/detail responses, audit labels, logs, or errors.
|
||||
- Tests must assert tenant isolation for every new endpoint.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Phase 2 adds schema and store implementation until Phase 1 tests pass.
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
phase: 2
|
||||
title: Schema and store resolver
|
||||
status: completed
|
||||
effort: ''
|
||||
---
|
||||
|
||||
# Phase 2: Schema and store resolver
|
||||
|
||||
## Context Links
|
||||
|
||||
- User credential struct: `internal/store/secure_cli_store.go:79`
|
||||
- Agent grant struct: `internal/store/secure_cli_store.go:97`
|
||||
- PostgreSQL lookup path: `internal/store/pg/secure_cli.go:337`
|
||||
- Context credential overlay: `internal/store/pg/secure_cli.go:505`
|
||||
- SQLite schema version map: `internal/store/sqlitestore/schema.go`
|
||||
- PostgreSQL latest migration at planning time: `migrations/000076_channel_memory_extraction.up.sql`
|
||||
|
||||
## Overview
|
||||
|
||||
Create durable agent-scoped typed credential storage and make `LookupByBinary` return an effective credential source without depending on channel-specific user IDs.
|
||||
|
||||
Priority: P1.
|
||||
|
||||
Status: pending.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- `secure_cli_agent_grants.encrypted_env` is a policy override payload, not a typed credential identity. It lacks `credential_type` and `host_scope`.
|
||||
- A dedicated table keeps grant authorization separate from credential material.
|
||||
- SQLite must be updated in both fresh schema and incremental migrations.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Add `secure_cli_agent_credentials` for both PostgreSQL and SQLite.
|
||||
- Encrypt secret blob with the same AES-256-GCM pattern as existing SecureCLI credentials.
|
||||
- Preserve per-user and context credentials.
|
||||
- Return source metadata for audit and UI.
|
||||
- Do not let an agent credential row grant access to a non-global CLI binary by itself.
|
||||
|
||||
## Architecture
|
||||
|
||||
New table shape:
|
||||
|
||||
```sql
|
||||
secure_cli_agent_credentials (
|
||||
id uuid/text primary key,
|
||||
tenant_id uuid/text not null,
|
||||
binary_id uuid/text not null,
|
||||
agent_id uuid/text not null,
|
||||
encrypted_env bytea/blob not null,
|
||||
metadata jsonb/text not null default '{}',
|
||||
credential_type text null,
|
||||
host_scope text null,
|
||||
created_by text not null default '',
|
||||
created_at timestamptz/text not null,
|
||||
updated_at timestamptz/text not null,
|
||||
unique (tenant_id, binary_id, agent_id)
|
||||
)
|
||||
```
|
||||
|
||||
Effective precedence:
|
||||
|
||||
1. Per-user credential when `userID` maps to a row.
|
||||
2. Context scoped credential from the channel scope chain.
|
||||
3. Agent credential for `(tenant_id, binary_id, agent_id)`.
|
||||
4. Binary/global env.
|
||||
|
||||
Authorization rule:
|
||||
|
||||
- If `secure_cli_binaries.is_global = false`, `secure_cli_agent_grants` must still allow the agent before runtime uses the binary or its credential.
|
||||
- If `is_global = true`, an agent credential can specialize the otherwise global binary for that agent.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Add migration: `migrations/000077_secure_cli_agent_credentials.up.sql` and `.down.sql`, if `000077` is still the next number.
|
||||
- Update `internal/upgrade/version.go`.
|
||||
- Update `internal/store/secure_cli_store.go`.
|
||||
- Add `internal/store/pg/secure_cli_agent_credentials.go`.
|
||||
- Add `internal/store/sqlitestore/secure-cli-agent-credentials.go`.
|
||||
- Update `internal/store/pg/secure_cli.go`.
|
||||
- Update `internal/store/sqlitestore/secure-cli.go`.
|
||||
- Update `internal/store/sqlitestore/schema.sql` and `internal/store/sqlitestore/schema.go`.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Re-run `find migrations -name '*.up.sql' | sort | tail` and choose the next migration number.
|
||||
2. Add PG migration with foreign keys to `secure_cli_binaries`, `agents`, and tenant scope. Add indexes on `(tenant_id, binary_id)`, `(tenant_id, agent_id)`, and unique `(tenant_id, binary_id, agent_id)`.
|
||||
3. Add SQLite fresh schema and incremental migration. Bump `SchemaVersion`.
|
||||
4. Add `SecureCLIAgentCredential` struct and store methods:
|
||||
- `GetAgentCredentials(ctx, binaryID, agentID)`
|
||||
- `SetAgentCredentialsTyped(ctx, binaryID, agentID, encryptedEnv, credentialType, hostScope)`
|
||||
- `SetAgentCredentials(ctx, binaryID, agentID, encryptedEnv)` for legacy env
|
||||
- `DeleteAgentCredentials(ctx, binaryID, agentID)`
|
||||
- `ListAgentCredentials(ctx, binaryID)`
|
||||
5. Extend lookup result with effective credential source fields. Prefer a neutral name such as `CredentialEnv`, `CredentialType`, `CredentialHostScope`, `CredentialSource`, and `CredentialSubjectID` instead of reusing `User*` fields for non-user sources.
|
||||
6. Update PG and SQLite lookup:
|
||||
- join user credential only when `userID` exists
|
||||
- join agent credential when `agentID` exists
|
||||
- apply context credentials before agent credential if context credential exists
|
||||
- preserve grant authorization check before returning a non-global binary
|
||||
7. Update fake stores in tests to implement the new interface.
|
||||
8. Run targeted store tests for PG and SQLite.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] PG migration added and down migration removes table/indexes.
|
||||
- [ ] SQLite schema and version migration added.
|
||||
- [ ] Store interface and concrete PG/SQLite methods added.
|
||||
- [ ] Effective source metadata added without breaking JSON responses.
|
||||
- [ ] Phase 1 store tests pass.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] `LookupByBinary` can resolve typed git credentials for an agent even when `userID == ""`.
|
||||
- [ ] Per-user credential still wins when present.
|
||||
- [ ] Context credential still wins over agent credential.
|
||||
- [ ] Non-global binaries still require enabled grants.
|
||||
- [ ] PG and SQLite tests cover fresh and migrated schemas.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- Risk: reusing `UserEnv` for agent credentials hides source semantics. Mitigation: introduce source-neutral fields and keep `User*` only for backward compatibility during refactor.
|
||||
- Risk: migration number collision. Mitigation: verify immediately before implementation.
|
||||
- Risk: SQLite desktop startup breaks. Mitigation: update both schema.sql and incremental migration map.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Secret values remain encrypted at rest.
|
||||
- Store methods must scope by tenant in every query.
|
||||
- Delete binary or agent should cascade or fail predictably; use foreign keys consistent with existing store behavior.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Phase 3 exposes API CRUD over the new store methods.
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
---
|
||||
phase: 3
|
||||
title: HTTP API credential management
|
||||
status: completed
|
||||
effort: ''
|
||||
---
|
||||
|
||||
# Phase 3: HTTP API credential management
|
||||
|
||||
## Context Links
|
||||
|
||||
- Route registration: `internal/http/secure_cli.go:38`
|
||||
- Current user credential handlers: `internal/http/secure_cli_user_credentials.go:13`
|
||||
- Typed credential validator: `internal/http/secure_cli_typed_credentials.go:54`
|
||||
- API docs table: `docs/18-http-api.md:1176`
|
||||
|
||||
## Overview
|
||||
|
||||
Add HTTP API endpoints for agent-scoped CLI credential management. This is required by the user request and must not be left as a UI-only feature.
|
||||
|
||||
Priority: P1.
|
||||
|
||||
Status: pending.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- The validator for `{credential_type, host_scope, blob}` already exists for user credentials and should be reused for agent credentials.
|
||||
- API must make clear that credentials are not grants. A credential row stores secret material; `agent-grants` still controls non-global binary access.
|
||||
- Responses should mirror the user credential API, but use `agent_id` and optional agent display metadata.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Add routes:
|
||||
- `GET /v1/cli-credentials/{id}/agent-credentials`
|
||||
- `GET /v1/cli-credentials/{id}/agent-credentials/{agentId}`
|
||||
- `PUT /v1/cli-credentials/{id}/agent-credentials/{agentId}`
|
||||
- `DELETE /v1/cli-credentials/{id}/agent-credentials/{agentId}`
|
||||
- Reuse typed payload body:
|
||||
- `credential_type: "pat" | "ssh_key" | "env"`
|
||||
- `host_scope`
|
||||
- `blob: {"token": "..."} | {"key": "..."}`
|
||||
- legacy `env` for env-only CLIs
|
||||
- Return masked metadata only.
|
||||
- Emit audit events with credential type and IDs, never secret values.
|
||||
- Require admin auth and tenant scope.
|
||||
|
||||
## Architecture
|
||||
|
||||
Response list shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_credentials": [
|
||||
{
|
||||
"id": "...",
|
||||
"binary_id": "...",
|
||||
"agent_id": "...",
|
||||
"agent_key": "builder",
|
||||
"has_secret": true,
|
||||
"credential_type": "pat",
|
||||
"host_scope": "github.com",
|
||||
"created_at": "...",
|
||||
"updated_at": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Detail response:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_id": "...",
|
||||
"credential_type": "pat",
|
||||
"host_scope": "github.com",
|
||||
"has_secret": true
|
||||
}
|
||||
```
|
||||
|
||||
Legacy env credentials may include sanitized `env` entries. Typed credentials must not return `blob`, `token`, or `key`.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Add `internal/http/secure_cli_agent_credentials.go`.
|
||||
- Extend `internal/http/secure_cli_typed_credentials.go` if shared helpers need neutral names.
|
||||
- Update `internal/http/secure_cli.go` route registration.
|
||||
- Add tests near `internal/http/secure_cli_typed_credentials_test.go`.
|
||||
- Update API docs in `docs/18-http-api.md` and `docs/20-api-keys-auth.md`.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Refactor `typedCredentialBody`, `prepareTypedCredentialEnv`, and `writeTypedCredentialError` only if needed to support both user and agent handlers.
|
||||
2. Add handler methods for list/get/put/delete agent credentials.
|
||||
3. Validate `binaryID` and `agentID` path params with `uuid.Parse`.
|
||||
4. Verify binary and agent exist through store methods before writing.
|
||||
5. For PUT:
|
||||
- typed branch: validate blob and call `SetAgentCredentialsTyped`
|
||||
- env branch: merge env object and call `SetAgentCredentials`
|
||||
6. For GET/list:
|
||||
- return metadata and `has_secret`
|
||||
- suppress `env` for typed credentials
|
||||
7. Emit audit events:
|
||||
- `secure_cli.agent_credentials.updated`
|
||||
- `secure_cli.agent_credentials.deleted`
|
||||
8. Invalidate SecureCLI cache after update/delete.
|
||||
9. Run HTTP tests.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] Agent credential routes registered.
|
||||
- [ ] Handler tests cover list/get/put/delete.
|
||||
- [ ] Typed validation shared with user credential API.
|
||||
- [ ] Responses mask typed secrets.
|
||||
- [ ] API docs updated with endpoint table and body examples.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] API can fully manage agent-scoped PAT and SSH credentials.
|
||||
- [ ] API can edit legacy env credentials for non-git binaries.
|
||||
- [ ] Invalid agent/binary returns a clear 404 or 400.
|
||||
- [ ] Non-admin cannot manage credentials.
|
||||
- [ ] No API response leaks raw credential material.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- Risk: new credential endpoint may be mistaken for grant endpoint. Mitigation: docs and UI state credential does not grant access.
|
||||
- Risk: duplicate validation forks. Mitigation: reuse the existing typed credential validator.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Use `http.MaxBytesReader` or existing JSON binding limits.
|
||||
- Do not log raw request body.
|
||||
- Audit should include `credential_type` and resource IDs only.
|
||||
- All writes must be tenant-scoped.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Phase 4 consumes these endpoints from the Web UI.
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
---
|
||||
phase: 4
|
||||
title: Web UI credential management
|
||||
status: completed
|
||||
effort: ''
|
||||
---
|
||||
|
||||
# Phase 4: Web UI credential management
|
||||
|
||||
## Context Links
|
||||
|
||||
- CLI credentials table action: `ui/web/src/pages/cli-credentials/cli-credentials-table.tsx:70`
|
||||
- Current user credential dialog: `ui/web/src/pages/cli-credentials/cli-user-credentials-dialog.tsx:70`
|
||||
- Current git typed fields: `ui/web/src/pages/cli-credentials/cli-credential-git-fields.tsx`
|
||||
- Current hooks: `ui/web/src/pages/cli-credentials/hooks/use-cli-credentials.ts`
|
||||
- Current i18n namespace: `ui/web/src/i18n/locales/en/cli-credentials.json`
|
||||
|
||||
## Overview
|
||||
|
||||
Make agent credentials the primary UI path for git PAT and SSH setup. Keep user credentials available but visually demote them to advanced personal overrides.
|
||||
|
||||
Priority: P1.
|
||||
|
||||
Status: pending.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- Current UI exposes git typed fields inside `User Credentials`, which requires operators to know a stable `user_id`.
|
||||
- Issue #117 asks for obvious `GH_PAT` or SSH fields. The primary action should therefore be per-agent credential setup from the git template row.
|
||||
- Agent access becomes the practical permission boundary. UI must say this plainly without leaking secrets.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Add Agent Credentials action/button in the CLI credentials table.
|
||||
- For git adapter rows, default the credential form to PAT with `host_scope = github.com` placeholder.
|
||||
- Support SSH key as second option.
|
||||
- Show effective credential source where useful: user override, context, agent, binary.
|
||||
- Move User Credentials into advanced/personal override wording.
|
||||
- Keep mobile-safe dialog behavior.
|
||||
- Add en/vi/zh i18n keys.
|
||||
|
||||
## Architecture
|
||||
|
||||
Preferred UI structure:
|
||||
|
||||
- Main table actions:
|
||||
- Grants
|
||||
- Agent Credentials
|
||||
- Advanced: User Credentials
|
||||
- Edit
|
||||
- Delete
|
||||
- New dialog:
|
||||
- agent picker
|
||||
- credential type selector
|
||||
- host scope input
|
||||
- PAT token field or SSH private key textarea
|
||||
- masked secret state on edit
|
||||
- help text explaining agent access implies credential use
|
||||
- Use existing `CliCredentialGitFields` by extracting labels/state shape into reusable props if needed.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Add `ui/web/src/pages/cli-credentials/cli-agent-credentials-dialog.tsx`.
|
||||
- Reuse or refactor `cli-credential-git-fields.tsx`.
|
||||
- Extend `ui/web/src/pages/cli-credentials/hooks/use-cli-credentials.ts`.
|
||||
- Update `cli-credentials-table.tsx` and panel state.
|
||||
- Update all locale files under `ui/web/src/i18n/locales/{en,vi,zh}/cli-credentials.json`.
|
||||
- Add/update tests in `ui/web/src/pages/cli-credentials/__tests__/`.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add API hook methods:
|
||||
- `listAgentCredentials(binaryId)`
|
||||
- `getAgentCredential(binaryId, agentId)`
|
||||
- `setAgentCredential(binaryId, agentId, payload)`
|
||||
- `deleteAgentCredential(binaryId, agentId)`
|
||||
2. Add `CliAgentCredentialsDialog`.
|
||||
3. Reuse typed git fields and env vars section. Avoid copy-pasting validation logic unless component boundaries require it.
|
||||
4. Update table/panel to open Agent Credentials as the main credential action.
|
||||
5. Rename current User Credentials copy to "Advanced user overrides" or equivalent in all locale files.
|
||||
6. Add a warning/info line: users with access to this agent can cause it to use this credential.
|
||||
7. Add UI tests:
|
||||
- git row shows Agent Credentials action
|
||||
- PAT form posts to `/agent-credentials/{agentId}`
|
||||
- edit state shows masked secret and does not submit empty replacement
|
||||
- User Credentials remains reachable as advanced override
|
||||
8. Run `pnpm` tests/build for `ui/web`.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] Agent credential dialog implemented.
|
||||
- [ ] Hooks added for all new endpoints.
|
||||
- [ ] Table/panel actions updated.
|
||||
- [ ] i18n updated in en/vi/zh.
|
||||
- [ ] UI tests pass.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Operator can configure `GH_PAT` for `github.com` without typing a channel user ID.
|
||||
- [ ] Operator can configure SSH private key for a host-scoped git remote.
|
||||
- [ ] User Credentials path remains available but no longer looks like the default git setup.
|
||||
- [ ] UI makes the agent-access security boundary visible.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- Risk: table action area becomes crowded. Mitigation: use icon buttons/tooltips or a compact menu if needed.
|
||||
- Risk: duplicated form state between user and agent dialogs. Mitigation: extract only the shared typed git fields, not the whole dialog.
|
||||
- Risk: mobile overflow in credential dialog. Mitigation: keep `max-h` scroll region and mobile-safe input sizes.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Never render raw stored token/key after save.
|
||||
- Clear plaintext form state on close/unmount.
|
||||
- Do not store plaintext in Zustand or route state.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Phase 5 validates runtime behavior against the new source model.
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
---
|
||||
phase: 5
|
||||
title: Runtime git adapter validation
|
||||
status: completed
|
||||
effort: ''
|
||||
---
|
||||
|
||||
# Phase 5: Runtime git adapter validation
|
||||
|
||||
## Context Links
|
||||
|
||||
- Adapter prepare call: `internal/tools/credentialed_exec.go:466`
|
||||
- Synthetic user credential helper: `internal/tools/credentialed_exec.go:511`
|
||||
- Git adapter: `internal/tools/credential_adapter_git.go`
|
||||
- Git adapter tests: `internal/tools/credential_adapter_git_test.go`
|
||||
- SSH adapter tests: `internal/tools/credential_adapter_git_ssh_test.go`
|
||||
- Current docs mention User Credentials: `docs/git-credential-adapter.md:29`
|
||||
|
||||
## Overview
|
||||
|
||||
Wire the effective credential source into runtime execution and validate the git adapter still injects PAT/SSH credentials only for remote git operations.
|
||||
|
||||
Priority: P1.
|
||||
|
||||
Status: pending.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- `credentialed_exec.go` currently synthesizes a `SecureCLIUserCredential` from `UserEnv`, `UserCredentialType`, and `UserHostScope`.
|
||||
- After Phase 2, the adapter should receive a source-neutral credential object, or the helper should be renamed so non-user sources are not misrepresented.
|
||||
- Git operations must be tested with same-agent, different-channel contexts.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Keep `git status`, `git log`, and other local-only commands uncredentialed.
|
||||
- Continue denying sandbox mode for non-passthrough adapters unless sandbox support is explicitly added later.
|
||||
- Add audit source metadata so operators can tell whether `user`, `context`, or `agent` credential was used.
|
||||
- Validate PAT header behavior against a GitHub-like HTTP endpoint or fixture.
|
||||
- Validate SSH key injection still scrubs temp paths and key bytes.
|
||||
|
||||
## Architecture
|
||||
|
||||
Runtime should deal with a neutral credential payload:
|
||||
|
||||
```go
|
||||
type SecureCLIEffectiveCredential struct {
|
||||
BinaryID uuid.UUID
|
||||
SubjectID string
|
||||
Source string // user, context, agent
|
||||
EncryptedEnv []byte
|
||||
CredentialType *string
|
||||
HostScope *string
|
||||
}
|
||||
```
|
||||
|
||||
If implementation keeps `SecureCLIUserCredential` as the adapter input for minimal change, add comments/tests that prove `UserID` is metadata-only and do not expose it as the credential source.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Update `internal/tools/credential_adapter.go` if a neutral type is introduced.
|
||||
- Update `internal/tools/credentialed_exec.go`.
|
||||
- Update `internal/tools/credential_audit_log_test.go`.
|
||||
- Update `internal/tools/shell_credentialed_gate_test.go` fake store.
|
||||
- Update git adapter tests.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Decide minimal runtime shape:
|
||||
- preferred: introduce neutral `SecureCLIEffectiveCredential`
|
||||
- fallback: keep `SecureCLIUserCredential` but add source metadata elsewhere
|
||||
2. Update `userCredFromBinary` or replace it with `effectiveCredentialFromBinary`.
|
||||
3. Ensure adapters receive credential data from user/context/agent source.
|
||||
4. Add audit source to `emitSystemEnvInjectionAudit`.
|
||||
5. Add tests:
|
||||
- no `userID`, agent credential present, git clone injects PAT
|
||||
- two different `CredentialUserID` values use same agent credential
|
||||
- user credential overrides agent credential
|
||||
- context credential overrides agent credential
|
||||
- agent credential does not bypass grant for non-global binary
|
||||
- PAT and SSH paths scrub secrets and temp paths
|
||||
6. Validate PAT transport:
|
||||
- create a local HTTP test server that captures git extra header behavior, or unit-test the generated Git config/env args
|
||||
- reconcile docs and code on Basic vs Bearer if mismatch is found
|
||||
7. Run targeted Go tests for tools/store/http.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] Runtime uses effective credential from agent source.
|
||||
- [ ] Audit includes credential source without raw host or secret value.
|
||||
- [ ] Cross-channel runtime tests pass.
|
||||
- [ ] Git PAT and SSH adapter tests pass.
|
||||
- [ ] Grant boundary tests pass.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Git clone/fetch/pull/push can use an agent credential without a matching user credential.
|
||||
- [ ] Same agent uses the same credential from Discord, Telegram, HTTP, and cron contexts.
|
||||
- [ ] Per-user overrides remain backward compatible.
|
||||
- [ ] Local git operations remain uncredentialed.
|
||||
- [ ] Secrets remain scrubbed from output, logs, and errors.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- Risk: adapter API churn touches many tests. Mitigation: start with a small neutral adapter type and update fake stores in one pass.
|
||||
- Risk: PAT auth behavior is wrong for GitHub. Mitigation: add characterization test and update docs/code together.
|
||||
- Risk: audit source reveals too much host info. Mitigation: keep host hashed or omit host value, matching current audit style.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Host scope validation remains exact host or host:port, no wildcards.
|
||||
- Deny patterns from binary/grant/context still apply after credential resolution.
|
||||
- Temporary SSH files must be removed and scrubbed from errors.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Phase 6 updates docs and performs final plan/implementation validation.
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
---
|
||||
phase: 6
|
||||
title: Docs validation and handoff
|
||||
status: completed
|
||||
effort: ''
|
||||
---
|
||||
|
||||
# Phase 6: Docs validation and handoff
|
||||
|
||||
## Context Links
|
||||
|
||||
- Git guide: `docs/git-credential-adapter.md`
|
||||
- HTTP API docs: `docs/18-http-api.md`
|
||||
- API auth docs: `docs/20-api-keys-auth.md`
|
||||
- Security docs: `docs/09-security.md`
|
||||
- Store model docs: `docs/06-store-data-model.md`
|
||||
- Project changelog: `docs/project-changelog.md`
|
||||
|
||||
## Overview
|
||||
|
||||
Update user-facing and developer docs, run validation, and leave a clean handoff for implementation review.
|
||||
|
||||
Priority: P1.
|
||||
|
||||
Status: pending.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- The current git guide says to open User Credentials. That will become the advanced path.
|
||||
- API docs must include the new endpoints because user specifically asked for endpoint support.
|
||||
- Docs must state the trust model: agent access implies ability to cause that agent to use its configured git credential.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Update docs for agent credential default.
|
||||
- Keep user credential override documented.
|
||||
- Add HTTP API endpoint table and examples.
|
||||
- Update security/data-model docs.
|
||||
- Update changelog.
|
||||
- Run code, test, and build validation appropriate to touched files.
|
||||
|
||||
## Architecture
|
||||
|
||||
Documentation model:
|
||||
|
||||
- Quick start: create git CLI credential, grant/use agent, add agent credential.
|
||||
- PAT path: fine-grained PAT preferred where possible, `host_scope = github.com`.
|
||||
- SSH path: unencrypted private key only, public key added to git host by operator.
|
||||
- Advanced path: per-user credential overrides for personal credentials.
|
||||
- Security model: any principal with access to run the agent can trigger the credential.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- `docs/git-credential-adapter.md`
|
||||
- `docs/18-http-api.md`
|
||||
- `docs/20-api-keys-auth.md`
|
||||
- `docs/09-security.md`
|
||||
- `docs/06-store-data-model.md`
|
||||
- `docs/project-changelog.md`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Rewrite git guide "Adding a credential" around Agent Credentials first.
|
||||
2. Add "Advanced user overrides" section.
|
||||
3. Add endpoint documentation for all agent credential routes.
|
||||
4. Add request/response examples for PAT and SSH.
|
||||
5. Update security docs with trust boundary and secret masking.
|
||||
6. Update data model docs with `secure_cli_agent_credentials`.
|
||||
7. Update changelog with issue #117 entry.
|
||||
8. Run validation:
|
||||
- `go test ./internal/store/... ./internal/http/... ./internal/tools/...`
|
||||
- `go build ./...`
|
||||
- `go build -tags sqliteonly ./...`
|
||||
- `cd ui/web && pnpm test -- --run`
|
||||
- `cd ui/web && pnpm build`
|
||||
9. If full integration tests are skipped due local database requirements, state that explicitly in final handoff.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] Git guide updated.
|
||||
- [ ] HTTP/API auth docs updated.
|
||||
- [ ] Security and data-model docs updated.
|
||||
- [ ] Changelog updated.
|
||||
- [ ] Validation commands recorded with pass/fail status.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] A new operator can find where to enter `GH_PAT` or SSH key without knowing channel user IDs.
|
||||
- [ ] API consumers can manage agent credentials without reading code.
|
||||
- [ ] Security docs describe agent access as the permission boundary.
|
||||
- [ ] Build/test output supports merge readiness.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- Risk: docs overpromise support for GitHub App or passphrase SSH. Mitigation: keep out-of-scope section explicit.
|
||||
- Risk: endpoint examples drift from implementation. Mitigation: generate examples from handler tests where practical.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Do not include real tokens, keys, or screenshots with secrets.
|
||||
- Use placeholder values only.
|
||||
- State least-privilege recommendation: fine-grained PAT or deploy key per host/repo where possible.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- After implementation and validation, open PR referencing issue #117 and this plan.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: Agent-scoped Git credentials
|
||||
description: >-
|
||||
TDD plan for moving git typed credentials from channel/user-id keyed defaults
|
||||
to agent-scoped credentials, with HTTP API and Web UI management.
|
||||
status: completed
|
||||
priority: P1
|
||||
issue: 117
|
||||
branch: codex/issue-117-agent-scoped-git-credentials-plan
|
||||
tags: []
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
created: '2026-05-31T08:45:33.071Z'
|
||||
createdBy: 'ck:plan'
|
||||
source: skill
|
||||
---
|
||||
|
||||
# Agent-scoped Git credentials
|
||||
|
||||
## Overview
|
||||
|
||||
Issue #117 started as a UI gap: the git template does not make it obvious where to enter `GH_PAT` or SSH key material. The deeper design problem is that current `User Credentials` are keyed by credential user ID, while the same human can appear as different external IDs across Discord, Telegram, HTTP, or group contexts.
|
||||
|
||||
Decision: make agent-scoped git credentials the primary model. Granting access to an agent becomes the security boundary for whether a user can cause that agent to use a git PAT or SSH key. Keep per-user credentials as an advanced override for backward compatibility and truly personal credentials.
|
||||
|
||||
TDD target:
|
||||
|
||||
- Add contract tests first for effective credential precedence and API behavior.
|
||||
- Add a dedicated agent credential storage surface instead of mixing typed secrets into agent grant policy rows.
|
||||
- Add HTTP endpoints for create, edit, list, detail, and delete of agent-scoped CLI credentials.
|
||||
- Update Web UI so git PAT and SSH setup is managed from Agent Credentials by default.
|
||||
- Validate runtime injection across Discord/Telegram/userless contexts uses the same agent credential.
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Name | Status |
|
||||
|-------|------|--------|
|
||||
| 1 | [Research and contract tests](./phase-01-research-and-contract-tests.md) | Completed |
|
||||
| 2 | [Schema and store resolver](./phase-02-schema-and-store-resolver.md) | Completed |
|
||||
| 3 | [HTTP API credential management](./phase-03-http-api-credential-management.md) | Completed |
|
||||
| 4 | [Web UI credential management](./phase-04-web-ui-credential-management.md) | Completed |
|
||||
| 5 | [Runtime git adapter validation](./phase-05-runtime-git-adapter-validation.md) | Completed |
|
||||
| 6 | [Docs validation and handoff](./phase-06-docs-validation-and-handoff.md) | Completed |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Current typed git adapter and validation: `internal/tools/credential_adapter_git.go`, `internal/http/secure_cli_typed_credentials.go`.
|
||||
- Current lookup joins per-user credentials in `internal/store/pg/secure_cli.go` and SQLite equivalent.
|
||||
- Current UI git form lives under `ui/web/src/pages/cli-credentials/cli-user-credentials-dialog.tsx`.
|
||||
- Migration number must be re-verified at implementation time. As of plan creation, latest PostgreSQL migration is `000076_channel_memory_extraction`.
|
||||
|
||||
## Verified Facts
|
||||
|
||||
- `LookupByBinary` takes `(binaryName, agentID, userID)` and only joins `secure_cli_user_credentials` when `userID` is non-empty.
|
||||
- Context credentials can currently override/fill credential fields via `applyContextSecureCLI`, but there is no agent credential typed secret row.
|
||||
- `SecureCLIAgentGrant` already has `encrypted_env`, but lacks `credential_type` and `host_scope`; using it for typed git secrets would mix policy and secret identity.
|
||||
- HTTP routes currently expose `/v1/cli-credentials/{id}/user-credentials...` but not agent credential endpoints.
|
||||
- The Web UI currently opens User Credentials from the CLI credentials table action.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- GitHub App installation tokens.
|
||||
- OAuth/device-code token minting.
|
||||
- Wildcard host scopes.
|
||||
- Passphrase-protected SSH keys.
|
||||
- Sandbox-mode git credential injection.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Redteam Report: Agent-scoped Git Credentials
|
||||
|
||||
Date: 2026-05-31
|
||||
|
||||
Scope: plan review for issue #117 before implementation.
|
||||
|
||||
## Findings
|
||||
|
||||
### R1 - Credential rows could accidentally bypass grants
|
||||
|
||||
Risk: If runtime joins `secure_cli_agent_credentials` without preserving the existing non-global grant gate, creating a credential row becomes an implicit grant.
|
||||
|
||||
Fix in plan: Phase 2 and Phase 5 require tests that non-global binaries still need `secure_cli_agent_grants`. Credential rows store secrets only.
|
||||
|
||||
Status: fixed in plan.
|
||||
|
||||
### R2 - Agent grants are the wrong place for typed secrets
|
||||
|
||||
Risk: `secure_cli_agent_grants.encrypted_env` already exists and is tempting to reuse, but it is policy override state and lacks `credential_type` and `host_scope`.
|
||||
|
||||
Fix in plan: Phase 2 uses dedicated `secure_cli_agent_credentials`.
|
||||
|
||||
Status: fixed in plan.
|
||||
|
||||
### R3 - User credential precedence could preserve the confusing default
|
||||
|
||||
Risk: Keeping user credentials as the visible default would not solve cross-channel identity confusion.
|
||||
|
||||
Fix in plan: Phase 4 makes Agent Credentials the primary git path and moves User Credentials to advanced overrides.
|
||||
|
||||
Status: fixed in plan.
|
||||
|
||||
### R4 - API support could lag behind UI
|
||||
|
||||
Risk: A UI-only feature would block automation and contradict the user requirement.
|
||||
|
||||
Fix in plan: Phase 3 defines full CRUD endpoints, request bodies, response masking, audit events, docs, and tests.
|
||||
|
||||
Status: fixed in plan.
|
||||
|
||||
### R5 - Typed validation might fork and drift
|
||||
|
||||
Risk: Copying PAT/SSH validation into a new handler can create inconsistent behavior between user and agent credentials.
|
||||
|
||||
Fix in plan: Phase 3 requires reusing `prepareTypedCredentialEnv` or a shared equivalent.
|
||||
|
||||
Status: fixed in plan.
|
||||
|
||||
### R6 - SQLite migration can be missed
|
||||
|
||||
Risk: Desktop edition uses SQLite and can break if only PostgreSQL migrations are added.
|
||||
|
||||
Fix in plan: Phase 2 requires PG migration, SQLite fresh schema, SQLite incremental migration, and version bump.
|
||||
|
||||
Status: fixed in plan.
|
||||
|
||||
### R7 - Runtime audit source can become misleading
|
||||
|
||||
Risk: Existing audit uses credential user ID. Agent credentials would make that label inaccurate.
|
||||
|
||||
Fix in plan: Phase 5 requires source-neutral credential metadata and audit source coverage.
|
||||
|
||||
Status: fixed in plan.
|
||||
|
||||
### R8 - PAT transport behavior needs proof
|
||||
|
||||
Risk: Docs and adapter assumptions around GitHub HTTPS auth can silently diverge.
|
||||
|
||||
Fix in plan: Phase 5 requires a GitHub-like PAT transport characterization test and code/docs reconciliation.
|
||||
|
||||
Status: fixed in plan.
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Validation Report: Agent-scoped Git Credentials Plan
|
||||
|
||||
Date: 2026-05-31
|
||||
|
||||
Scope: validate plan completeness and consistency against current code.
|
||||
|
||||
## Checks
|
||||
|
||||
### V1 - Current failure mode is represented
|
||||
|
||||
Evidence: current resolver can return channel-specific user IDs, and `LookupByBinary` only joins user credentials when `userID` is non-empty.
|
||||
|
||||
Plan coverage: Phase 1 and Phase 5 include cross-channel/no-user tests.
|
||||
|
||||
Status: pass.
|
||||
|
||||
### V2 - API endpoints are included
|
||||
|
||||
Evidence: current routes include user credential endpoints only.
|
||||
|
||||
Plan coverage: Phase 3 defines list/get/put/delete agent credential endpoints and docs updates.
|
||||
|
||||
Status: pass.
|
||||
|
||||
### V3 - Store model separates policy from secret identity
|
||||
|
||||
Evidence: `SecureCLIAgentGrant` has policy fields plus encrypted env override but no typed credential metadata.
|
||||
|
||||
Plan coverage: Phase 2 adds dedicated `secure_cli_agent_credentials`.
|
||||
|
||||
Status: pass.
|
||||
|
||||
### V4 - Dual database migration is covered
|
||||
|
||||
Evidence: repo has separate PostgreSQL migrations and SQLite schema/version migrations.
|
||||
|
||||
Plan coverage: Phase 2 explicitly updates both systems.
|
||||
|
||||
Status: pass.
|
||||
|
||||
### V5 - UI default path matches product decision
|
||||
|
||||
Evidence: current table opens User Credentials, and current git guide documents User Credentials.
|
||||
|
||||
Plan coverage: Phase 4 and Phase 6 make Agent Credentials the default and keep User Credentials as advanced override.
|
||||
|
||||
Status: pass.
|
||||
|
||||
### V6 - TDD gates are explicit
|
||||
|
||||
Evidence: implementation phases depend on failing tests from Phase 1.
|
||||
|
||||
Plan coverage: every phase lists tests or validation commands.
|
||||
|
||||
Status: pass.
|
||||
|
||||
### V7 - Security boundary is explicit
|
||||
|
||||
Evidence: agent access is the proposed operational permission boundary.
|
||||
|
||||
Plan coverage: Phase 3, Phase 4, and Phase 6 all require warnings/docs/tests that credential does not grant binary access but agent users can trigger credential use.
|
||||
|
||||
Status: pass.
|
||||
|
||||
## Fixes Applied During Validation
|
||||
|
||||
- Added explicit non-global grant boundary tests.
|
||||
- Added exact HTTP endpoint list and response shapes.
|
||||
- Added SQLite migration requirement.
|
||||
- Added runtime audit source requirement.
|
||||
- Added PAT transport characterization requirement.
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
None.
|
||||
@@ -70,9 +70,27 @@
|
||||
"deleted": "CLI credential deleted",
|
||||
"deleteFailed": "Failed to delete credential"
|
||||
},
|
||||
"agentCredentials": {
|
||||
"title": "Agent Credentials",
|
||||
"description": "Configure PAT, SSH key, or env credentials that {{name}} can use when a selected agent runs it.",
|
||||
"securityHint": "Anyone with access to the selected agent can cause that agent to use this credential.",
|
||||
"agent": "Agent",
|
||||
"env": "Environment Variables",
|
||||
"add": "Add Agent Credential",
|
||||
"save": "Save",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"back": "Back",
|
||||
"empty": "No agent credentials configured",
|
||||
"saved": "Agent credentials saved",
|
||||
"saveFailed": "Failed to save agent credentials",
|
||||
"deleted": "Agent credentials deleted",
|
||||
"deleteFailed": "Failed to delete agent credentials",
|
||||
"envRequired": "At least one environment variable is required"
|
||||
},
|
||||
"userCredentials": {
|
||||
"title": "User Credentials",
|
||||
"description": "Per-user environment variable overrides for {{name}}",
|
||||
"title": "Advanced User Overrides",
|
||||
"description": "Optional per-user overrides for {{name}}. Use Agent Credentials for normal git PAT or SSH setup.",
|
||||
"userId": "User ID",
|
||||
"userIdPlaceholder": "user-id or email",
|
||||
"env": "Environment Variables",
|
||||
@@ -89,7 +107,7 @@
|
||||
"deleted": "User credentials deleted",
|
||||
"deleteFailed": "Failed to delete user credentials",
|
||||
"envRequired": "At least one environment variable is required",
|
||||
"mergeHint": "Chat users (Telegram, Discord, etc.) must be merged into a tenant user first via Contacts page before they can have per-user credentials.",
|
||||
"mergeHint": "Use only for personal overrides. Chat users (Telegram, Discord, etc.) must be merged into a tenant user first via Contacts page.",
|
||||
"credentialType": "Credential Type",
|
||||
"credentialTypeEnv": "Environment Variables (legacy)",
|
||||
"credentialTypePAT": "Personal Access Token (PAT)",
|
||||
|
||||
@@ -70,9 +70,27 @@
|
||||
"deleted": "Đã xóa thông tin CLI",
|
||||
"deleteFailed": "Không thể xóa thông tin"
|
||||
},
|
||||
"agentCredentials": {
|
||||
"title": "Credential theo agent",
|
||||
"description": "Cấu hình PAT, khóa SSH hoặc biến môi trường để {{name}} dùng khi agent được chọn chạy lệnh.",
|
||||
"securityHint": "Bất kỳ ai có quyền dùng agent được chọn đều có thể khiến agent đó dùng credential này.",
|
||||
"agent": "Agent",
|
||||
"env": "Biến môi trường",
|
||||
"add": "Thêm credential agent",
|
||||
"save": "Lưu",
|
||||
"edit": "Sửa",
|
||||
"delete": "Xóa",
|
||||
"back": "Quay lại",
|
||||
"empty": "Chưa có credential theo agent",
|
||||
"saved": "Đã lưu credential agent",
|
||||
"saveFailed": "Không thể lưu credential agent",
|
||||
"deleted": "Đã xóa credential agent",
|
||||
"deleteFailed": "Không thể xóa credential agent",
|
||||
"envRequired": "Cần ít nhất một biến môi trường"
|
||||
},
|
||||
"userCredentials": {
|
||||
"title": "Thông tin người dùng",
|
||||
"description": "Ghi đè biến môi trường cho từng người dùng của {{name}}",
|
||||
"title": "Ghi đè người dùng nâng cao",
|
||||
"description": "Ghi đè tùy chọn theo người dùng cho {{name}}. Dùng Credential theo agent cho thiết lập git PAT hoặc SSH thông thường.",
|
||||
"userId": "ID người dùng",
|
||||
"userIdPlaceholder": "user-id hoặc email",
|
||||
"env": "Biến môi trường",
|
||||
@@ -89,7 +107,7 @@
|
||||
"deleted": "Đã xóa thông tin người dùng",
|
||||
"deleteFailed": "Không thể xóa thông tin người dùng",
|
||||
"envRequired": "Cần ít nhất một biến môi trường",
|
||||
"mergeHint": "Người dùng chat (Telegram, Discord...) cần được gộp vào tenant user qua trang Contacts trước khi có thể thiết lập credentials riêng.",
|
||||
"mergeHint": "Chỉ dùng cho ghi đè cá nhân. Người dùng chat (Telegram, Discord...) cần được gộp vào tenant user qua trang Contacts trước.",
|
||||
"credentialType": "Loại credential",
|
||||
"credentialTypeEnv": "Biến môi trường (cũ)",
|
||||
"credentialTypePAT": "Personal Access Token (PAT)",
|
||||
|
||||
@@ -70,9 +70,27 @@
|
||||
"deleted": "CLI 凭证已删除",
|
||||
"deleteFailed": "删除凭证失败"
|
||||
},
|
||||
"agentCredentials": {
|
||||
"title": "代理凭证",
|
||||
"description": "配置所选代理运行 {{name}} 时使用的 PAT、SSH 密钥或环境变量凭证。",
|
||||
"securityHint": "任何可访问所选代理的用户都可以让该代理使用此凭证。",
|
||||
"agent": "代理",
|
||||
"env": "环境变量",
|
||||
"add": "添加代理凭证",
|
||||
"save": "保存",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"back": "返回",
|
||||
"empty": "未配置代理凭证",
|
||||
"saved": "代理凭证已保存",
|
||||
"saveFailed": "保存代理凭证失败",
|
||||
"deleted": "代理凭证已删除",
|
||||
"deleteFailed": "删除代理凭证失败",
|
||||
"envRequired": "至少需要一个环境变量"
|
||||
},
|
||||
"userCredentials": {
|
||||
"title": "用户凭证",
|
||||
"description": "{{name}} 的每用户环境变量覆盖",
|
||||
"title": "高级用户覆盖",
|
||||
"description": "{{name}} 的可选每用户覆盖。常规 git PAT 或 SSH 设置请使用代理凭证。",
|
||||
"userId": "用户 ID",
|
||||
"userIdPlaceholder": "用户 ID 或邮箱",
|
||||
"env": "环境变量",
|
||||
@@ -89,7 +107,7 @@
|
||||
"deleted": "用户凭证已删除",
|
||||
"deleteFailed": "删除用户凭证失败",
|
||||
"envRequired": "至少需要一个环境变量",
|
||||
"mergeHint": "聊天用户(Telegram、Discord 等)需要先通过联系人页面合并为租户用户,才能设置独立凭证。",
|
||||
"mergeHint": "仅用于个人覆盖。聊天用户(Telegram、Discord 等)需要先通过联系人页面合并为租户用户。",
|
||||
"credentialType": "凭证类型",
|
||||
"credentialTypeEnv": "环境变量(旧)",
|
||||
"credentialTypePAT": "个人访问令牌 (PAT)",
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAgentCredentialPayload } from "../cli-agent-credentials-dialog-helpers";
|
||||
|
||||
describe("agent credential payload builder", () => {
|
||||
it("builds a PAT payload for git agent credentials", () => {
|
||||
const result = buildAgentCredentialPayload({
|
||||
isGit: true,
|
||||
type: "pat",
|
||||
hostScope: " github.com ",
|
||||
token: "ghp_test",
|
||||
privateKey: "",
|
||||
hasExistingSecret: false,
|
||||
envEntries: [],
|
||||
isNewEntry: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "typed",
|
||||
payload: {
|
||||
credential_type: "pat",
|
||||
host_scope: "github.com",
|
||||
blob: { token: "ghp_test" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an existing typed secret when edit form has no replacement secret", () => {
|
||||
const result = buildAgentCredentialPayload({
|
||||
isGit: true,
|
||||
type: "ssh_key",
|
||||
hostScope: "git.example.com:2222",
|
||||
token: "",
|
||||
privateKey: "",
|
||||
hasExistingSecret: true,
|
||||
envEntries: [],
|
||||
isNewEntry: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kind: "no_change" });
|
||||
});
|
||||
|
||||
it("requires env vars for a new env credential", () => {
|
||||
const result = buildAgentCredentialPayload({
|
||||
isGit: false,
|
||||
type: "env",
|
||||
hostScope: "",
|
||||
token: "",
|
||||
privateKey: "",
|
||||
hasExistingSecret: false,
|
||||
envEntries: [],
|
||||
isNewEntry: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kind: "error", errorKey: "env_required" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
function source(path: string): string {
|
||||
return readFileSync(resolve(process.cwd(), path), "utf8");
|
||||
}
|
||||
|
||||
describe("CLI agent credential UI wiring", () => {
|
||||
it("exposes Agent Credentials as a distinct table action before advanced user overrides", () => {
|
||||
const table = source("src/pages/cli-credentials/cli-credentials-table.tsx");
|
||||
|
||||
expect(table).toContain("onAgentCreds");
|
||||
expect(table).toContain("agentCredentials.title");
|
||||
expect(table.indexOf("onAgentCreds(item)")).toBeLessThan(table.indexOf("onUserCreds(item)"));
|
||||
});
|
||||
|
||||
it("mounts the Agent Credentials dialog from the panel", () => {
|
||||
const panel = source("src/pages/cli-credentials/cli-credentials-panel.tsx");
|
||||
|
||||
expect(panel).toContain("cli-agent-credentials-dialog");
|
||||
expect(panel).toContain("CLIAgentCredentialsDialog");
|
||||
expect(panel).toContain("agentCredsTarget");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { normalizeCliPreset, normalizeCliPresets } from "../hooks/use-cli-credentials";
|
||||
|
||||
describe("cli credential preset normalization", () => {
|
||||
@@ -30,4 +32,15 @@ describe("cli credential preset normalization", () => {
|
||||
expect(normalizeCliPresets(null)).toEqual({});
|
||||
expect(normalizeCliPresets(undefined)).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps agent credential API calls on the agent-credentials endpoint family", () => {
|
||||
const source = readFileSync(
|
||||
resolve(process.cwd(), "src/pages/cli-credentials/hooks/use-cli-agent-credentials.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).toContain("/v1/cli-credentials/${binaryId}/agent-credentials");
|
||||
expect(source).toContain("/v1/cli-credentials/${binaryId}/agent-credentials/${agentId}");
|
||||
expect(source).toContain("useCliAgentCredentials");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { CliCredentialEnvVarsSection, type ManualEnvEntry } from "./cli-credential-env-vars-section";
|
||||
import { CliCredentialGitFields, type GitCredentialType } from "./cli-credential-git-fields";
|
||||
import type { AgentData } from "@/types/agent";
|
||||
import type { SecureCLIBinary } from "./hooks/use-cli-credentials";
|
||||
|
||||
interface Props {
|
||||
binary: SecureCLIBinary;
|
||||
agents: AgentData[];
|
||||
agentId: string;
|
||||
setAgentId: (v: string) => void;
|
||||
editing: boolean;
|
||||
envEntries: ManualEnvEntry[];
|
||||
setEnvEntries: Dispatch<SetStateAction<ManualEnvEntry[]>>;
|
||||
gitType: GitCredentialType;
|
||||
setGitType: (v: GitCredentialType) => void;
|
||||
gitHostScope: string;
|
||||
setGitHostScope: (v: string) => void;
|
||||
gitToken: string;
|
||||
setGitToken: (v: string) => void;
|
||||
gitPrivateKey: string;
|
||||
setGitPrivateKey: (v: string) => void;
|
||||
gitErrorKey?: string;
|
||||
gitHasExistingSecret: boolean;
|
||||
}
|
||||
|
||||
export function CliAgentCredentialForm(props: Props) {
|
||||
const { t } = useTranslation("cli-credentials");
|
||||
const isGit = props.binary.adapter_name === "git";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{t("agentCredentials.agent")}</Label>
|
||||
<Select value={props.agentId} onValueChange={props.setAgentId} disabled={props.editing}>
|
||||
<SelectTrigger className="text-base md:text-sm">
|
||||
<SelectValue placeholder={t("grants.selectAgent")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{props.agents.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.display_name || a.agent_key}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isGit ? (
|
||||
<CliCredentialGitFields
|
||||
type={props.gitType}
|
||||
onTypeChange={props.setGitType}
|
||||
hostScope={props.gitHostScope}
|
||||
onHostScopeChange={props.setGitHostScope}
|
||||
token={props.gitToken}
|
||||
onTokenChange={props.setGitToken}
|
||||
privateKey={props.gitPrivateKey}
|
||||
onPrivateKeyChange={props.setGitPrivateKey}
|
||||
errorKey={props.gitErrorKey}
|
||||
hasExistingSecret={props.gitHasExistingSecret}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{(!isGit || props.gitType === "env") ? (
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{t("agentCredentials.env")}</Label>
|
||||
<CliCredentialEnvVarsSection
|
||||
isManualMode
|
||||
activePreset={null}
|
||||
envValues={{}}
|
||||
setEnvValues={() => undefined}
|
||||
manualEnvEntries={props.envEntries}
|
||||
setManualEnvEntries={props.setEnvEntries}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2, Pencil, Trash2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { CLIAgentCredential } from "./hooks/use-cli-agent-credentials";
|
||||
|
||||
interface Props {
|
||||
entries: CLIAgentCredential[];
|
||||
agentNameMap: Map<string, string>;
|
||||
deleting: string | null;
|
||||
onEdit: (entry: CLIAgentCredential) => void;
|
||||
onDelete: (entry: CLIAgentCredential) => void;
|
||||
}
|
||||
|
||||
export function CliAgentCredentialList({ entries, agentNameMap, deleting, onEdit, onDelete }: Props) {
|
||||
const { t } = useTranslation("cli-credentials");
|
||||
|
||||
const credentialLabel = (entry: CLIAgentCredential) => {
|
||||
if (entry.credential_type === "pat") return t("userCredentials.credentialTypePAT");
|
||||
if (entry.credential_type === "ssh_key") return t("userCredentials.credentialTypeSSH");
|
||||
return entry.has_secret ? "env" : t("userCredentials.noSecret");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{entry.name || agentNameMap.get(entry.agent_id) || entry.agent_key || entry.agent_id}
|
||||
</span>
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">{credentialLabel(entry)}</Badge>
|
||||
</div>
|
||||
{entry.host_scope ? (
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">{entry.host_scope}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onEdit(entry)} title={t("agentCredentials.edit")}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => onDelete(entry)}
|
||||
disabled={deleting === entry.agent_id}
|
||||
title={t("agentCredentials.delete")}
|
||||
>
|
||||
{deleting === entry.agent_id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { CLIEnvEntryResponse, CLIEnvPayload } from "@/types/cli-credential";
|
||||
import type { ManualEnvEntry } from "./cli-credential-env-vars-section";
|
||||
import type { GitCredentialType } from "./cli-credential-git-fields";
|
||||
|
||||
export function entriesFromEnv(env: Record<string, CLIEnvEntryResponse> | null | undefined): ManualEnvEntry[] {
|
||||
if (!env || Object.keys(env).length === 0) return [];
|
||||
return Object.entries(env).map(([key, entry]) => ({
|
||||
key,
|
||||
value: entry.value ?? "",
|
||||
kind: entry.kind ?? "sensitive",
|
||||
}));
|
||||
}
|
||||
|
||||
export function envPayloadFromEntries(entries: ManualEnvEntry[]): CLIEnvPayload {
|
||||
const env: CLIEnvPayload = {};
|
||||
for (const entry of entries) {
|
||||
const key = entry.key.trim();
|
||||
if (key) env[key] = { kind: entry.kind, value: entry.value };
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export type AgentCredentialPayloadResult =
|
||||
| { kind: "typed"; payload: { credential_type: "pat" | "ssh_key"; host_scope: string; blob: Record<string, string> } }
|
||||
| { kind: "env"; payload: { env: CLIEnvPayload } }
|
||||
| { kind: "no_change" }
|
||||
| { kind: "error"; errorKey: string };
|
||||
|
||||
export function buildAgentCredentialPayload(input: {
|
||||
isGit: boolean;
|
||||
type: GitCredentialType;
|
||||
hostScope: string;
|
||||
token: string;
|
||||
privateKey: string;
|
||||
hasExistingSecret: boolean;
|
||||
envEntries: ManualEnvEntry[];
|
||||
isNewEntry: boolean;
|
||||
}): AgentCredentialPayloadResult {
|
||||
const { isGit, type, hostScope, token, privateKey, hasExistingSecret, envEntries, isNewEntry } = input;
|
||||
if (isGit && type !== "env") {
|
||||
const scope = hostScope.trim();
|
||||
if (!scope) return { kind: "error", errorKey: "git.cred_host_scope_required" };
|
||||
if (type === "pat") {
|
||||
if (!token) return hasExistingSecret ? { kind: "no_change" } : { kind: "error", errorKey: "git.cred_blob_missing_token" };
|
||||
return { kind: "typed", payload: { credential_type: "pat", host_scope: scope, blob: { token } } };
|
||||
}
|
||||
if (!privateKey.trim()) return hasExistingSecret ? { kind: "no_change" } : { kind: "error", errorKey: "git.cred_blob_missing_key" };
|
||||
return { kind: "typed", payload: { credential_type: "ssh_key", host_scope: scope, blob: { key: privateKey } } };
|
||||
}
|
||||
const env = envPayloadFromEntries(envEntries);
|
||||
if (isNewEntry && Object.keys(env).length === 0) return { kind: "error", errorKey: "env_required" };
|
||||
return { kind: "env", payload: { env } };
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bot, Loader2, Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useAgents } from "@/pages/agents/hooks/use-agents";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import { type ManualEnvEntry } from "./cli-credential-env-vars-section";
|
||||
import { CliAgentCredentialForm } from "./cli-agent-credential-form";
|
||||
import { CliAgentCredentialList } from "./cli-agent-credential-list";
|
||||
import {
|
||||
buildAgentCredentialPayload,
|
||||
entriesFromEnv,
|
||||
} from "./cli-agent-credentials-dialog-helpers";
|
||||
import { type GitCredentialType } from "./cli-credential-git-fields";
|
||||
import { useCliAgentCredentials, type CLIAgentCredential } from "./hooks/use-cli-agent-credentials";
|
||||
import type { SecureCLIBinary } from "./hooks/use-cli-credentials";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
binary: SecureCLIBinary;
|
||||
}
|
||||
|
||||
type ViewState = "list" | "form";
|
||||
|
||||
export function CLIAgentCredentialsDialog({ open, onOpenChange, binary }: Props) {
|
||||
const { t } = useTranslation("cli-credentials");
|
||||
const { t: tc } = useTranslation("common");
|
||||
const { agents } = useAgents();
|
||||
const { agentCredentials, loading, getCredential, setCredential, deleteCredential } = useCliAgentCredentials(binary.id);
|
||||
const isGit = binary.adapter_name === "git";
|
||||
|
||||
const [view, setView] = useState<ViewState>("list");
|
||||
const [editEntry, setEditEntry] = useState<CLIAgentCredential | null>(null);
|
||||
const [agentId, setAgentId] = useState("");
|
||||
const [envEntries, setEnvEntries] = useState<ManualEnvEntry[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [gitType, setGitType] = useState<GitCredentialType>("pat");
|
||||
const [gitHostScope, setGitHostScope] = useState("github.com");
|
||||
const [gitToken, setGitToken] = useState("");
|
||||
const [gitPrivateKey, setGitPrivateKey] = useState("");
|
||||
const [gitErrorKey, setGitErrorKey] = useState<string | undefined>();
|
||||
const [gitHasExistingSecret, setGitHasExistingSecret] = useState(false);
|
||||
|
||||
const agentNameMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const a of agents) map.set(a.id, a.display_name || a.agent_key);
|
||||
return map;
|
||||
}, [agents]);
|
||||
|
||||
const clearForm = () => {
|
||||
setEditEntry(null);
|
||||
setAgentId("");
|
||||
setEnvEntries([]);
|
||||
setGitType(isGit ? "pat" : "env");
|
||||
setGitHostScope(isGit ? "github.com" : "");
|
||||
setGitToken("");
|
||||
setGitPrivateKey("");
|
||||
setGitErrorKey(undefined);
|
||||
setGitHasExistingSecret(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
clearForm();
|
||||
return;
|
||||
}
|
||||
setView("list");
|
||||
clearForm();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, binary.id]);
|
||||
|
||||
const openAdd = () => {
|
||||
clearForm();
|
||||
setView("form");
|
||||
};
|
||||
|
||||
const openEdit = async (entry: CLIAgentCredential) => {
|
||||
setEditEntry(entry);
|
||||
setAgentId(entry.agent_id);
|
||||
setGitErrorKey(undefined);
|
||||
setView("form");
|
||||
try {
|
||||
const res = await getCredential(entry.agent_id);
|
||||
setEnvEntries(entriesFromEnv(res.env));
|
||||
if (isGit) {
|
||||
const nextType = (res.credential_type ?? "env") as GitCredentialType;
|
||||
setGitType(nextType === "pat" || nextType === "ssh_key" ? nextType : "env");
|
||||
setGitHostScope(res.host_scope ?? "github.com");
|
||||
setGitHasExistingSecret(!!res.has_secret);
|
||||
setGitToken("");
|
||||
setGitPrivateKey("");
|
||||
}
|
||||
} catch {
|
||||
setEnvEntries([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!agentId) return;
|
||||
setGitErrorKey(undefined);
|
||||
const result = buildAgentCredentialPayload({
|
||||
isGit,
|
||||
type: gitType,
|
||||
hostScope: gitHostScope,
|
||||
token: gitToken,
|
||||
privateKey: gitPrivateKey,
|
||||
hasExistingSecret: gitHasExistingSecret,
|
||||
envEntries,
|
||||
isNewEntry: editEntry === null,
|
||||
});
|
||||
if (result.kind === "error") {
|
||||
if (result.errorKey === "env_required") toast.error(t("agentCredentials.envRequired"));
|
||||
else setGitErrorKey(result.errorKey);
|
||||
return;
|
||||
}
|
||||
if (result.kind === "no_change") {
|
||||
toast.success(t("agentCredentials.saved"));
|
||||
setView("list");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await setCredential(agentId, result.payload);
|
||||
clearForm();
|
||||
setView("list");
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string })?.code;
|
||||
if (code?.startsWith("git.cred_")) setGitErrorKey(code);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (entry: CLIAgentCredential) => {
|
||||
setDeleting(entry.agent_id);
|
||||
try {
|
||||
await deleteCredential(entry.agent_id);
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] flex flex-col sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2"><Bot className="h-4 w-4" />{t("agentCredentials.title")}</DialogTitle>
|
||||
<DialogDescription>{t("agentCredentials.description", { name: binary.binary_name })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 -mx-4 px-4 sm:-mx-6 sm:px-6 overflow-y-auto min-h-0">
|
||||
{view === "list" ? (
|
||||
<>
|
||||
<p className="rounded-md border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">{t("agentCredentials.securityHint")}</p>
|
||||
{loading ? <p className="text-xs text-muted-foreground">{tc("loading")}</p> : null}
|
||||
{agentCredentials.length === 0 ? <p className="py-6 text-center text-sm text-muted-foreground">{t("agentCredentials.empty")}</p> : null}
|
||||
<CliAgentCredentialList
|
||||
entries={agentCredentials}
|
||||
agentNameMap={agentNameMap}
|
||||
deleting={deleting}
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<CliAgentCredentialForm
|
||||
binary={binary}
|
||||
agents={agents}
|
||||
agentId={agentId}
|
||||
setAgentId={setAgentId}
|
||||
editing={editEntry !== null}
|
||||
envEntries={envEntries}
|
||||
setEnvEntries={setEnvEntries}
|
||||
gitType={gitType}
|
||||
setGitType={setGitType}
|
||||
gitHostScope={gitHostScope}
|
||||
setGitHostScope={setGitHostScope}
|
||||
gitToken={gitToken}
|
||||
setGitToken={setGitToken}
|
||||
gitPrivateKey={gitPrivateKey}
|
||||
setGitPrivateKey={setGitPrivateKey}
|
||||
gitErrorKey={gitErrorKey}
|
||||
gitHasExistingSecret={gitHasExistingSecret}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
{view === "list" ? (
|
||||
<><Button variant="outline" onClick={() => onOpenChange(false)}>{tc("close")}</Button><Button onClick={openAdd} className="gap-1"><Plus className="h-3.5 w-3.5" />{t("agentCredentials.add")}</Button></>
|
||||
) : (
|
||||
<><Button variant="outline" onClick={() => setView("list")} disabled={saving}>{t("agentCredentials.back")}</Button><Button onClick={handleSave} disabled={saving || !agentId}>{saving ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : null}{t("agentCredentials.save")}</Button></>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,9 @@ const CliCredentialFormDialog = lazy(() =>
|
||||
const CLIUserCredentialsDialog = lazy(() =>
|
||||
import("./cli-user-credentials-dialog").then((m) => ({ default: m.CLIUserCredentialsDialog }))
|
||||
);
|
||||
const CLIAgentCredentialsDialog = lazy(() =>
|
||||
import("./cli-agent-credentials-dialog").then((m) => ({ default: m.CLIAgentCredentialsDialog }))
|
||||
);
|
||||
|
||||
export function CliCredentialsPanel() {
|
||||
const { t } = useTranslation("cli-credentials");
|
||||
@@ -34,6 +37,7 @@ export function CliCredentialsPanel() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<SecureCLIBinary | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [userCredsTarget, setUserCredsTarget] = useState<SecureCLIBinary | null>(null);
|
||||
const [agentCredsTarget, setAgentCredsTarget] = useState<SecureCLIBinary | null>(null);
|
||||
const [grantsTarget, setGrantsTarget] = useState<SecureCLIBinary | null>(null);
|
||||
|
||||
const { items, loading, refresh, createCredential, updateCredential, deleteCredential } =
|
||||
@@ -88,6 +92,7 @@ export function CliCredentialsPanel() {
|
||||
onEdit={openEdit}
|
||||
onDelete={setDeleteTarget}
|
||||
onUserCreds={setUserCredsTarget}
|
||||
onAgentCreds={setAgentCredsTarget}
|
||||
onGrants={setGrantsTarget}
|
||||
/>
|
||||
{/* Finding #12: surface LIMIT 20 truncation so admins know there are more entries. */}
|
||||
@@ -130,6 +135,16 @@ export function CliCredentialsPanel() {
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{agentCredsTarget && (
|
||||
<Suspense fallback={null}>
|
||||
<CLIAgentCredentialsDialog
|
||||
open={!!agentCredsTarget}
|
||||
onOpenChange={(open: boolean) => !open && setAgentCredsTarget(null)}
|
||||
binary={agentCredsTarget}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{grantsTarget && (
|
||||
<CliCredentialGrantsDialog
|
||||
open={!!grantsTarget}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Phase 8: each row has a chip sub-row from agent_grants_summary.
|
||||
*/
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { KeyRound, Pencil, Trash2, Users, Shield } from "lucide-react";
|
||||
import { Bot, KeyRound, Pencil, Trash2, Users, Shield } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CliCredentialAgentChips } from "./cli-credential-agent-chips";
|
||||
@@ -15,10 +15,11 @@ interface Props {
|
||||
onEdit: (item: SecureCLIBinary) => void;
|
||||
onDelete: (item: SecureCLIBinary) => void;
|
||||
onUserCreds: (item: SecureCLIBinary) => void;
|
||||
onAgentCreds: (item: SecureCLIBinary) => void;
|
||||
onGrants: (item: SecureCLIBinary) => void;
|
||||
}
|
||||
|
||||
export function CliCredentialsTable({ items, onEdit, onDelete, onUserCreds, onGrants }: Props) {
|
||||
export function CliCredentialsTable({ items, onEdit, onDelete, onUserCreds, onAgentCreds, onGrants }: Props) {
|
||||
const { t } = useTranslation("cli-credentials");
|
||||
const { t: tc } = useTranslation("common");
|
||||
|
||||
@@ -77,6 +78,9 @@ export function CliCredentialsTable({ items, onEdit, onDelete, onUserCreds, onGr
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
{t("grants.addGrant")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => onAgentCreds(item)} title={t("agentCredentials.title")}>
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => onUserCreds(item)} title={t("userCredentials.title")}>
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
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 i18n from "@/i18n";
|
||||
import type { CLIAgentCredential, CLIAgentCredentialInput } from "@/types/cli-credential";
|
||||
|
||||
export type { CLIAgentCredential, CLIAgentCredentialInput };
|
||||
|
||||
/** Hook for managing agent-scoped credentials on a specific CLI binary. */
|
||||
export function useCliAgentCredentials(binaryId: string) {
|
||||
const http = useHttp();
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = ["cliCredentials", binaryId, "agentCredentials"] as const;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const res = await http.get<{ agent_credentials: CLIAgentCredential[] }>(
|
||||
`/v1/cli-credentials/${binaryId}/agent-credentials`,
|
||||
);
|
||||
return res.agent_credentials ?? [];
|
||||
},
|
||||
enabled: !!binaryId,
|
||||
});
|
||||
|
||||
const invalidate = useCallback(
|
||||
() => queryClient.invalidateQueries({ queryKey }),
|
||||
[queryClient, queryKey],
|
||||
);
|
||||
|
||||
const getCredential = useCallback(
|
||||
(agentId: string) => http.get<CLIAgentCredential>(
|
||||
`/v1/cli-credentials/${binaryId}/agent-credentials/${agentId}`,
|
||||
),
|
||||
[http, binaryId],
|
||||
);
|
||||
|
||||
const setCredential = useCallback(
|
||||
async (agentId: string, input: CLIAgentCredentialInput) => {
|
||||
try {
|
||||
await http.put(`/v1/cli-credentials/${binaryId}/agent-credentials/${agentId}`, input);
|
||||
await invalidate();
|
||||
toast.success(i18n.t("cli-credentials:agentCredentials.saved"));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
i18n.t("cli-credentials:agentCredentials.saveFailed"),
|
||||
err instanceof Error ? err.message : "",
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, binaryId, invalidate],
|
||||
);
|
||||
|
||||
const deleteCredential = useCallback(
|
||||
async (agentId: string) => {
|
||||
try {
|
||||
await http.delete(`/v1/cli-credentials/${binaryId}/agent-credentials/${agentId}`);
|
||||
await invalidate();
|
||||
toast.success(i18n.t("cli-credentials:agentCredentials.deleted"));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
i18n.t("cli-credentials:agentCredentials.deleteFailed"),
|
||||
err instanceof Error ? err.message : "",
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, binaryId, invalidate],
|
||||
);
|
||||
|
||||
return {
|
||||
agentCredentials: data ?? [],
|
||||
loading: isLoading,
|
||||
getCredential,
|
||||
setCredential,
|
||||
deleteCredential,
|
||||
};
|
||||
}
|
||||
@@ -3,9 +3,21 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import i18n from "@/i18n";
|
||||
import type { SecureCLIBinary, CLICredentialInput, CLIPreset, CLIAgentGrant, CLIAgentGrantInput } from "@/types/cli-credential";
|
||||
import type {
|
||||
SecureCLIBinary,
|
||||
CLICredentialInput,
|
||||
CLIPreset,
|
||||
CLIAgentGrant,
|
||||
CLIAgentGrantInput,
|
||||
} from "@/types/cli-credential";
|
||||
|
||||
export type { SecureCLIBinary, CLICredentialInput, CLIPreset, CLIAgentGrant, CLIAgentGrantInput };
|
||||
export type {
|
||||
SecureCLIBinary,
|
||||
CLICredentialInput,
|
||||
CLIPreset,
|
||||
CLIAgentGrant,
|
||||
CLIAgentGrantInput,
|
||||
};
|
||||
|
||||
const QUERY_KEY = ["cliCredentials"] as const;
|
||||
const PRESETS_KEY = ["cliCredentials", "presets"] as const;
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface CLIEnvEntryResponse {
|
||||
}
|
||||
|
||||
export type CLIEnvPayload = Record<string, string | CLIEnvEntryInput>;
|
||||
export type CLIGitCredentialType = "env" | "pat" | "ssh_key";
|
||||
|
||||
export interface SecureCLIBinary {
|
||||
id: string;
|
||||
@@ -116,6 +117,29 @@ export interface CLIAgentGrantInput {
|
||||
env_vars?: CLIEnvPayload | null;
|
||||
}
|
||||
|
||||
export interface CLIAgentCredential {
|
||||
id: string;
|
||||
binary_id: string;
|
||||
agent_id: string;
|
||||
agent_key?: string;
|
||||
name?: string;
|
||||
has_secret: boolean;
|
||||
env_keys?: string[];
|
||||
env?: Record<string, CLIEnvEntryResponse>;
|
||||
credential_type?: CLIGitCredentialType | string | null;
|
||||
host_scope?: string | null;
|
||||
created_by?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CLIAgentCredentialInput {
|
||||
env?: CLIEnvPayload;
|
||||
credential_type?: Exclude<CLIGitCredentialType, "env">;
|
||||
host_scope?: string;
|
||||
blob?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Summary of a single grant shown in the table row chips (Phase 4 API field). */
|
||||
export interface AgentGrantSummary {
|
||||
grant_id: string;
|
||||
|
||||
Reference in New Issue
Block a user