feat(cli-credentials): add agent-scoped git credentials

This commit is contained in:
Goon committed 2026-05-31 16:49:23 +07:00
1 parent e6242dbbbc
commit 994acfe3a4
47 files changed
+2152 -113

No files matched your search

+25 -1
View File
@@ -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 = `
+30
View File
@@ -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
}
+50 -14
View File
@@ -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)
}
}