The mirror credential no longer has to ride the tool-call payload (which is persisted to transcript → claudewatcher → brain → gitea history). Adds remote_password_env: the name of a server-side env var the tool resolves at call time, so the secret stays in the server process. An env name that resolves to empty errors loudly rather than silently sending an empty password. Raw remote_password still works but the schema/description now mark it DISCOURAGED. Tests: password resolved from the env var (never in output); unset env var → ErrValidation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
141 lines
4.9 KiB
Go
141 lines
4.9 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
|
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
|
)
|
|
|
|
type RepoMirrorPush struct {
|
|
c *gitea.Client
|
|
a *allowlist.Allowlist
|
|
}
|
|
|
|
func NewRepoMirrorPush(c *gitea.Client, a *allowlist.Allowlist) *RepoMirrorPush {
|
|
return &RepoMirrorPush{c: c, a: a}
|
|
}
|
|
|
|
func (t *RepoMirrorPush) Descriptor() registry.ToolDescriptor {
|
|
return registry.ToolDescriptor{
|
|
Name: "repo_mirror_push",
|
|
Description: "Manage push mirrors for a repository: add, list, or delete. For the mirror credential, PREFER remote_password_env (the name of an env var the server reads) so the secret never rides the tool-call payload/transcript; remote_password (raw) is discouraged and will be persisted in logs.",
|
|
InputSchema: json.RawMessage(`{
|
|
"type":"object",
|
|
"properties":{
|
|
"owner":{"type":"string"},
|
|
"repo":{"type":"string"},
|
|
"action":{"type":"string","enum":["add","list","delete"]},
|
|
"remote_address":{"type":"string","description":"Mirror target URL (required for add)."},
|
|
"remote_username":{"type":"string"},
|
|
"remote_password_env":{"type":"string","description":"PREFERRED: name of a server-side env var holding the mirror credential; the server resolves it, so the secret is never in this call. Errors if the var is unset."},
|
|
"remote_password":{"type":"string","description":"DISCOURAGED: raw credential — lands in the tool-call transcript/logs. Use remote_password_env instead."},
|
|
"interval":{"type":"string","description":"Sync interval, e.g. '8h0m0s'."},
|
|
"sync_on_commit":{"type":"boolean"},
|
|
"mirror_name":{"type":"string","description":"Remote name to delete (required for delete)."}
|
|
},
|
|
"required":["owner","repo","action"]
|
|
}`),
|
|
}
|
|
}
|
|
|
|
type repoMirrorPushArgs struct {
|
|
Owner string `json:"owner"`
|
|
Repo string `json:"repo"`
|
|
Action string `json:"action"`
|
|
RemoteAddress string `json:"remote_address"`
|
|
RemoteUsername string `json:"remote_username"`
|
|
RemotePassword string `json:"remote_password"`
|
|
RemotePasswordEnv string `json:"remote_password_env"`
|
|
Interval string `json:"interval"`
|
|
SyncOnCommit bool `json:"sync_on_commit"`
|
|
MirrorName string `json:"mirror_name"`
|
|
}
|
|
|
|
// safeMirror omits remote_password so it is never returned to the caller.
|
|
type safeMirror struct {
|
|
ID int `json:"id"`
|
|
RemoteName string `json:"remote_name"`
|
|
RemoteAddress string `json:"remote_address"`
|
|
Interval string `json:"interval"`
|
|
SyncOnCommit bool `json:"sync_on_commit"`
|
|
}
|
|
|
|
func toSafeMirror(m *gitea.PushMirror) safeMirror {
|
|
return safeMirror{
|
|
ID: m.ID,
|
|
RemoteName: m.RemoteName,
|
|
RemoteAddress: m.RemoteAddress,
|
|
Interval: m.Interval,
|
|
SyncOnCommit: m.SyncOnCommit,
|
|
}
|
|
}
|
|
|
|
// resolveMirrorPassword prefers remote_password_env — the name of a server-side
|
|
// env var — so the credential never appears in the tool-call payload (#49). It
|
|
// falls back to the raw (discouraged) remote_password. An env name that resolves
|
|
// to empty is a loud error, not a silent empty password.
|
|
func resolveMirrorPassword(args repoMirrorPushArgs) (string, error) {
|
|
if args.RemotePasswordEnv != "" {
|
|
pw := os.Getenv(args.RemotePasswordEnv)
|
|
if pw == "" {
|
|
return "", fmt.Errorf("remote_password_env %q is unset or empty in the server environment: %w",
|
|
args.RemotePasswordEnv, gitea.ErrValidation)
|
|
}
|
|
return pw, nil
|
|
}
|
|
return args.RemotePassword, nil
|
|
}
|
|
|
|
func (t *RepoMirrorPush) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
|
var args repoMirrorPushArgs
|
|
if err := parseArgs(raw, &args); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := t.a.Check(args.Owner); err != nil {
|
|
return nil, err
|
|
}
|
|
switch args.Action {
|
|
case "add":
|
|
password, err := resolveMirrorPassword(args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m, err := t.c.AddPushMirror(ctx, args.Owner, args.Repo, gitea.AddPushMirrorArgs{
|
|
RemoteAddress: args.RemoteAddress,
|
|
RemoteUsername: args.RemoteUsername,
|
|
RemotePassword: password,
|
|
Interval: args.Interval,
|
|
SyncOnCommit: args.SyncOnCommit,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return textOK(toSafeMirror(m))
|
|
case "list":
|
|
mirrors, err := t.c.ListPushMirrors(ctx, args.Owner, args.Repo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
safe := make([]safeMirror, len(mirrors))
|
|
for i := range mirrors {
|
|
safe[i] = toSafeMirror(&mirrors[i])
|
|
}
|
|
return textOK(safe)
|
|
case "delete":
|
|
if args.MirrorName == "" {
|
|
return nil, fmt.Errorf("mirror_name is required for action=delete")
|
|
}
|
|
if err := t.c.DeletePushMirror(ctx, args.Owner, args.Repo, args.MirrorName); err != nil {
|
|
return nil, err
|
|
}
|
|
return textOK(map[string]string{"status": "deleted", "mirror_name": args.MirrorName})
|
|
default:
|
|
return nil, fmt.Errorf("unknown action %q: must be add, list, or delete", args.Action)
|
|
}
|
|
}
|