Files
sherlock/internal/agent/spawn.go
T
amacocianandCopilot 24f77e7b74 Phase 1: login broker + agent-agnostic wrapper
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>
2026-05-24 22:39:34 +02:00

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)
}