Lock and implement Phase 1 decisions: - #8 token storage: OS keyring (zalando/go-keyring) with strict probe at startup of both sherlock and sherlock-broker; fail fast with exit 3 and a per-OS hint if Secret Service / Keychain / Credential Manager is missing. - #9 RPC framing: JSON-over-newline on the UDS at $XDG_RUNTIME_DIR/sherlock.sock, debuggable with socat. - #10 broker lifecycle: forked child process (setsid-detached), per-user PID-file flock prevents double-start, auto-exit after SHERLOCK_BROKER_IDLE (default 1h). No systemd. - #11 loopback port: 127.0.0.1:6990 for the Authentik PKCE callback. Actual Authentik provider creation deferred; login_start returns a clean 'not_configured' error mentioning the env vars to set, and the full OIDC path is exercised by an integration test against a stub Authentik (httptest + go-jose ES256 signer). New packages (all green under `go test -race`): - internal/xdg, internal/rpc, internal/socket — primitives - internal/keyring (+ fake/) — Probe, Store, TokenSet - internal/authn — discovery, PKCE, loopback flow, single-flight refresh - internal/broker — lifecycle, server, spawn, RPC methods - internal/agent — TOML profile loader (embedded + user overlay), MCP-config renderer, argv/env builder, syscall.Exec wrapper CLI: - cmd/sherlock: login / logout [--shutdown] / status / run <agent> / <agent-name> alias dispatch - cmd/sherlock-broker: daemon subcommand wiring all of the above Deps: zalando/go-keyring, BurntSushi/toml, coreos/go-oidc/v3, golang.org/x/oauth2, golang.org/x/sync. go directive bumped to 1.25; CI Go version bumped to 1.26.3 to match. Docs: new docs/storage.md and docs/rpc.md; auth-model, conventions, README, plan.md all updated to reflect the locked decisions. End-to-end verified locally: auto-spawn broker, status, login refused with not_configured, agent alias execs through with the rendered MCP config path, logout --shutdown brings the socket down. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
143 lines
4.5 KiB
Go
143 lines
4.5 KiB
Go
package agent
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/xdg"
|
|
)
|
|
|
|
// MCPConfig is what gets rendered to disk and handed to the agent.
|
|
// In Phase 1 it always has an empty Servers map — service MCPs land
|
|
// in Phases 2-4.
|
|
type MCPConfig struct {
|
|
Servers map[string]MCPServer `json:"servers"`
|
|
}
|
|
|
|
// MCPServer is one stdio-based MCP server entry. Phase 1 doesn't
|
|
// populate any; the fields exist so future phases don't need to
|
|
// change the renderer signature.
|
|
type MCPServer struct {
|
|
Command string `json:"command"`
|
|
Args []string `json:"args,omitempty"`
|
|
Env map[string]string `json:"env,omitempty"`
|
|
}
|
|
|
|
// RenderMCPConfig writes the rendered MCP config for profile to a path
|
|
// derived from xdg.MCPConfigDir, returning the path. Mode 0600.
|
|
//
|
|
// When profile.MCP.Mode == config-file the path is fixed by the profile
|
|
// itself; otherwise it lives under XDG runtime dir.
|
|
func RenderMCPConfig(profile Profile, cfg MCPConfig) (string, error) {
|
|
var path string
|
|
switch profile.MCP.Mode {
|
|
case MCPModeConfigFile:
|
|
if profile.MCP.Path == "" {
|
|
return "", fmt.Errorf("agent: profile %s: empty mcp.path", profile.Name)
|
|
}
|
|
path = profile.MCP.Path
|
|
case "":
|
|
// Agent doesn't take an MCP config. Nothing to render.
|
|
return "", nil
|
|
default:
|
|
dir, err := xdg.MCPConfigDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
path = filepath.Join(dir, profile.Name+".mcp.json")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return "", fmt.Errorf("agent: mkdir %s: %w", filepath.Dir(path), err)
|
|
}
|
|
body, err := json.MarshalIndent(cfg, "", " ")
|
|
if err != nil {
|
|
return "", fmt.Errorf("agent: marshal mcp config: %w", err)
|
|
}
|
|
if err := os.WriteFile(path, body, 0o600); err != nil {
|
|
return "", fmt.Errorf("agent: write %s: %w", path, err)
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
// BuildArgv composes the final argv passed to syscall.Exec:
|
|
//
|
|
// executable :: profile.Argv.Prefix :: [mcp flag/value] :: userArgs :: profile.Argv.Suffix
|
|
func BuildArgv(profile Profile, mcpPath string, userArgs []string) []string {
|
|
argv := []string{profile.Executable}
|
|
argv = append(argv, profile.Argv.Prefix...)
|
|
if profile.MCP.Mode == MCPModeCLIFlag && mcpPath != "" {
|
|
argv = append(argv, profile.MCP.Flag, mcpPath)
|
|
}
|
|
argv = append(argv, userArgs...)
|
|
argv = append(argv, profile.Argv.Suffix...)
|
|
return argv
|
|
}
|
|
|
|
// BuildEnv composes the child env: parent env, minus profile.Forbid.Env,
|
|
// plus profile.Env, plus broker-supplied overrides (e.g. socket path),
|
|
// plus the MCP env var when profile.MCP.Mode == env-var.
|
|
//
|
|
// brokerEnv keys override the parent; profile.Env overrides brokerEnv;
|
|
// the MCP env var (if any) overrides everything (so the agent always
|
|
// sees the freshly rendered config path).
|
|
func BuildEnv(profile Profile, mcpPath string, brokerEnv map[string]string) []string {
|
|
merged := make(map[string]string)
|
|
for _, kv := range os.Environ() {
|
|
k, v, _ := strings.Cut(kv, "=")
|
|
merged[k] = v
|
|
}
|
|
for _, k := range profile.Forbid.Env {
|
|
delete(merged, k)
|
|
}
|
|
for k, v := range brokerEnv {
|
|
merged[k] = v
|
|
}
|
|
for k, v := range profile.Env {
|
|
merged[k] = v
|
|
}
|
|
if profile.MCP.Mode == MCPModeEnvVar && mcpPath != "" {
|
|
merged[profile.MCP.Var] = mcpPath
|
|
}
|
|
out := make([]string, 0, len(merged))
|
|
for k, v := range merged {
|
|
out = append(out, k+"="+v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Execer abstracts the final exec so tests can capture it without
|
|
// actually replacing the test binary.
|
|
type Execer interface {
|
|
Exec(argv0 string, argv []string, env []string) error
|
|
}
|
|
|
|
// SyscallExecer is the production exec backed by syscall.Exec. It
|
|
// replaces the running process with the agent — successful invocations
|
|
// never return.
|
|
type SyscallExecer struct{}
|
|
|
|
func (SyscallExecer) Exec(argv0 string, argv []string, env []string) error {
|
|
return syscall.Exec(argv0, argv, env)
|
|
}
|
|
|
|
// ResolveExecutable returns an absolute path to the agent binary,
|
|
// looking it up on $PATH if necessary.
|
|
func ResolveExecutable(profile Profile) (string, error) {
|
|
if profile.Executable == "" {
|
|
return "", fmt.Errorf("agent: profile %s has empty executable", profile.Name)
|
|
}
|
|
if filepath.IsAbs(profile.Executable) {
|
|
return profile.Executable, nil
|
|
}
|
|
// We deliberately don't use os/exec.LookPath here so we don't have
|
|
// to swallow the PATHEXT/Windows quirks; sherlock targets unix and
|
|
// can use the same heuristic that syscall.Exec needs (an actual
|
|
// path). LookPath via exec is fine though — use it to keep this
|
|
// portable.
|
|
return execLookPath(profile.Executable)
|
|
}
|