mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b7b8296ac | ||
|
|
f857c4afb4 | ||
|
|
bb7de29c80 | ||
|
|
de5d402e1f | ||
|
|
9ddef0e957 | ||
|
|
ba8fbb8b71 | ||
|
|
265aa52ba7 | ||
|
|
135e3c5657 | ||
|
|
355b76cc78 | ||
|
|
6a9f44a716 | ||
|
|
e1f1b111a1 | ||
|
|
284a948f00 | ||
|
|
671af594fb | ||
|
|
7153ec7a7d | ||
|
|
73eb8d3376 | ||
|
|
0444eb0e6f |
No files matched your search
+67
-19
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/skills"
|
||||
"github.com/Gitlawb/zero/internal/specialist"
|
||||
"github.com/Gitlawb/zero/internal/swarm"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
"github.com/Gitlawb/zero/internal/update"
|
||||
@@ -240,6 +242,8 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
case "exec":
|
||||
// Forward leading --add-dir occurrences so exec's own parser collects them.
|
||||
return runExec(append(addDirFlagArgs(addDirs), args[1:]...), stdout, stderr, deps)
|
||||
case "daemon":
|
||||
return runDaemon(args[1:], stdout, stderr, deps)
|
||||
case "config":
|
||||
return runConfig(args[1:], stdout, stderr, deps)
|
||||
case "models":
|
||||
@@ -274,6 +278,8 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
return runHooks(args[1:], stdout, stderr, deps)
|
||||
case "mcp":
|
||||
return runMCP(args[1:], stdout, stderr, deps)
|
||||
case "auth":
|
||||
return runAuth(args[1:], stdout, stderr, deps)
|
||||
case "sandbox":
|
||||
return runSandbox(args[1:], stdout, stderr, deps)
|
||||
case "update":
|
||||
@@ -415,6 +421,12 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to resolve user config path: "+err.Error(), 1)
|
||||
}
|
||||
doctorUserConfigPath := ""
|
||||
projectConfigPath := ""
|
||||
if resolveOptions, optErr := config.DefaultResolveOptions(workspaceRoot); optErr == nil {
|
||||
doctorUserConfigPath = resolveOptions.UserConfigPath
|
||||
projectConfigPath = resolveOptions.ProjectConfigPath
|
||||
}
|
||||
|
||||
needsSetup := setupRequired(resolved)
|
||||
setupVisible := forceSetup || needsSetup
|
||||
@@ -507,20 +519,24 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
})
|
||||
lastKnownMCPConfig := mcpConfig
|
||||
return deps.runTUI(context.Background(), tui.Options{
|
||||
Cwd: workspaceRoot,
|
||||
UserConfigPath: userConfigPath,
|
||||
ProviderName: resolved.Provider.Name,
|
||||
ModelName: resolved.Provider.Model,
|
||||
ProviderProfile: resolved.Provider,
|
||||
FavoriteModels: resolved.Preferences.FavoriteModels,
|
||||
Provider: provider,
|
||||
NewProvider: deps.newProvider,
|
||||
Registry: registry,
|
||||
SessionStore: deps.newSessionStore(),
|
||||
SandboxStore: sandboxStore,
|
||||
MCPConfig: mcpConfig,
|
||||
MCPPermissionStore: mcpPermissionStore,
|
||||
MCPTokenStore: mcpTokenStore,
|
||||
Cwd: workspaceRoot,
|
||||
UserConfigPath: userConfigPath,
|
||||
DoctorUserConfigPath: doctorUserConfigPath,
|
||||
ProjectConfigPath: projectConfigPath,
|
||||
ProviderName: resolved.Provider.Name,
|
||||
ModelName: resolved.Provider.Model,
|
||||
ProviderProfile: resolved.Provider,
|
||||
FavoriteModels: resolved.Preferences.FavoriteModels,
|
||||
Provider: provider,
|
||||
NewProvider: deps.newProvider,
|
||||
ProbeProviderHealth: deps.probeProviderHealth,
|
||||
UserAgent: userAgent(),
|
||||
Registry: registry,
|
||||
SessionStore: deps.newSessionStore(),
|
||||
SandboxStore: sandboxStore,
|
||||
MCPConfig: mcpConfig,
|
||||
MCPPermissionStore: mcpPermissionStore,
|
||||
MCPTokenStore: mcpTokenStore,
|
||||
MCPCommand: func(ctx context.Context, args []string) tui.MCPCommandResult {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
@@ -582,12 +598,37 @@ func newCoreRegistryScoped(workspaceRoot string, scope tools.PathScope) *tools.R
|
||||
return registry
|
||||
}
|
||||
|
||||
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string) (*specialist.Runtime, error) {
|
||||
// agentToolRuntime bundles the specialist runtime with the swarm it backs so
|
||||
// their lifetimes (and shutdown) stay paired: registerSpecialistTools brings up
|
||||
// both, closeSpecialistRuntime tears down both.
|
||||
type agentToolRuntime struct {
|
||||
specialist *specialist.Runtime
|
||||
swarm *swarm.Swarm
|
||||
}
|
||||
|
||||
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string) (*agentToolRuntime, error) {
|
||||
paths, err := specialist.DefaultPaths(workspaceRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return specialist.RegisterTools(registry, specialist.Executor{Paths: paths})
|
||||
executor := specialist.Executor{Paths: paths}
|
||||
runtime, err := specialist.RegisterTools(registry, executor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The swarm reuses the same specialist executor to launch each member, so
|
||||
// every member runs under the orchestrator's sandbox + policy. Mailbox state
|
||||
// lives under the workspace so its files fall within the sandbox write rules.
|
||||
sw, err := swarm.New(swarm.Options{
|
||||
BaseDir: filepath.Join(workspaceRoot, ".zero", "swarm"),
|
||||
Launcher: swarm.NewSpecialistLauncher(executor),
|
||||
})
|
||||
if err != nil {
|
||||
runtime.Close()
|
||||
return nil, err
|
||||
}
|
||||
swarm.RegisterTools(registry, sw)
|
||||
return &agentToolRuntime{specialist: runtime, swarm: sw}, nil
|
||||
}
|
||||
|
||||
func shouldRegisterExecSpecialistTools(options execOptions) bool {
|
||||
@@ -609,12 +650,17 @@ func closeMCPRuntime(stderr io.Writer, runtime mcpToolRuntime) {
|
||||
}
|
||||
}
|
||||
|
||||
func closeSpecialistRuntime(stderr io.Writer, runtime *specialist.Runtime) {
|
||||
func closeSpecialistRuntime(stderr io.Writer, runtime *agentToolRuntime) {
|
||||
if runtime == nil {
|
||||
return
|
||||
}
|
||||
if err := runtime.Close(); err != nil {
|
||||
_, _ = fmt.Fprintf(stderr, "[zero] specialist_cleanup_error: %s\n", err)
|
||||
if runtime.swarm != nil {
|
||||
runtime.swarm.Close()
|
||||
}
|
||||
if runtime.specialist != nil {
|
||||
if err := runtime.specialist.Close(); err != nil {
|
||||
_, _ = fmt.Fprintf(stderr, "[zero] specialist_cleanup_error: %s\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -637,6 +683,7 @@ Usage:
|
||||
|
||||
Commands:
|
||||
exec Run a one-shot prompt through the Go agent runtime
|
||||
daemon Manage the local background worker daemon (start/stop/status/run/attach)
|
||||
setup Guide first-run provider setup
|
||||
config Inspect resolved Go configuration without leaking secrets
|
||||
models List Zero model registry entries
|
||||
@@ -655,6 +702,7 @@ Commands:
|
||||
tools Scaffold and list local Zero plugin-tools
|
||||
hooks Inspect Zero hook configuration
|
||||
mcp Manage MCP backend settings
|
||||
auth Log in to model providers via OAuth
|
||||
sandbox Inspect sandbox policy and persistent grants
|
||||
update Check for Zero CLI updates
|
||||
worktrees Prepare isolated git worktrees
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -59,10 +60,38 @@ func TestRunPrintsHelp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func setCLIUserConfigRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
root := t.TempDir()
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
t.Setenv("APPDATA", root)
|
||||
case "darwin":
|
||||
t.Setenv("HOME", root)
|
||||
default:
|
||||
t.Setenv("XDG_CONFIG_HOME", root)
|
||||
}
|
||||
|
||||
configRoot, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
t.Fatalf("UserConfigDir() error = %v", err)
|
||||
}
|
||||
return configRoot
|
||||
}
|
||||
|
||||
func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
setCLIUserConfigRoot(t)
|
||||
projectConfigPath := filepath.Join(cwd, ".zero", "config.json")
|
||||
if err := os.MkdirAll(filepath.Dir(projectConfigPath), 0o700); err != nil {
|
||||
t.Fatalf("create project config parent: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(projectConfigPath, []byte("{}"), 0o600); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
var launchedOptions tui.Options
|
||||
|
||||
@@ -104,6 +133,12 @@ func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *tes
|
||||
if launchedOptions.UserConfigPath != userConfigPath {
|
||||
t.Fatalf("UserConfigPath = %q, want %q", launchedOptions.UserConfigPath, userConfigPath)
|
||||
}
|
||||
if launchedOptions.DoctorUserConfigPath != "" {
|
||||
t.Fatalf("DoctorUserConfigPath = %q, want empty for missing user config", launchedOptions.DoctorUserConfigPath)
|
||||
}
|
||||
if launchedOptions.ProjectConfigPath != projectConfigPath {
|
||||
t.Fatalf("ProjectConfigPath = %q, want %q", launchedOptions.ProjectConfigPath, projectConfigPath)
|
||||
}
|
||||
if launchedOptions.Provider != nil {
|
||||
t.Fatalf("Provider = %#v, want nil", launchedOptions.Provider)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
)
|
||||
|
||||
// runAuth dispatches `zero auth <command>` for provider OAuth login. It is
|
||||
// additive and independent of `zero mcp oauth` (MCP server auth), which is
|
||||
// unchanged.
|
||||
func runAuth(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
if len(args) == 0 {
|
||||
return writeExecUsageError(stderr, "auth subcommand required. Use `zero auth status`.")
|
||||
}
|
||||
if args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
|
||||
if err := writeAuthHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
switch args[0] {
|
||||
case "login":
|
||||
return runAuthLogin(args[1:], stdout, stderr, deps)
|
||||
case "logout":
|
||||
return runAuthLogout(args[1:], stdout, stderr, deps)
|
||||
case "status":
|
||||
return runAuthStatus(args[1:], stdout, stderr, deps)
|
||||
case "refresh":
|
||||
return runAuthRefresh(args[1:], stdout, stderr, deps)
|
||||
default:
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown auth subcommand %q", args[0]))
|
||||
}
|
||||
}
|
||||
|
||||
// authArgs is the parsed form of an auth subcommand's arguments.
|
||||
type authArgs struct {
|
||||
positional []string
|
||||
json bool
|
||||
device bool
|
||||
watch bool
|
||||
scopes []string
|
||||
help bool
|
||||
}
|
||||
|
||||
func parseAuthArgs(sub string, args []string) (authArgs, error) {
|
||||
var parsed authArgs
|
||||
addScope := func(scope string) error {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" {
|
||||
return fmt.Errorf("--scope requires a non-empty value")
|
||||
}
|
||||
parsed.scopes = append(parsed.scopes, scope)
|
||||
return nil
|
||||
}
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
parsed.help = true
|
||||
case arg == "--json":
|
||||
parsed.json = true
|
||||
case arg == "--device":
|
||||
parsed.device = true
|
||||
case arg == "--watch":
|
||||
parsed.watch = true
|
||||
case arg == "--scope":
|
||||
if i+1 >= len(args) {
|
||||
return authArgs{}, fmt.Errorf("--scope requires a value")
|
||||
}
|
||||
i++
|
||||
if err := addScope(args[i]); err != nil {
|
||||
return authArgs{}, err
|
||||
}
|
||||
case strings.HasPrefix(arg, "--scope="):
|
||||
if err := addScope(strings.TrimPrefix(arg, "--scope=")); err != nil {
|
||||
return authArgs{}, err
|
||||
}
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return authArgs{}, fmt.Errorf("unknown flag %q", arg)
|
||||
default:
|
||||
parsed.positional = append(parsed.positional, arg)
|
||||
}
|
||||
}
|
||||
if parsed.help {
|
||||
return parsed, nil // help short-circuits flag validation
|
||||
}
|
||||
if err := validateAuthFlags(sub, parsed); err != nil {
|
||||
return authArgs{}, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// validateAuthFlags rejects flags a subcommand does not accept, so an ambiguous
|
||||
// invocation fails fast instead of silently ignoring a flag.
|
||||
func validateAuthFlags(sub string, a authArgs) error {
|
||||
allowed := map[string]map[string]bool{
|
||||
"login": {"device": true, "scope": true},
|
||||
"logout": {"json": true},
|
||||
"status": {"json": true},
|
||||
"refresh": {"watch": true},
|
||||
}[sub]
|
||||
bad := func(name string) error { return fmt.Errorf("zero auth %s does not accept %s", sub, name) }
|
||||
if a.json && !allowed["json"] {
|
||||
return bad("--json")
|
||||
}
|
||||
if a.device && !allowed["device"] {
|
||||
return bad("--device")
|
||||
}
|
||||
if a.watch && !allowed["watch"] {
|
||||
return bad("--watch")
|
||||
}
|
||||
if len(a.scopes) > 0 && !allowed["scope"] {
|
||||
return bad("--scope")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// newAuthManager builds an oauth.Manager backed by the file store, printing the
|
||||
// authorization URL / device code to stdout. The store path honors
|
||||
// ZERO_OAUTH_TOKENS_PATH (env), so callers/tests can redirect it.
|
||||
func newAuthManager(deps appDeps, out io.Writer) (*oauth.Manager, error) {
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{Now: deps.now})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return oauth.NewManager(oauth.ManagerOptions{
|
||||
Store: store,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
Now: deps.now,
|
||||
Out: out,
|
||||
// The opener prints the URL so headless shells can copy it; the URL
|
||||
// carries no token material. A real browser launch is intentionally not
|
||||
// performed (the sandbox/headless contexts make printing the safer default).
|
||||
OpenBrowser: func(string) error { return nil },
|
||||
})
|
||||
}
|
||||
|
||||
func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
parsed, err := parseAuthArgs("login", args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if parsed.help {
|
||||
_ = writeAuthHelp(stdout)
|
||||
return exitSuccess
|
||||
}
|
||||
if len(parsed.positional) != 1 {
|
||||
return writeExecUsageError(stderr, "usage: zero auth login <provider> [--device] [--scope <scope>]")
|
||||
}
|
||||
manager, err := newAuthManager(deps, stdout)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
status, err := manager.Login(context.Background(), oauth.LoginOptions{
|
||||
Provider: parsed.positional[0],
|
||||
Device: parsed.device,
|
||||
ExtraScopes: parsed.scopes,
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "Logged in to %s.\n%s\n", parsed.positional[0], oauth.FormatStatuses([]oauth.Status{status})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
parsed, err := parseAuthArgs("logout", args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if parsed.help {
|
||||
_ = writeAuthHelp(stdout)
|
||||
return exitSuccess
|
||||
}
|
||||
if len(parsed.positional) != 1 {
|
||||
return writeExecUsageError(stderr, "usage: zero auth logout <provider>")
|
||||
}
|
||||
provider := parsed.positional[0]
|
||||
manager, err := newAuthManager(deps, stdout)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
removed, err := manager.Logout(provider)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if parsed.json {
|
||||
payload := struct {
|
||||
Provider string `json:"provider"`
|
||||
Removed bool `json:"removed"`
|
||||
}{Provider: provider, Removed: removed}
|
||||
if err := writePrettyJSON(stdout, payload); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
msg := fmt.Sprintf("No OAuth login stored for %s.\n", provider)
|
||||
if removed {
|
||||
msg = fmt.Sprintf("Logged out of %s.\n", provider)
|
||||
}
|
||||
if _, err := fmt.Fprint(stdout, msg); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runAuthStatus(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
parsed, err := parseAuthArgs("status", args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if parsed.help {
|
||||
_ = writeAuthHelp(stdout)
|
||||
return exitSuccess
|
||||
}
|
||||
if len(parsed.positional) > 1 {
|
||||
return writeExecUsageError(stderr, "usage: zero auth status [provider]")
|
||||
}
|
||||
manager, err := newAuthManager(deps, stdout)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
statuses, err := manager.StatusAll()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if len(parsed.positional) == 1 {
|
||||
statuses = filterAuthStatuses(statuses, parsed.positional[0])
|
||||
}
|
||||
if parsed.json {
|
||||
payload := struct {
|
||||
Logins []oauth.Status `json:"logins"`
|
||||
}{Logins: statuses}
|
||||
if err := writePrettyJSON(stdout, payload); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, oauth.FormatStatuses(statuses)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runAuthRefresh(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
parsed, err := parseAuthArgs("refresh", args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if parsed.help {
|
||||
_ = writeAuthHelp(stdout)
|
||||
return exitSuccess
|
||||
}
|
||||
if len(parsed.positional) != 1 {
|
||||
return writeExecUsageError(stderr, "usage: zero auth refresh <provider>")
|
||||
}
|
||||
provider := parsed.positional[0]
|
||||
manager, err := newAuthManager(deps, stdout)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
key := oauth.ProviderKey(provider)
|
||||
if parsed.watch {
|
||||
return runAuthRefreshWatch(manager, key, provider, stdout, stderr)
|
||||
}
|
||||
if _, err := manager.Handle401(context.Background(), key); err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "Refreshed OAuth token for %s.\n", provider); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// runAuthRefreshWatch keeps a provider's token fresh in the foreground until
|
||||
// interrupted. This is the opt-in proactive-refresh scheduler surface (for a
|
||||
// long-running external process that reads the token file). It validates a
|
||||
// refreshable token exists first, then schedules refreshes before each expiry.
|
||||
func runAuthRefreshWatch(manager *oauth.Manager, key, provider string, stdout io.Writer, stderr io.Writer) int {
|
||||
ctx, stop := signalContext()
|
||||
defer stop()
|
||||
// Validate (and refresh-if-needed) once up front so a missing/non-refreshable
|
||||
// token fails fast instead of silently watching nothing.
|
||||
if _, err := manager.GetFresh(ctx, key); err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
scheduler := oauth.NewRefreshScheduler()
|
||||
scheduler.Start(ctx, manager, key)
|
||||
defer scheduler.Stop()
|
||||
if _, err := fmt.Fprintf(stdout, "Watching %s — refreshing before expiry. Press Ctrl+C to stop.\n", provider); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
<-ctx.Done()
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func filterAuthStatuses(statuses []oauth.Status, provider string) []oauth.Status {
|
||||
want := oauth.ProviderKey(provider)
|
||||
filtered := make([]oauth.Status, 0, 1)
|
||||
for _, st := range statuses {
|
||||
if st.Key == want {
|
||||
filtered = append(filtered, st)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func writeAuthHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero auth <command>
|
||||
|
||||
Commands:
|
||||
login <provider> [--device] [--scope <scope>] Log in to a provider via OAuth
|
||||
logout <provider> Delete a provider's stored login
|
||||
status [provider] Show login presence/expiry (never the token)
|
||||
refresh <provider> [--watch] Force a token refresh (--watch keeps it fresh)
|
||||
|
||||
A provider is any OAuth 2.0 / OIDC server you configure via env (no providers are
|
||||
built in). For a provider named <name>, set:
|
||||
ZERO_OAUTH_<NAME>_CLIENT_ID (required)
|
||||
ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
|
||||
ZERO_OAUTH_<NAME>_AUTHORIZE_URL ZERO_OAUTH_<NAME>_TOKEN_URL
|
||||
ZERO_OAUTH_<NAME>_DEVICE_URL ZERO_OAUTH_<NAME>_ISSUER_URL (for discovery)
|
||||
ZERO_OAUTH_<NAME>_SCOPES ZERO_OAUTH_<NAME>_FLOW (loopback|device)
|
||||
Endpoint URLs must be https (loopback exempt).
|
||||
|
||||
Flags:
|
||||
--device Use the device-code flow (headless/SSH; no browser)
|
||||
--scope Add an OAuth scope (repeatable)
|
||||
--watch Keep the token fresh in the foreground (refresh only)
|
||||
--json Print result as JSON (status/logout)
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
)
|
||||
|
||||
// withAuthStore points the provider OAuth store at a temp file for the test.
|
||||
func withAuthStore(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
|
||||
return path
|
||||
}
|
||||
|
||||
func TestRunAuthStatusEmpty(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No OAuth provider logins are stored.") {
|
||||
t.Fatalf("status output = %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthStatusReportsLoginWithoutSecret(t *testing.T) {
|
||||
path := withAuthStore(t)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("demo"), oauth.Token{
|
||||
AccessToken: "super-secret", RefreshToken: "super-secret-rt", Account: "[email protected]",
|
||||
}); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "demo") || !strings.Contains(out, "[email protected]") {
|
||||
t.Fatalf("status should show provider + account: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "super-secret") {
|
||||
t.Fatalf("status leaked token material: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthLogoutNothing(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "logout", "demo"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No OAuth login stored for demo") {
|
||||
t.Fatalf("logout output = %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthLoginValidation(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
// Missing provider.
|
||||
if code := runWithDeps([]string{"auth", "login"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("login with no provider should fail")
|
||||
}
|
||||
// --json is rejected for the interactive login.
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := runWithDeps([]string{"auth", "login", "demo", "--json"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("login --json should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthLoginUnknownProvider(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "login", "does-not-exist"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("unknown provider login should fail")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "not configured") {
|
||||
t.Fatalf("stderr = %q, want not-configured error", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthRefreshNoToken(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
t.Setenv("ZERO_OAUTH_DEMO_CLIENT_ID", "client") // so config resolves; refresh still fails (no token)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "refresh", "demo"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("refresh with no stored token should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthRejectsWrongFlags(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
cases := [][]string{
|
||||
{"auth", "login", "demo", "--watch"}, // watch is refresh-only
|
||||
{"auth", "login", "demo", "--json"}, // json not for interactive login
|
||||
{"auth", "status", "demo", "--device"}, // device is login-only
|
||||
{"auth", "logout", "demo", "--scope", "x"}, // scope is login-only
|
||||
{"auth", "refresh", "demo", "--json"}, // json not for refresh
|
||||
{"auth", "login", "demo", "--scope", ""}, // empty scope rejected
|
||||
}
|
||||
for _, args := range cases {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps(args, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Errorf("args %v should be rejected, got success", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthHelp(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("exit = %d", code)
|
||||
}
|
||||
for _, want := range []string{"zero auth", "login", "logout", "status", "refresh", "--device"} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("help missing %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,13 @@ type backendStatusOptions struct {
|
||||
}
|
||||
|
||||
func runBackends(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
if len(args) > 0 {
|
||||
switch args[0] {
|
||||
case "doctor":
|
||||
return runBackendsDoctor(args[1:], stdout, stderr, deps)
|
||||
}
|
||||
}
|
||||
|
||||
options, help, err := parseBackendsArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
@@ -44,6 +51,44 @@ func runBackends(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
type backendDoctorOptions struct {
|
||||
json bool
|
||||
}
|
||||
|
||||
func runBackendsDoctor(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseBackendsDoctorArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeBackendsDoctorHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
report, err := backendDoctorReport(deps)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, report); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if !report.OK {
|
||||
return exitProvider
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, redaction.RedactString(formatBackendDoctorReport(report), redaction.Options{})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if !report.OK {
|
||||
return exitProvider
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func parseBackendsArgs(args []string) (backendStatusOptions, bool, error) {
|
||||
options := backendStatusOptions{}
|
||||
for _, arg := range args {
|
||||
@@ -59,6 +104,21 @@ func parseBackendsArgs(args []string) (backendStatusOptions, bool, error) {
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
func parseBackendsDoctorArgs(args []string) (backendDoctorOptions, bool, error) {
|
||||
options := backendDoctorOptions{}
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "-h", "--help", "help":
|
||||
return options, true, nil
|
||||
case "--json":
|
||||
options.json = true
|
||||
default:
|
||||
return options, false, execUsageError{fmt.Sprintf("unknown backends doctor flag %q", arg)}
|
||||
}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
func backendLifecycleSnapshot(deps appDeps) (zerocommands.BackendLifecycleSnapshot, error) {
|
||||
cwd, err := deps.getwd()
|
||||
if err != nil {
|
||||
@@ -86,6 +146,32 @@ func backendLifecycleSnapshot(deps appDeps) (zerocommands.BackendLifecycleSnapsh
|
||||
return zerocommands.NewBackendLifecycleSnapshot(servers, hookResult.Config.Hooks, pluginResult.Plugins), nil
|
||||
}
|
||||
|
||||
func backendDoctorReport(deps appDeps) (zerocommands.BackendDoctorReport, error) {
|
||||
cwd, err := deps.getwd()
|
||||
if err != nil {
|
||||
return zerocommands.BackendDoctorReport{}, fmt.Errorf("failed to resolve workspace: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := deps.resolveMCPConfig(cwd)
|
||||
if err != nil {
|
||||
return zerocommands.BackendDoctorReport{}, err
|
||||
}
|
||||
hookResult, err := deps.loadHooks(hooks.LoadOptions{Cwd: cwd})
|
||||
if err != nil {
|
||||
return zerocommands.BackendDoctorReport{}, err
|
||||
}
|
||||
pluginResult, err := deps.loadPlugins(plugins.LoadOptions{Cwd: cwd})
|
||||
if err != nil {
|
||||
return zerocommands.BackendDoctorReport{}, err
|
||||
}
|
||||
|
||||
return zerocommands.NewBackendDoctorReport(zerocommands.BackendDoctorInput{
|
||||
MCP: cfg,
|
||||
Hooks: hookResult,
|
||||
Plugins: pluginResult,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func formatBackendLifecycleSnapshot(snapshot zerocommands.BackendLifecycleSnapshot) string {
|
||||
lines := []string{"Zero Backends:"}
|
||||
lines = append(lines, fmt.Sprintf(" MCP servers: %d", len(snapshot.MCPServers)))
|
||||
@@ -135,16 +221,52 @@ func formatBackendLifecycleSnapshot(snapshot zerocommands.BackendLifecycleSnapsh
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func formatBackendDoctorReport(report zerocommands.BackendDoctorReport) string {
|
||||
lines := []string{
|
||||
"Zero backend doctor",
|
||||
"Overall: " + string(report.Status),
|
||||
}
|
||||
for _, check := range report.Checks {
|
||||
target := ""
|
||||
if check.Target != "" {
|
||||
target = " " + check.Target
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("[%s] %s%s - %s", check.Status, check.ID, target, check.Message))
|
||||
if check.Action != "" {
|
||||
lines = append(lines, " action: "+check.Action)
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func writeBackendsHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero backends [flags]
|
||||
zero backends doctor [flags]
|
||||
|
||||
Inspect MCP, hook, and plugin backend lifecycle state without connecting to
|
||||
external MCP servers.
|
||||
|
||||
Commands:
|
||||
doctor Diagnose MCP, hook, and plugin backend setup
|
||||
|
||||
Flags:
|
||||
--json Print backend lifecycle data as JSON
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeBackendsDoctorHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero backends doctor [flags]
|
||||
|
||||
Diagnose MCP, hook, and plugin backend setup without connecting to external MCP
|
||||
servers or executing hooks/plugins.
|
||||
|
||||
Flags:
|
||||
--json Print backend diagnostics as JSON
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -5,7 +5,12 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
@@ -153,3 +158,220 @@ func TestRunBackendsTextAndHelp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBackendsDoctorJSONAndTextWithoutConnectingMCP(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
secret := "sk-proj-" + strings.Repeat("b", 24)
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
if workspaceRoot != cwd {
|
||||
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
||||
}
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"remote": {
|
||||
Type: "http",
|
||||
URL: "https://api.example.com/mcp?token=" + secret,
|
||||
},
|
||||
"broken": {
|
||||
Type: "http",
|
||||
},
|
||||
}}, nil
|
||||
},
|
||||
loadHooks: func(options hooks.LoadOptions) (hooks.LoadResult, error) {
|
||||
if options.Cwd != cwd {
|
||||
t.Fatalf("hook Cwd = %q, want %q", options.Cwd, cwd)
|
||||
}
|
||||
return hooks.LoadResult{Diagnostics: []hooks.Diagnostic{{
|
||||
Kind: hooks.DiagnosticSchema,
|
||||
Message: "schema failed " + secret,
|
||||
HookID: "zero.bad",
|
||||
}}}, nil
|
||||
},
|
||||
loadPlugins: func(options plugins.LoadOptions) (plugins.LoadResult, error) {
|
||||
if options.Cwd != cwd {
|
||||
t.Fatalf("plugin Cwd = %q, want %q", options.Cwd, cwd)
|
||||
}
|
||||
return plugins.LoadResult{Plugins: []plugins.LoadedPlugin{{
|
||||
ID: "zero.docs",
|
||||
Name: "Docs",
|
||||
Enabled: true,
|
||||
Source: plugins.SourceProject,
|
||||
}}}, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return nil, errors.New("zero backends doctor must not connect to MCP servers")
|
||||
},
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"backends", "doctor", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitProvider {
|
||||
t.Fatalf("exitCode = %d, want %d stderr=%s stdout=%s", exitCode, exitProvider, stderr.String(), stdout.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), secret) || strings.Contains(stdout.String(), "sk-proj-") {
|
||||
t.Fatalf("backend doctor JSON leaked secret material:\n%s", stdout.String())
|
||||
}
|
||||
var payload zerocommands.BackendDoctorReport
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("backend doctor JSON failed to decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.OK {
|
||||
t.Fatalf("payload.OK = true, want false for invalid MCP/hook diagnostic: %#v", payload.Checks)
|
||||
}
|
||||
if payload.Status != zerocommands.BackendDoctorStatusFail {
|
||||
t.Fatalf("payload.Status = %q, want %q", payload.Status, zerocommands.BackendDoctorStatusFail)
|
||||
}
|
||||
assertBackendDoctorPayloadCheck(t, payload, "backend.mcp.invalid", "broken")
|
||||
assertBackendDoctorPayloadCheck(t, payload, "backend.hooks.diagnostic", "zero.bad")
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"backends", "doctor"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitProvider {
|
||||
t.Fatalf("text exitCode = %d, want %d stderr=%s", exitCode, exitProvider, stderr.String())
|
||||
}
|
||||
for _, want := range []string{"Zero backend doctor", "[fail] backend.mcp.invalid", "zero mcp add broken", "[fail] backend.hooks.diagnostic"} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("backend doctor text missing %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBackendsDoctorHelp(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"backends", "doctor", "--help"}, &stdout, &stderr, appDeps{})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
for _, want := range []string{"Usage:", "zero backends doctor", "--json"} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("backend doctor help missing %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBackendsDoctorDoesNotConnectOrExecuteConfiguredBackends(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
hookSentinel := filepath.Join(cwd, "hook-ran")
|
||||
pluginToolSentinel := filepath.Join(cwd, "plugin-tool-ran")
|
||||
pluginHookSentinel := filepath.Join(cwd, "plugin-hook-ran")
|
||||
helperCommand := os.Args[0]
|
||||
helperArgs := func(path string) []string {
|
||||
return []string{"-test.run=TestBackendDoctorHelperProcess", "--", "--zero-backend-doctor-sentinel", path}
|
||||
}
|
||||
|
||||
var hits int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
hookConfigPath := filepath.Join(cwd, ".zero", "hooks.json")
|
||||
writeBackendDoctorJSON(t, hookConfigPath, map[string]any{
|
||||
"enabled": true,
|
||||
"hooks": []any{map[string]any{
|
||||
"id": "zero.sentinel",
|
||||
"event": "sessionStart",
|
||||
"command": helperCommand,
|
||||
"args": helperArgs(hookSentinel),
|
||||
}},
|
||||
})
|
||||
pluginDir := filepath.Join(cwd, ".zero", "plugins", "sentinel")
|
||||
writeBackendDoctorJSON(t, filepath.Join(pluginDir, "plugin.json"), map[string]any{
|
||||
"schemaVersion": 1,
|
||||
"id": "zero.sentinel",
|
||||
"name": "Sentinel",
|
||||
"version": "1.0.0",
|
||||
"tools": []any{map[string]any{
|
||||
"name": "sentinel_tool",
|
||||
"command": helperCommand,
|
||||
"args": helperArgs(pluginToolSentinel),
|
||||
}},
|
||||
"hooks": []any{map[string]any{
|
||||
"name": "sentinel_hook",
|
||||
"event": "sessionStart",
|
||||
"command": helperCommand,
|
||||
"args": helperArgs(pluginHookSentinel),
|
||||
}},
|
||||
})
|
||||
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveMCPConfig: func(string) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"remote": {Type: "http", URL: server.URL + "/mcp"},
|
||||
}}, nil
|
||||
},
|
||||
loadHooks: func(options hooks.LoadOptions) (hooks.LoadResult, error) {
|
||||
return hooks.LoadConfig(hooks.LoadOptions{
|
||||
Cwd: options.Cwd,
|
||||
UserConfigPath: filepath.Join(cwd, "missing-user-hooks.json"),
|
||||
ProjectConfigPath: hookConfigPath,
|
||||
})
|
||||
},
|
||||
loadPlugins: func(options plugins.LoadOptions) (plugins.LoadResult, error) {
|
||||
return plugins.Load(plugins.LoadOptions{
|
||||
Roots: []plugins.Root{{Source: plugins.SourceProject, Path: filepath.Join(cwd, ".zero", "plugins")}},
|
||||
Cwd: options.Cwd,
|
||||
})
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return nil, errors.New("zero backends doctor must not register MCP tools")
|
||||
},
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"backends", "doctor", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exitCode = %d stderr=%s stdout=%s", exitCode, stderr.String(), stdout.String())
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != 0 {
|
||||
t.Fatalf("backend doctor connected to MCP HTTP server %d time(s)", got)
|
||||
}
|
||||
for _, sentinel := range []string{hookSentinel, pluginToolSentinel, pluginHookSentinel} {
|
||||
if _, err := os.Stat(sentinel); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("backend doctor executed configured backend command, sentinel=%s err=%v", sentinel, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertBackendDoctorPayloadCheck(t *testing.T, report zerocommands.BackendDoctorReport, id string, target string) {
|
||||
t.Helper()
|
||||
for _, check := range report.Checks {
|
||||
if check.ID == id && check.Target == target {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("check %s/%s not found in %#v", id, target, report.Checks)
|
||||
}
|
||||
|
||||
func writeBackendDoctorJSON(t *testing.T, path string, value any) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatalf("create dir for %s: %v", path, err)
|
||||
}
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendDoctorHelperProcess(t *testing.T) {
|
||||
for index, arg := range os.Args {
|
||||
if arg != "--zero-backend-doctor-sentinel" || index+1 >= len(os.Args) {
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(os.Args[index+1], []byte("executed"), 0o600); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/background"
|
||||
"github.com/Gitlawb/zero/internal/daemon"
|
||||
"github.com/Gitlawb/zero/internal/daemon/remote"
|
||||
)
|
||||
|
||||
// runDaemon dispatches the `zero daemon ...` subcommands. The daemon supervises a
|
||||
// pool of headless `zero exec` workers and routes sessions to them over an
|
||||
// owner-only local control socket. It is an ADDITIVE surface — interactive and
|
||||
// one-shot exec are unchanged.
|
||||
func runDaemon(args []string, stdout io.Writer, stderr io.Writer, _ appDeps) int {
|
||||
if len(args) == 0 {
|
||||
return writeDaemonUsage(stderr, exitUsage)
|
||||
}
|
||||
sub, rest := args[0], args[1:]
|
||||
switch sub {
|
||||
case "start":
|
||||
return runDaemonStart(rest, stdout, stderr)
|
||||
case "stop":
|
||||
return runDaemonStop(rest, stdout, stderr)
|
||||
case "status":
|
||||
return runDaemonStatus(rest, stdout, stderr)
|
||||
case "run":
|
||||
return runDaemonRun(rest, stdout, stderr)
|
||||
case "attach":
|
||||
return runDaemonAttach(rest, stdout, stderr)
|
||||
case "serve-remote":
|
||||
return runDaemonServeRemote(rest, stdout, stderr)
|
||||
case "-h", "--help", "help":
|
||||
return writeDaemonUsage(stdout, exitSuccess)
|
||||
default:
|
||||
fmt.Fprintf(stderr, "unknown daemon subcommand %q\n", sub)
|
||||
return writeDaemonUsage(stderr, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func writeDaemonUsage(w io.Writer, code int) int {
|
||||
fmt.Fprint(w, `Usage: zero daemon <command>
|
||||
|
||||
Commands:
|
||||
start [--foreground] Start the daemon (background by default).
|
||||
stop Gracefully stop the running daemon.
|
||||
status Show daemon / pool / session status.
|
||||
run --session <id> [--cwd <dir>] [--prompt <text>] [exec flags...]
|
||||
Create/route a session and stream its output.
|
||||
attach <session> Attach to a running session's stream.
|
||||
serve-remote --addr <host:port> --tls-cert <f> --tls-key <f>
|
||||
Serve an opt-in, TLS-only network bridge to this
|
||||
daemon. Requires a bearer token in $ZERO_DAEMON_REMOTE_TOKEN
|
||||
(or $ZERO_DAEMON_REMOTE_TOKEN_FILE).
|
||||
|
||||
run and attach accept --remote <host:port> [--token <t>] [--ca-cert <f>]
|
||||
[--server-name <name>] to drive a remote daemon over the bridge instead of the
|
||||
local socket.
|
||||
`)
|
||||
return code
|
||||
}
|
||||
|
||||
// runDaemonStart starts the daemon. Without --foreground it spawns a detached
|
||||
// background process running the foreground daemon and returns once it is up.
|
||||
func runDaemonStart(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
foreground := false
|
||||
for _, a := range args {
|
||||
switch a {
|
||||
case "--foreground", "-f":
|
||||
foreground = true
|
||||
case "-h", "--help":
|
||||
return writeDaemonUsage(stdout, exitSuccess)
|
||||
default:
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown flag %q for daemon start", a))
|
||||
}
|
||||
}
|
||||
paths, err := daemon.DefaultPaths()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if foreground {
|
||||
return runDaemonForeground(paths, stdout, stderr)
|
||||
}
|
||||
return runDaemonStartDetached(paths, stdout, stderr)
|
||||
}
|
||||
|
||||
// runDaemonForeground runs the daemon in this process until SIGINT/SIGTERM. Each
|
||||
// worker is a headless `zero exec` child with the sandbox re-entrancy markers
|
||||
// scrubbed (NewExecLauncher) so it establishes its own sandbox.
|
||||
func runDaemonForeground(paths daemon.Paths, stdout io.Writer, stderr io.Writer) int {
|
||||
launcher, err := daemon.NewExecLauncher(daemon.ExecLauncherConfig{})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
logf := func(line string) { fmt.Fprintln(stderr, "[daemon] "+line) }
|
||||
pool, err := daemon.NewPool(daemon.PoolOptions{Launcher: launcher, Log: logf})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
mgr, err := daemon.NewSessionManager(daemon.SessionManagerOptions{Pool: pool})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
srv, err := daemon.NewServer(daemon.ServerOptions{Paths: paths, Manager: mgr, Pool: pool, Log: logf})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigCh
|
||||
srv.Shutdown()
|
||||
}()
|
||||
|
||||
if err := srv.Serve(); err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// runDaemonStartDetached spawns the foreground daemon as a detached background
|
||||
// process (its own process group, output to a log file) and waits for it to bind.
|
||||
func runDaemonStartDetached(paths daemon.Paths, stdout io.Writer, stderr io.Writer) int {
|
||||
if daemonReachable(paths) {
|
||||
fmt.Fprintln(stdout, "zero daemon is already running")
|
||||
return exitSuccess
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(paths.Socket), 0o700); err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
logPath := filepath.Join(filepath.Dir(paths.Socket), "daemon.log")
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
defer logFile.Close()
|
||||
|
||||
cmd := exec.Command(exe, "daemon", "start", "--foreground")
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
background.ConfigureChildProcessGroup(cmd) // own process group: outlives this shell
|
||||
if err := cmd.Start(); err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
_ = cmd.Process.Release()
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if daemonReachable(paths) {
|
||||
fmt.Fprintf(stdout, "zero daemon started (socket %s)\n", paths.Socket)
|
||||
return exitSuccess
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
return writeAppError(stderr, "daemon did not come up within timeout; see "+logPath, exitCrash)
|
||||
}
|
||||
|
||||
func runDaemonStop(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
if helpRequested(args) {
|
||||
return writeDaemonUsage(stdout, exitSuccess)
|
||||
}
|
||||
if len(args) > 0 {
|
||||
return writeExecUsageError(stderr, "daemon stop takes no arguments")
|
||||
}
|
||||
paths, err := daemon.DefaultPaths()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
client, err := daemon.Dial(paths.Socket)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stdout, "zero daemon is not running")
|
||||
return exitSuccess
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.Shutdown(); err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
fmt.Fprintln(stdout, "zero daemon stopped")
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runDaemonStatus(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
if helpRequested(args) {
|
||||
return writeDaemonUsage(stdout, exitSuccess)
|
||||
}
|
||||
if len(args) > 0 {
|
||||
return writeExecUsageError(stderr, "daemon status takes no arguments")
|
||||
}
|
||||
paths, err := daemon.DefaultPaths()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
client, err := daemon.Dial(paths.Socket)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stdout, "zero daemon is not running")
|
||||
return exitSuccess
|
||||
}
|
||||
defer client.Close()
|
||||
report, err := client.Status()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
fmt.Fprintf(stdout, "daemon pid=%d version=%d socket=%s\n", report.PID, report.Version, report.Socket)
|
||||
fmt.Fprintf(stdout, "pool size=%d busy=%d queue=%d\n", report.PoolSize, len(report.Workers), report.QueueDepth)
|
||||
for _, s := range report.Sessions {
|
||||
fmt.Fprintf(stdout, " session %s state=%s lines=%d\n", s.ID, s.State, s.Lines)
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runDaemonRun(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
session, cwd, prompt := "", "", ""
|
||||
var remoteFlags remoteDialFlags
|
||||
var forward []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
value := func() (string, bool) {
|
||||
if i+1 >= len(args) {
|
||||
return "", false
|
||||
}
|
||||
i++
|
||||
return args[i], true
|
||||
}
|
||||
switch {
|
||||
case a == "-h" || a == "--help":
|
||||
return writeDaemonUsage(stdout, exitSuccess)
|
||||
case a == "--remote":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--remote requires a value")
|
||||
}
|
||||
remoteFlags.Addr = v
|
||||
case strings.HasPrefix(a, "--remote="):
|
||||
remoteFlags.Addr = strings.TrimPrefix(a, "--remote=")
|
||||
case a == "--token":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--token requires a value")
|
||||
}
|
||||
remoteFlags.Token = v
|
||||
case strings.HasPrefix(a, "--token="):
|
||||
remoteFlags.Token = strings.TrimPrefix(a, "--token=")
|
||||
case a == "--ca-cert":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--ca-cert requires a value")
|
||||
}
|
||||
remoteFlags.CACert = v
|
||||
case strings.HasPrefix(a, "--ca-cert="):
|
||||
remoteFlags.CACert = strings.TrimPrefix(a, "--ca-cert=")
|
||||
case a == "--server-name":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--server-name requires a value")
|
||||
}
|
||||
remoteFlags.ServerName = v
|
||||
case strings.HasPrefix(a, "--server-name="):
|
||||
remoteFlags.ServerName = strings.TrimPrefix(a, "--server-name=")
|
||||
case a == "--session":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--session requires a value")
|
||||
}
|
||||
session = v
|
||||
case strings.HasPrefix(a, "--session="):
|
||||
session = strings.TrimPrefix(a, "--session=")
|
||||
case a == "--cwd":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--cwd requires a value")
|
||||
}
|
||||
cwd = v
|
||||
case strings.HasPrefix(a, "--cwd="):
|
||||
cwd = strings.TrimPrefix(a, "--cwd=")
|
||||
case a == "--prompt" || a == "-p":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--prompt requires a value")
|
||||
}
|
||||
prompt = v
|
||||
case strings.HasPrefix(a, "--prompt="):
|
||||
prompt = strings.TrimPrefix(a, "--prompt=")
|
||||
default:
|
||||
// Forwarded verbatim to the worker `zero exec` (reuses exec's own flag
|
||||
// parsing for per-session run options).
|
||||
forward = append(forward, a)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(session) == "" {
|
||||
return writeExecUsageError(stderr, "daemon run requires --session <id>")
|
||||
}
|
||||
if prompt == "" && len(forward) == 0 {
|
||||
return writeExecUsageError(stderr, "daemon run requires --prompt <text> or exec args")
|
||||
}
|
||||
client, err := dialForCLI(remoteFlags)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, daemonDialError(remoteFlags, err), exitCrash)
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.Run(session, cwd, prompt, forward, func(line string) { fmt.Fprintln(stdout, line) }); err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runDaemonAttach(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
session := ""
|
||||
var remoteFlags remoteDialFlags
|
||||
extra := 0
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
value := func() (string, bool) {
|
||||
if i+1 >= len(args) {
|
||||
return "", false
|
||||
}
|
||||
i++
|
||||
return args[i], true
|
||||
}
|
||||
switch {
|
||||
case a == "-h" || a == "--help":
|
||||
return writeDaemonUsage(stdout, exitSuccess)
|
||||
case a == "--remote":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--remote requires a value")
|
||||
}
|
||||
remoteFlags.Addr = v
|
||||
case strings.HasPrefix(a, "--remote="):
|
||||
remoteFlags.Addr = strings.TrimPrefix(a, "--remote=")
|
||||
case a == "--token":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--token requires a value")
|
||||
}
|
||||
remoteFlags.Token = v
|
||||
case strings.HasPrefix(a, "--token="):
|
||||
remoteFlags.Token = strings.TrimPrefix(a, "--token=")
|
||||
case a == "--ca-cert":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--ca-cert requires a value")
|
||||
}
|
||||
remoteFlags.CACert = v
|
||||
case strings.HasPrefix(a, "--ca-cert="):
|
||||
remoteFlags.CACert = strings.TrimPrefix(a, "--ca-cert=")
|
||||
case a == "--server-name":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--server-name requires a value")
|
||||
}
|
||||
remoteFlags.ServerName = v
|
||||
case strings.HasPrefix(a, "--server-name="):
|
||||
remoteFlags.ServerName = strings.TrimPrefix(a, "--server-name=")
|
||||
case session == "":
|
||||
session = a
|
||||
default:
|
||||
extra++
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(session) == "" {
|
||||
return writeExecUsageError(stderr, "daemon attach requires a <session> id")
|
||||
}
|
||||
if extra > 0 {
|
||||
return writeExecUsageError(stderr, "daemon attach accepts exactly one <session> id")
|
||||
}
|
||||
client, err := dialForCLI(remoteFlags)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, daemonDialError(remoteFlags, err), exitCrash)
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.Attach(session, func(line string) { fmt.Fprintln(stdout, line) }); err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// daemonDialError tailors the connect-failure message for local vs remote.
|
||||
func daemonDialError(flags remoteDialFlags, err error) string {
|
||||
if strings.TrimSpace(flags.Addr) != "" {
|
||||
return "failed to reach remote daemon at " + flags.Addr + ": " + err.Error()
|
||||
}
|
||||
return "zero daemon is not running (start it with `zero daemon start`)"
|
||||
}
|
||||
|
||||
// runDaemonServeRemote starts the local daemon plus an opt-in, TLS-only network
|
||||
// bridge. TLS and a bearer token are mandatory (fail closed): it refuses to
|
||||
// start without a cert/key pair and a token from the environment.
|
||||
func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
addr, certFile, keyFile := "", "", ""
|
||||
minVersion, maxConns := 0, 0
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
value := func() (string, bool) {
|
||||
if i+1 >= len(args) {
|
||||
return "", false
|
||||
}
|
||||
i++
|
||||
return args[i], true
|
||||
}
|
||||
switch {
|
||||
case a == "-h" || a == "--help":
|
||||
return writeDaemonUsage(stdout, exitSuccess)
|
||||
case a == "--addr":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--addr requires a value")
|
||||
}
|
||||
addr = v
|
||||
case strings.HasPrefix(a, "--addr="):
|
||||
addr = strings.TrimPrefix(a, "--addr=")
|
||||
case a == "--tls-cert":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--tls-cert requires a value")
|
||||
}
|
||||
certFile = v
|
||||
case strings.HasPrefix(a, "--tls-cert="):
|
||||
certFile = strings.TrimPrefix(a, "--tls-cert=")
|
||||
case a == "--tls-key":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--tls-key requires a value")
|
||||
}
|
||||
keyFile = v
|
||||
case strings.HasPrefix(a, "--tls-key="):
|
||||
keyFile = strings.TrimPrefix(a, "--tls-key=")
|
||||
case a == "--min-version":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--min-version requires a value")
|
||||
}
|
||||
minVersion = atoiOrZero(v)
|
||||
case a == "--max-conns":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--max-conns requires a value")
|
||||
}
|
||||
maxConns = atoiOrZero(v)
|
||||
default:
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown flag %q for daemon serve-remote", a))
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(addr) == "" {
|
||||
return writeExecUsageError(stderr, "daemon serve-remote requires --addr <host:port>")
|
||||
}
|
||||
// Fail closed: TLS cert/key + a bearer token are mandatory.
|
||||
tlsConfig, err := remote.ServerTLSConfig(certFile, keyFile)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
token, err := remote.TokenFromEnv()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
auth, err := remote.NewTokenAuthenticator(token)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
paths, err := daemon.DefaultPaths()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
launcher, err := daemon.NewExecLauncher(daemon.ExecLauncherConfig{})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
logf := func(line string) { fmt.Fprintln(stderr, "[daemon] "+line) }
|
||||
pool, err := daemon.NewPool(daemon.PoolOptions{Launcher: launcher, Log: logf})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
mgr, err := daemon.NewSessionManager(daemon.SessionManagerOptions{Pool: pool})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
srv, err := daemon.NewServer(daemon.ServerOptions{Paths: paths, Manager: mgr, Pool: pool, Log: logf})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
bridge, err := remote.NewBridge(remote.BridgeOptions{
|
||||
Server: srv, Authenticator: auth, MinVersion: minVersion, MaxConnections: maxConns, Log: logf,
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
|
||||
// Serve the local control socket too, so local clients keep working.
|
||||
go func() {
|
||||
if serveErr := srv.Serve(); serveErr != nil {
|
||||
logf("local serve error: " + serveErr.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- bridge.ListenAndServeTLS(addr, tlsConfig) }()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
fmt.Fprintf(stdout, "zero daemon remote bridge listening on %s (TLS)\n", addr)
|
||||
select {
|
||||
case <-sigCh:
|
||||
srv.Shutdown()
|
||||
_ = bridge.Close()
|
||||
<-serveErr // wait for the accept loop to unwind
|
||||
return exitSuccess
|
||||
case err := <-serveErr:
|
||||
// Bind/serve failed before any signal (e.g. address in use).
|
||||
srv.Shutdown()
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
}
|
||||
|
||||
// remoteDialFlags holds the optional flags that redirect run/attach to a remote
|
||||
// daemon. When Addr is empty the local control socket is used.
|
||||
type remoteDialFlags struct {
|
||||
Addr string
|
||||
Token string
|
||||
CACert string
|
||||
ServerName string
|
||||
}
|
||||
|
||||
// dialForCLI returns a daemon.Client for either the local socket (Addr empty) or
|
||||
// a remote bridge (Addr set). For remote, the token falls back to the env.
|
||||
func dialForCLI(flags remoteDialFlags) (*daemon.Client, error) {
|
||||
if strings.TrimSpace(flags.Addr) == "" {
|
||||
paths, err := daemon.DefaultPaths()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return daemon.Dial(paths.Socket)
|
||||
}
|
||||
token := strings.TrimSpace(flags.Token)
|
||||
if token == "" {
|
||||
token, _ = remote.TokenFromEnv() // best effort; DialRemote rejects an empty token
|
||||
}
|
||||
return remote.DialRemote(remote.RemoteConfig{
|
||||
Address: flags.Addr,
|
||||
Token: token,
|
||||
CACertFile: flags.CACert,
|
||||
ServerName: flags.ServerName,
|
||||
})
|
||||
}
|
||||
|
||||
func atoiOrZero(s string) int {
|
||||
n := 0
|
||||
for _, r := range strings.TrimSpace(s) {
|
||||
if r < '0' || r > '9' {
|
||||
return 0
|
||||
}
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// daemonReachable reports whether a daemon is accepting connections (a successful
|
||||
// handshake). Used for the single-instance check and start-up wait.
|
||||
func daemonReachable(paths daemon.Paths) bool {
|
||||
client, err := daemon.Dial(paths.Socket)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = client.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
func helpRequested(args []string) bool {
|
||||
for _, a := range args {
|
||||
if a == "-h" || a == "--help" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// isolateDaemonPaths points DefaultPaths at a temp dir so the test never touches
|
||||
// a real daemon on the dev machine.
|
||||
func isolateDaemonPaths(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
|
||||
}
|
||||
|
||||
func runDaemonCLI(t *testing.T, args ...string) (int, string, string) {
|
||||
t.Helper()
|
||||
var out, errb bytes.Buffer
|
||||
code := runDaemon(args, &out, &errb, appDeps{})
|
||||
return code, out.String(), errb.String()
|
||||
}
|
||||
|
||||
func TestDaemonUsage(t *testing.T) {
|
||||
code, _, _ := runDaemonCLI(t)
|
||||
if code != exitUsage {
|
||||
t.Fatalf("no-args exit = %d, want exitUsage", code)
|
||||
}
|
||||
code, out, _ := runDaemonCLI(t, "--help")
|
||||
if code != exitSuccess || !strings.Contains(out, "Usage: zero daemon") {
|
||||
t.Fatalf("--help exit=%d out=%q", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonUnknownSubcommand(t *testing.T) {
|
||||
code, _, errb := runDaemonCLI(t, "frobnicate")
|
||||
if code != exitUsage {
|
||||
t.Fatalf("unknown subcommand exit = %d, want exitUsage", code)
|
||||
}
|
||||
if !strings.Contains(errb, "unknown daemon subcommand") {
|
||||
t.Fatalf("stderr = %q, want unknown-subcommand message", errb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonRunRequiresSession(t *testing.T) {
|
||||
isolateDaemonPaths(t)
|
||||
code, _, errb := runDaemonCLI(t, "run", "--prompt", "hi")
|
||||
if code != exitUsage {
|
||||
t.Fatalf("run without --session exit = %d, want exitUsage", code)
|
||||
}
|
||||
if !strings.Contains(errb, "--session") {
|
||||
t.Fatalf("stderr = %q, want a --session hint", errb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonRunRequiresPromptOrArgs(t *testing.T) {
|
||||
isolateDaemonPaths(t)
|
||||
code, _, errb := runDaemonCLI(t, "run", "--session", "s1")
|
||||
if code != exitUsage {
|
||||
t.Fatalf("run without prompt/args exit = %d, want exitUsage", code)
|
||||
}
|
||||
if !strings.Contains(errb, "--prompt") {
|
||||
t.Fatalf("stderr = %q, want a --prompt hint", errb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonStopWhenNotRunning(t *testing.T) {
|
||||
isolateDaemonPaths(t)
|
||||
code, out, _ := runDaemonCLI(t, "stop")
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("stop (not running) exit = %d, want exitSuccess", code)
|
||||
}
|
||||
if !strings.Contains(out, "not running") {
|
||||
t.Fatalf("stop output = %q, want 'not running'", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonStatusWhenNotRunning(t *testing.T) {
|
||||
isolateDaemonPaths(t)
|
||||
code, out, _ := runDaemonCLI(t, "status")
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("status (not running) exit = %d, want exitSuccess", code)
|
||||
}
|
||||
if !strings.Contains(out, "not running") {
|
||||
t.Fatalf("status output = %q, want 'not running'", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonAttachRequiresSession(t *testing.T) {
|
||||
isolateDaemonPaths(t)
|
||||
code, _, errb := runDaemonCLI(t, "attach")
|
||||
if code != exitUsage {
|
||||
t.Fatalf("attach without session exit = %d, want exitUsage", code)
|
||||
}
|
||||
if !strings.Contains(errb, "session") {
|
||||
t.Fatalf("stderr = %q, want a session hint", errb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonRunWhenNotRunning(t *testing.T) {
|
||||
isolateDaemonPaths(t)
|
||||
code, _, errb := runDaemonCLI(t, "run", "--session", "s1", "--prompt", "hello")
|
||||
if code != exitCrash {
|
||||
t.Fatalf("run (no daemon) exit = %d, want exitCrash", code)
|
||||
}
|
||||
if !strings.Contains(errb, "not running") {
|
||||
t.Fatalf("stderr = %q, want 'not running'", errb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonSubcommandsRejectExtraArgs(t *testing.T) {
|
||||
isolateDaemonPaths(t)
|
||||
cases := [][]string{
|
||||
{"stop", "oops"},
|
||||
{"status", "oops"},
|
||||
{"attach", "s1", "extra"},
|
||||
}
|
||||
for _, args := range cases {
|
||||
code, _, errb := runDaemonCLI(t, args...)
|
||||
if code != exitUsage {
|
||||
t.Fatalf("%v exit = %d, want exitUsage (reject extra args); stderr=%q", args, code, errb)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/specialist"
|
||||
"github.com/Gitlawb/zero/internal/specmode"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -163,7 +162,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if options.allowEscalation {
|
||||
registry.Register(tools.NewEscalateModelTool())
|
||||
}
|
||||
var specialistRuntime *specialist.Runtime
|
||||
var specialistRuntime *agentToolRuntime
|
||||
if shouldRegisterExecSpecialistTools(options) {
|
||||
var err error
|
||||
specialistRuntime, err = registerSpecialistTools(registry, workspaceRoot)
|
||||
@@ -704,7 +703,8 @@ func buildExecSandboxEngine(workspaceRoot string, resolved config.ResolvedConfig
|
||||
// depth — it guarantees that even a direct/programmatic caller passing a typo
|
||||
// can never WIDEN the ceiling back to the High default.
|
||||
// applyConfiguredSandboxPolicy overlays every config-sourced sandbox knob onto
|
||||
// the default policy: the autonomy ceiling and the network mode.
|
||||
// the default policy (the autonomy ceiling and the network mode), plus the
|
||||
// ZERO_SANDBOX_AUTO_ALLOW_BASH environment opt-in.
|
||||
func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfig) sandbox.Policy {
|
||||
policy = applyConfiguredAutonomyCeiling(policy, cfg.MaxAutonomy)
|
||||
if network := strings.TrimSpace(cfg.Network); network != "" {
|
||||
@@ -721,6 +721,12 @@ func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfi
|
||||
if cfg.MonitorDenials {
|
||||
policy.MonitorDenials = true
|
||||
}
|
||||
// ZERO_SANDBOX_AUTO_ALLOW_BASH opts into auto-allowing a *sandboxed* shell
|
||||
// command without a prompt — the active sandbox is the safety boundary, and
|
||||
// the engine only honors this when the shell sandbox is actually active. Like
|
||||
// the flags above it only ever turns the opt-in ON; unset/blank stays off
|
||||
// (prompt), and an explicit config opt-in is preserved.
|
||||
policy = sandbox.ApplyAutoAllowBashEnv(policy)
|
||||
return policy
|
||||
}
|
||||
|
||||
|
||||
@@ -662,6 +662,19 @@ func TestApplyConfiguredSandboxPolicyHardeningFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConfiguredSandboxPolicyHonorsAutoAllowBashEnv(t *testing.T) {
|
||||
// Unset/blank: the opt-in stays off so a sandboxed bash command still prompts.
|
||||
t.Setenv(sandbox.EnvAutoAllowBash, "")
|
||||
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); got.AutoAllowBashWhenSandboxed {
|
||||
t.Fatal("auto-allow-bash must be off when ZERO_SANDBOX_AUTO_ALLOW_BASH is unset")
|
||||
}
|
||||
// Truthy: the env opt-in is wired through the central policy builder.
|
||||
t.Setenv(sandbox.EnvAutoAllowBash, "true")
|
||||
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); !got.AutoAllowBashWhenSandboxed {
|
||||
t.Fatal("ZERO_SANDBOX_AUTO_ALLOW_BASH=true must enable AutoAllowBashWhenSandboxed")
|
||||
}
|
||||
}
|
||||
|
||||
func newSandboxTestStore(t *testing.T) *sandbox.GrantStore {
|
||||
t.Helper()
|
||||
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{
|
||||
|
||||
@@ -1094,7 +1094,8 @@ func TestResolveProviderProfileExtendedJSONAliases(t *testing.T) {
|
||||
"auth_scheme": "raw",
|
||||
"auth_header_value": "header-secret",
|
||||
"custom_headers": {"X-Zero": "1"},
|
||||
"model_id": "custom-model"
|
||||
"model_id": "custom-model",
|
||||
"parse_think_tags": true
|
||||
}]
|
||||
}`)
|
||||
|
||||
@@ -1119,6 +1120,30 @@ func TestResolveProviderProfileExtendedJSONAliases(t *testing.T) {
|
||||
if profile.CustomHeaders["X-Zero"] != "1" {
|
||||
t.Fatalf("CustomHeaders = %#v, want X-Zero header", profile.CustomHeaders)
|
||||
}
|
||||
if profile.ParseThinkTags == nil || !*profile.ParseThinkTags {
|
||||
t.Fatalf("ParseThinkTags = %#v, want true", profile.ParseThinkTags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProviderProfileParseThinkTagsFalseAlias(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "custom",
|
||||
"providers": [{
|
||||
"name": "custom",
|
||||
"provider_kind": "openai-compatible",
|
||||
"base_url": "https://custom.example/v1",
|
||||
"model_id": "custom-model",
|
||||
"parse_think_tags": false
|
||||
}]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Provider.ParseThinkTags == nil || *resolved.Provider.ParseThinkTags {
|
||||
t.Fatalf("ParseThinkTags = %#v, want explicit false", resolved.Provider.ParseThinkTags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsUnknownProviderCatalogID(t *testing.T) {
|
||||
|
||||
@@ -34,6 +34,7 @@ type ProviderProfile struct {
|
||||
AuthHeaderValue string `json:"authHeaderValue,omitempty"`
|
||||
CustomHeaders map[string]string `json:"customHeaders,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
ParseThinkTags *bool `json:"parseThinkTags,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
@@ -51,6 +52,7 @@ func HasProviderProfile(profile ProviderProfile) bool {
|
||||
strings.TrimSpace(profile.AuthHeaderValue) != "" ||
|
||||
profile.CustomHeaders != nil ||
|
||||
strings.TrimSpace(profile.Model) != "" ||
|
||||
profile.ParseThinkTags != nil ||
|
||||
strings.TrimSpace(profile.Description) != ""
|
||||
}
|
||||
|
||||
@@ -304,6 +306,8 @@ func (profile *ProviderProfile) UnmarshalJSON(data []byte) error {
|
||||
CustomHeadersSnake map[string]string `json:"custom_headers"`
|
||||
Model string `json:"model"`
|
||||
ModelID string `json:"model_id"`
|
||||
ParseThinkTags *bool `json:"parseThinkTags"`
|
||||
ParseThinkTagsSnake *bool `json:"parse_think_tags"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
@@ -325,6 +329,7 @@ func (profile *ProviderProfile) UnmarshalJSON(data []byte) error {
|
||||
profile.AuthHeaderValue = firstNonEmpty(raw.AuthHeaderValue, raw.AuthHeaderValueSnake)
|
||||
profile.CustomHeaders = firstNonNilMap(raw.CustomHeaders, raw.CustomHeadersSnake)
|
||||
profile.Model = strings.TrimSpace(firstNonEmpty(raw.Model, raw.ModelID))
|
||||
profile.ParseThinkTags = firstNonNilBool(raw.ParseThinkTags, raw.ParseThinkTagsSnake)
|
||||
profile.Description = strings.TrimSpace(raw.Description)
|
||||
return nil
|
||||
}
|
||||
@@ -348,6 +353,15 @@ func firstNonNilMap(values ...map[string]string) map[string]string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstNonNilBool(values ...*bool) *bool {
|
||||
for _, value := range values {
|
||||
if value != nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyStringMap(values map[string]string) map[string]string {
|
||||
if values == nil {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Backoff bounds. Mirrors reference-daemon-code-agent-js/worker-manager.js:
|
||||
// backoffMs(consecutive) = jitter(min(1000 * 2**consecutive, 300000)).
|
||||
const (
|
||||
backoffUnit = time.Second
|
||||
backoffCap = 5 * time.Minute
|
||||
)
|
||||
|
||||
// backoffBase returns the un-jittered exponential delay for a 1-based restart
|
||||
// count: min(1s * 2^attempt, 5m). attempt<=0 yields the unit delay.
|
||||
func backoffBase(attempt int) time.Duration {
|
||||
if attempt <= 0 {
|
||||
return backoffUnit
|
||||
}
|
||||
// Shift carefully: cap before overflow. 2^attempt * 1s exceeds the cap well
|
||||
// before any int64 overflow (cap is 5m), so clamp once it would exceed it.
|
||||
d := backoffUnit
|
||||
for i := 0; i < attempt; i++ {
|
||||
d *= 2
|
||||
if d >= backoffCap {
|
||||
return backoffCap
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// BackoffWithJitter returns backoffBase(attempt) scaled by a uniform random
|
||||
// factor in [0.5, 1.5), matching worker-manager.js jitter(base) =
|
||||
// base*(0.5 + random()). The jitter de-synchronizes simultaneous restarts so a
|
||||
// fleet of workers does not thunder back at the same instant.
|
||||
func BackoffWithJitter(attempt int) time.Duration {
|
||||
base := backoffBase(attempt)
|
||||
factor := 0.5 + rand.Float64() //nolint:gosec // jitter, not security-sensitive
|
||||
return time.Duration(float64(base) * factor)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBackoffBaseExponentialAndCap(t *testing.T) {
|
||||
cases := []struct {
|
||||
attempt int
|
||||
want time.Duration
|
||||
}{
|
||||
{0, 1 * time.Second},
|
||||
{1, 2 * time.Second},
|
||||
{2, 4 * time.Second},
|
||||
{3, 8 * time.Second},
|
||||
{20, backoffCap}, // far past the cap
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := backoffBase(tc.attempt); got != tc.want {
|
||||
t.Fatalf("backoffBase(%d) = %s, want %s", tc.attempt, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackoffWithJitterBounds(t *testing.T) {
|
||||
for attempt := 1; attempt <= 6; attempt++ {
|
||||
base := backoffBase(attempt)
|
||||
lo := time.Duration(float64(base) * 0.5)
|
||||
hi := time.Duration(float64(base) * 1.5)
|
||||
for i := 0; i < 200; i++ {
|
||||
got := BackoffWithJitter(attempt)
|
||||
if got < lo || got >= hi {
|
||||
t.Fatalf("BackoffWithJitter(%d) = %s, want in [%s,%s)", attempt, got, lo, hi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client is a control-socket client used by the `zero daemon run|attach|status|
|
||||
// stop` subcommands. It performs the version handshake on Dial.
|
||||
type Client struct {
|
||||
conn net.Conn
|
||||
}
|
||||
|
||||
// Dial connects to the daemon control socket and completes the handshake.
|
||||
func Dial(socketPath string) (*Client, error) {
|
||||
conn, err := net.DialTimeout("unix", socketPath, 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &Client{conn: conn}
|
||||
if err := c.handshake(); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// NewClientConn completes the control handshake on an already-connected
|
||||
// transport (e.g. a TLS connection the remote bridge has authenticated) and
|
||||
// returns a Client ready for Run/Attach/Status. It takes ownership of conn and
|
||||
// closes it on handshake failure.
|
||||
func NewClientConn(conn net.Conn) (*Client, error) {
|
||||
c := &Client{conn: conn}
|
||||
if err := c.handshake(); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Client) handshake() error {
|
||||
if err := WriteControl(c.conn, Ctrl{Type: CtrlHello, Version: ProtoVersion}); err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := ReadControl(c.conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if reply.Type != CtrlHelloOK {
|
||||
return fmt.Errorf("daemon: handshake rejected: %s", reply.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the connection.
|
||||
func (c *Client) Close() error { return c.conn.Close() }
|
||||
|
||||
// Run starts/routes a session and streams its stream-json output lines to onLine
|
||||
// until the session ends. It returns the session's terminal error, if any.
|
||||
func (c *Client) Run(session, cwd, prompt string, args []string, onLine func(string)) error {
|
||||
if err := WriteControl(c.conn, Ctrl{Type: CtrlRun, Session: session, Cwd: cwd, Prompt: prompt, Args: args}); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.streamLoop(onLine)
|
||||
}
|
||||
|
||||
// Attach streams a running session's output (buffered history + live) to onLine.
|
||||
func (c *Client) Attach(session string, onLine func(string)) error {
|
||||
if err := WriteControl(c.conn, Ctrl{Type: CtrlAttach, Session: session}); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.streamLoop(onLine)
|
||||
}
|
||||
|
||||
func (c *Client) streamLoop(onLine func(string)) error {
|
||||
for {
|
||||
msg, err := ReadControl(c.conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch msg.Type {
|
||||
case CtrlAck:
|
||||
// dispatch acknowledged; keep reading
|
||||
case CtrlData:
|
||||
if onLine != nil {
|
||||
onLine(msg.Line)
|
||||
}
|
||||
case CtrlEnd:
|
||||
return nil
|
||||
case CtrlError:
|
||||
return fmt.Errorf("daemon: %s", msg.Message)
|
||||
default:
|
||||
return fmt.Errorf("daemon: unexpected message %q", msg.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status requests the daemon/worker/session status report.
|
||||
func (c *Client) Status() (*StatusReport, error) {
|
||||
if err := WriteControl(c.conn, Ctrl{Type: CtrlStatus}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg, err := ReadControl(c.conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.Type == CtrlError {
|
||||
return nil, fmt.Errorf("daemon: %s", msg.Message)
|
||||
}
|
||||
if msg.Type != CtrlStatusResult || msg.Status == nil {
|
||||
return nil, fmt.Errorf("daemon: unexpected status reply %q", msg.Type)
|
||||
}
|
||||
return msg.Status, nil
|
||||
}
|
||||
|
||||
// Shutdown asks the daemon to drain and stop.
|
||||
func (c *Client) Shutdown() error {
|
||||
if err := WriteControl(c.conn, Ctrl{Type: CtrlShutdown}); err != nil {
|
||||
return err
|
||||
}
|
||||
msg, err := ReadControl(c.conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if msg.Type == CtrlError {
|
||||
return fmt.Errorf("daemon: %s", msg.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/background"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// reentrancyMarkers are the env vars zero sets on a command it has ALREADY
|
||||
// wrapped in a sandbox. The daemon MUST strip them from every worker's
|
||||
// environment: a worker that inherited them would trip zero's re-entrancy guard
|
||||
// (sandbox.IsAlreadySandboxed needs BOTH) and run with a pass-through plan —
|
||||
// i.e. UNSANDBOXED — silently defeating the sandbox. Stripping them forces each
|
||||
// worker to (re)establish its own sandbox. This is the daemon's #1 security
|
||||
// invariant: it must NOT bypass the sandbox.
|
||||
var reentrancyMarkers = []string{sandbox.EnvSandboxed, sandbox.EnvSandboxBackend}
|
||||
|
||||
// scrubWorkerEnv returns env with the sandbox re-entrancy markers removed. Every
|
||||
// other variable is preserved — including the ZERO_SANDBOX_* policy config the
|
||||
// worker reads to rebuild its own policy — so the sandbox policy is PROPAGATED,
|
||||
// not bypassed. The caller's slice is never mutated.
|
||||
func scrubWorkerEnv(env []string) []string {
|
||||
out := make([]string, 0, len(env))
|
||||
for _, kv := range env {
|
||||
drop := false
|
||||
for _, marker := range reentrancyMarkers {
|
||||
if strings.HasPrefix(kv, marker+"=") {
|
||||
drop = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !drop {
|
||||
out = append(out, kv)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// execWorker is a WorkerHandle backed by a `zero exec` child process speaking
|
||||
// stream-json on stdout.
|
||||
type execWorker struct {
|
||||
cmd *exec.Cmd
|
||||
stdout io.ReadCloser
|
||||
lines *scannerLines
|
||||
pid int
|
||||
}
|
||||
|
||||
func (w *execWorker) Stdout() Lines { return w.lines }
|
||||
func (w *execWorker) Pid() int { return w.pid }
|
||||
|
||||
func (w *execWorker) Wait() (int, error) {
|
||||
err := w.cmd.Wait()
|
||||
if err == nil {
|
||||
return 0, nil
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return exitErr.ExitCode(), nil
|
||||
}
|
||||
return -1, err
|
||||
}
|
||||
|
||||
func (w *execWorker) Kill() error {
|
||||
if w.cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
// background.TerminateProcess is the cross-platform terminate (kills the
|
||||
// process group on POSIX, taskkill /T on Windows).
|
||||
return background.TerminateProcess(w.cmd.Process.Pid)
|
||||
}
|
||||
|
||||
// scannerLines adapts a bufio.Scanner to the Lines interface.
|
||||
type scannerLines struct {
|
||||
sc *bufio.Scanner
|
||||
}
|
||||
|
||||
func (s *scannerLines) Next() (string, bool, error) {
|
||||
if s.sc.Scan() {
|
||||
return s.sc.Text(), true, nil
|
||||
}
|
||||
if err := s.sc.Err(); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// ExecLauncherConfig configures the production worker launcher.
|
||||
type ExecLauncherConfig struct {
|
||||
// Executable is the zero binary to spawn (defaults to os.Executable()).
|
||||
Executable string
|
||||
// BaseArgs are prepended before the per-session args (defaults to the headless
|
||||
// stream-json exec invocation `exec --output-format stream-json`).
|
||||
BaseArgs []string
|
||||
// Env is the base environment for workers (defaults to os.Environ()). The
|
||||
// re-entrancy markers are always scrubbed from it.
|
||||
Env []string
|
||||
}
|
||||
|
||||
// NewExecLauncher builds a Launcher that spawns `zero exec` workers which speak
|
||||
// stream-json on stdout. Each worker:
|
||||
// - runs with the re-entrancy markers SCRUBBED from its env (so it establishes
|
||||
// its own sandbox; the daemon never bypasses the sandbox), while the rest of
|
||||
// the policy config/env is propagated;
|
||||
// - is placed in its own process group (background.ConfigureChildProcessGroup)
|
||||
// so Kill / drain can terminate it and any children cleanly;
|
||||
// - has its per-session working directory (spec.Cwd) and flags (spec.Args)
|
||||
// applied. The pool does the os/exec itself and uses internal/background only
|
||||
// for process-group setup + cross-platform terminate.
|
||||
func NewExecLauncher(cfg ExecLauncherConfig) (Launcher, error) {
|
||||
exe := cfg.Executable
|
||||
if exe == "" {
|
||||
resolved, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("daemon: locate zero executable: %w", err)
|
||||
}
|
||||
exe = resolved
|
||||
}
|
||||
baseArgs := cfg.BaseArgs
|
||||
if baseArgs == nil {
|
||||
baseArgs = []string{"exec", "--output-format", "stream-json"}
|
||||
}
|
||||
baseEnv := cfg.Env
|
||||
if baseEnv == nil {
|
||||
baseEnv = os.Environ()
|
||||
}
|
||||
env := scrubWorkerEnv(baseEnv)
|
||||
|
||||
return func(ctx context.Context, spec WorkerSpec) (WorkerHandle, error) {
|
||||
args := make([]string, 0, len(baseArgs)+len(spec.Args))
|
||||
args = append(args, baseArgs...)
|
||||
args = append(args, spec.Args...)
|
||||
|
||||
cmd := exec.CommandContext(ctx, exe, args...)
|
||||
cmd.Dir = spec.Cwd
|
||||
cmd.Env = env
|
||||
cmd.Stdin = nil // the prompt is passed via flags; no streamed stdin input
|
||||
background.ConfigureChildProcessGroup(cmd)
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("daemon: worker stdout pipe: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("daemon: start worker: %w", err)
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
// Allow long stream-json lines up to the control-frame cap.
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), MaxFrameSize)
|
||||
return &execWorker{
|
||||
cmd: cmd,
|
||||
stdout: stdout,
|
||||
lines: &scannerLines{sc: scanner},
|
||||
pid: cmd.Process.Pid,
|
||||
}, nil
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func TestScrubWorkerEnvRemovesReentrancyMarkers(t *testing.T) {
|
||||
in := []string{
|
||||
"PATH=/bin",
|
||||
sandbox.EnvSandboxed + "=1",
|
||||
"ZERO_SANDBOX_AUTO_ALLOW_BASH=1", // policy config — must be PRESERVED
|
||||
sandbox.EnvSandboxBackend + "=bubblewrap",
|
||||
"HOME=/home/u",
|
||||
}
|
||||
out := scrubWorkerEnv(in)
|
||||
|
||||
for _, kv := range out {
|
||||
if strings.HasPrefix(kv, sandbox.EnvSandboxed+"=") || strings.HasPrefix(kv, sandbox.EnvSandboxBackend+"=") {
|
||||
t.Fatalf("re-entrancy marker not scrubbed: %q", kv)
|
||||
}
|
||||
}
|
||||
want := map[string]bool{"PATH=/bin": true, "ZERO_SANDBOX_AUTO_ALLOW_BASH=1": true, "HOME=/home/u": true}
|
||||
got := map[string]bool{}
|
||||
for _, kv := range out {
|
||||
got[kv] = true
|
||||
}
|
||||
for w := range want {
|
||||
if !got[w] {
|
||||
t.Fatalf("scrub dropped a non-marker var: %q", w)
|
||||
}
|
||||
}
|
||||
if len(out) != len(want) {
|
||||
t.Fatalf("scrub result = %v, want exactly %d preserved vars", out, len(want))
|
||||
}
|
||||
if len(in) != 5 {
|
||||
t.Fatal("scrubWorkerEnv must not mutate the caller's slice")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecLauncherScrubsEnvEndToEnd proves the spawned WORKER PROCESS actually
|
||||
// receives an environment without the re-entrancy markers — the security
|
||||
// invariant. It uses /bin/sh to dump the worker env, so it is POSIX-only.
|
||||
func TestExecLauncherScrubsEnvEndToEnd(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses the POSIX env dumper /bin/sh -c env")
|
||||
}
|
||||
launcher, err := NewExecLauncher(ExecLauncherConfig{
|
||||
Executable: "/bin/sh",
|
||||
BaseArgs: []string{"-c", "env"},
|
||||
Env: append([]string{"KEEP=yes"},
|
||||
sandbox.EnvSandboxed+"=1",
|
||||
sandbox.EnvSandboxBackend+"=bubblewrap"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewExecLauncher: %v", err)
|
||||
}
|
||||
h, err := launcher(context.Background(), WorkerSpec{})
|
||||
if err != nil {
|
||||
t.Fatalf("launch: %v", err)
|
||||
}
|
||||
lines := h.Stdout()
|
||||
var got []string
|
||||
for {
|
||||
line, ok, lerr := lines.Next()
|
||||
if lerr != nil {
|
||||
t.Fatalf("read worker stdout: %v", lerr)
|
||||
}
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
got = append(got, line)
|
||||
}
|
||||
if _, err := h.Wait(); err != nil {
|
||||
t.Fatalf("worker wait: %v", err)
|
||||
}
|
||||
|
||||
keepSeen := false
|
||||
for _, l := range got {
|
||||
if strings.HasPrefix(l, sandbox.EnvSandboxed+"=") || strings.HasPrefix(l, sandbox.EnvSandboxBackend+"=") {
|
||||
t.Fatalf("worker process still has a re-entrancy marker in its env: %q", l)
|
||||
}
|
||||
if l == "KEEP=yes" {
|
||||
keepSeen = true
|
||||
}
|
||||
}
|
||||
if !keepSeen {
|
||||
t.Fatalf("worker env lost a non-marker var (KEEP=yes); got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewExecLauncherDefaults(t *testing.T) {
|
||||
// With no Executable it resolves the test binary's own path (os.Executable),
|
||||
// and constructs without error.
|
||||
if _, err := NewExecLauncher(ExecLauncherConfig{}); err != nil {
|
||||
t.Fatalf("NewExecLauncher with defaults: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Single-instance lock. Mirrors reference-daemon-code-agent-js/supervisor.js's
|
||||
// lock file: a PID file created with O_EXCL. A second start fails; a STALE lock
|
||||
// left by a dead daemon (the recorded PID is no longer alive) is reclaimed so the
|
||||
// daemon recovers from an unclean shutdown without manual cleanup.
|
||||
|
||||
// ErrAlreadyRunning is returned when a live daemon already holds the lock.
|
||||
var ErrAlreadyRunning = errors.New("daemon: another instance is already running")
|
||||
|
||||
// fileLock is an acquired single-instance lock.
|
||||
type fileLock struct {
|
||||
path string
|
||||
}
|
||||
|
||||
// processAlive reports whether pid is a live process. Implemented per-platform
|
||||
// (lock_posix.go / lock_windows.go). It is a package var so tests can stub it.
|
||||
var processAlive = osProcessAlive
|
||||
|
||||
// acquireLock takes the single-instance lock at path, reclaiming a stale lock
|
||||
// whose recorded PID is dead. isAlive overrides the liveness check (tests pass a
|
||||
// stub); nil uses the real processAlive.
|
||||
func acquireLock(path string, isAlive func(pid int) bool) (*fileLock, error) {
|
||||
if isAlive == nil {
|
||||
isAlive = processAlive
|
||||
}
|
||||
// At most two passes: create, or detect-stale-then-retry once.
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
// A failed PID write would leave a malformed lock file that another
|
||||
// process reads as stale (unparsable PID) and wrongly reclaims, breaking
|
||||
// the single-instance guarantee — so on write failure, remove it and fail.
|
||||
if _, werr := fmt.Fprintf(f, "%d\n", os.Getpid()); werr != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(path)
|
||||
return nil, werr
|
||||
}
|
||||
if cerr := f.Close(); cerr != nil {
|
||||
_ = os.Remove(path)
|
||||
return nil, cerr
|
||||
}
|
||||
return &fileLock{path: path}, nil
|
||||
}
|
||||
if !errors.Is(err, fs.ErrExist) {
|
||||
return nil, err
|
||||
}
|
||||
pid, perr := readPidFile(path)
|
||||
if perr == nil && pid > 0 && isAlive(pid) {
|
||||
return nil, fmt.Errorf("%w (pid %d)", ErrAlreadyRunning, pid)
|
||||
}
|
||||
// Stale lock (dead PID or unreadable) — remove and retry once.
|
||||
if rmErr := os.Remove(path); rmErr != nil && !errors.Is(rmErr, fs.ErrNotExist) {
|
||||
return nil, rmErr
|
||||
}
|
||||
}
|
||||
return nil, ErrAlreadyRunning
|
||||
}
|
||||
|
||||
// release removes the lock file. Safe to call once.
|
||||
func (l *fileLock) release() error {
|
||||
if l == nil || l.path == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(l.path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readPidFile reads and parses the PID recorded in a lock file.
|
||||
func readPidFile(path string) (int, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// osProcessAlive reports whether pid is a live process on POSIX. Signal 0 does
|
||||
// not deliver a signal; it only checks existence/permission. ESRCH means no such
|
||||
// process (dead); EPERM means it exists but we may not signal it (alive).
|
||||
func osProcessAlive(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
err := syscall.Kill(pid, 0)
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
return errors.Is(err, syscall.EPERM)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLockSingleInstance(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "daemon.lock")
|
||||
alive := func(int) bool { return true }
|
||||
|
||||
l1, err := acquireLock(path, alive)
|
||||
if err != nil {
|
||||
t.Fatalf("first acquireLock: %v", err)
|
||||
}
|
||||
// Second acquire while the holder is "alive" must be refused.
|
||||
if _, err := acquireLock(path, alive); !errors.Is(err, ErrAlreadyRunning) {
|
||||
t.Fatalf("second acquireLock err = %v, want ErrAlreadyRunning", err)
|
||||
}
|
||||
// After release, a new acquire succeeds.
|
||||
if err := l1.release(); err != nil {
|
||||
t.Fatalf("release: %v", err)
|
||||
}
|
||||
l2, err := acquireLock(path, alive)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire after release: %v", err)
|
||||
}
|
||||
_ = l2.release()
|
||||
}
|
||||
|
||||
func TestLockStaleRecovery(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "daemon.lock")
|
||||
// Simulate a stale lock from a crashed daemon: a PID file whose process is
|
||||
// dead.
|
||||
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
|
||||
t.Fatalf("write stale lock: %v", err)
|
||||
}
|
||||
dead := func(int) bool { return false }
|
||||
l, err := acquireLock(path, dead)
|
||||
if err != nil {
|
||||
t.Fatalf("stale-lock recovery failed: %v", err)
|
||||
}
|
||||
// The lock now records OUR pid, not the stale one.
|
||||
data, _ := os.ReadFile(path)
|
||||
if strings.TrimSpace(string(data)) != strconv.Itoa(os.Getpid()) {
|
||||
t.Fatalf("reclaimed lock pid = %q, want %d", strings.TrimSpace(string(data)), os.Getpid())
|
||||
}
|
||||
_ = l.release()
|
||||
}
|
||||
|
||||
func TestLockReleaseRemovesFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "daemon.lock")
|
||||
l, err := acquireLock(path, func(int) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatalf("acquireLock: %v", err)
|
||||
}
|
||||
if err := l.release(); err != nil {
|
||||
t.Fatalf("release: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("lock file still present after release: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessAliveSelfAndDead(t *testing.T) {
|
||||
if !osProcessAlive(os.Getpid()) {
|
||||
t.Fatal("osProcessAlive(self) = false, want true")
|
||||
}
|
||||
// PID 0 / negative are never live.
|
||||
if osProcessAlive(0) || osProcessAlive(-1) {
|
||||
t.Fatal("osProcessAlive must reject non-positive pids")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
// osProcessAlive reports whether pid is a live process on Windows. It opens the
|
||||
// process for limited query access; a successful open with a non-exited status
|
||||
// means the PID is live. x/sys is already a module dependency.
|
||||
func osProcessAlive(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
var code uint32
|
||||
if err := windows.GetExitCodeProcess(handle, &code); err != nil {
|
||||
// Could not query; treat the open as proof of life (conservative: do not
|
||||
// reclaim a lock we are unsure about).
|
||||
return true
|
||||
}
|
||||
const stillActive = 259 // STILL_ACTIVE
|
||||
return code == stillActive
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DefaultDir returns the per-user directory for the daemon's runtime files:
|
||||
// $XDG_RUNTIME_DIR/zero when set (a tmpfs owned by the user on Linux), otherwise
|
||||
// ~/.zero. Mirrors supervisor.js / config.js choosing a per-user runtime dir.
|
||||
func DefaultDir() (string, error) {
|
||||
if rt := strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")); rt != "" {
|
||||
return filepath.Join(rt, "zero"), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".zero"), nil
|
||||
}
|
||||
|
||||
// Paths bundles the daemon's socket, lock, and status file paths.
|
||||
type Paths struct {
|
||||
Socket string
|
||||
Lock string
|
||||
Status string
|
||||
}
|
||||
|
||||
// DefaultPaths returns the daemon control-socket, lock, and status file paths
|
||||
// under DefaultDir.
|
||||
func DefaultPaths() (Paths, error) {
|
||||
dir, err := DefaultDir()
|
||||
if err != nil {
|
||||
return Paths{}, err
|
||||
}
|
||||
return Paths{
|
||||
Socket: filepath.Join(dir, "daemon.sock"),
|
||||
Lock: filepath.Join(dir, "daemon.lock"),
|
||||
Status: filepath.Join(dir, "daemon.status"),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Exit codes a worker may use to signal restart policy. Mirrors
|
||||
// reference-daemon-code-agent-js/worker-manager.js EXIT_TEMPFAIL/EXIT_PERMANENT.
|
||||
const (
|
||||
// ExitTempfail asks for a retry after a fixed cool-off (transient failure).
|
||||
ExitTempfail = 75
|
||||
// ExitPermanent asks the daemon to STOP retrying this work (fatal).
|
||||
ExitPermanent = 76
|
||||
)
|
||||
|
||||
// defaultPoolSize, defaultMaxAttempts and defaultKillTimeout are used when a
|
||||
// PoolOptions field is left zero.
|
||||
const (
|
||||
defaultPoolSize = 4
|
||||
defaultMaxAttempts = 5
|
||||
defaultKillTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// ErrPoolDraining is returned by Run once the pool is shutting down.
|
||||
var ErrPoolDraining = errors.New("daemon: pool is draining")
|
||||
|
||||
// ErrPermanent is returned when a worker exits ExitPermanent or the attempt cap
|
||||
// is exhausted; the request must not be retried.
|
||||
var ErrPermanent = errors.New("daemon: worker failed permanently")
|
||||
|
||||
// WorkerSpec describes the worker to launch for one session attempt.
|
||||
type WorkerSpec struct {
|
||||
Session string
|
||||
Cwd string
|
||||
// Args are the per-session exec flags (e.g. --prompt ...), parsed by the CLI
|
||||
// and appended after `exec` by the production launcher.
|
||||
Args []string
|
||||
}
|
||||
|
||||
// WorkerHandle is a launched worker process the pool supervises. The production
|
||||
// launcher wraps an exec.Cmd running `zero exec -i/-o stream-json`; tests inject
|
||||
// a fake. Stdout yields the worker's stream-json event lines.
|
||||
type WorkerHandle interface {
|
||||
Stdout() Lines
|
||||
// Wait blocks until the worker exits and returns its process exit code.
|
||||
Wait() (int, error)
|
||||
// Kill force-terminates the worker (used on drain timeout).
|
||||
Kill() error
|
||||
Pid() int
|
||||
}
|
||||
|
||||
// Lines is a stream of stream-json output lines from a worker. Next returns the
|
||||
// next line and io-EOF-style ok=false when the stream ends.
|
||||
type Lines interface {
|
||||
Next() (line string, ok bool, err error)
|
||||
}
|
||||
|
||||
// Launcher spawns a worker for a single attempt. It must apply the sandbox/env
|
||||
// policy (the production launcher scrubs the re-entrancy markers so the worker
|
||||
// re-establishes its own sandbox — see newExecLauncher).
|
||||
type Launcher func(ctx context.Context, spec WorkerSpec) (WorkerHandle, error)
|
||||
|
||||
// PoolOptions configures a Pool. Zero fields take documented defaults.
|
||||
type PoolOptions struct {
|
||||
// Size is the max number of concurrent workers (lease slots). Sessions beyond
|
||||
// this queue until a slot frees.
|
||||
Size int
|
||||
// Launcher spawns a worker. Required.
|
||||
Launcher Launcher
|
||||
// MaxAttempts caps the total attempts per request (first + retries). When
|
||||
// exhausted, Run returns ErrPermanent (mirrors capping consecutive restarts).
|
||||
MaxAttempts int
|
||||
// KillTimeout bounds graceful termination before SIGKILL-equivalent on drain.
|
||||
KillTimeout time.Duration
|
||||
// Backoff returns the delay before retry attempt n (1-based crash count).
|
||||
// Defaults to BackoffWithJitter; tests inject a deterministic function.
|
||||
Backoff func(attempt int) time.Duration
|
||||
// TempfailDelay is the cool-off after an ExitTempfail exit. Defaults to 30s.
|
||||
TempfailDelay time.Duration
|
||||
// Log, when set, receives one line per lifecycle event.
|
||||
Log func(string)
|
||||
}
|
||||
|
||||
// Pool is a bounded, self-healing worker pool. A crashed worker never takes down
|
||||
// the pool: Run retries on a fresh worker with backoff up to MaxAttempts, and an
|
||||
// ExitPermanent worker stops retries. Drain stops new work and kills stragglers.
|
||||
type Pool struct {
|
||||
opts PoolOptions
|
||||
slots chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
draining bool
|
||||
active map[int]WorkerHandle // pid -> handle, for drain/kill + status
|
||||
nextID int
|
||||
|
||||
drainOnce sync.Once
|
||||
drained chan struct{}
|
||||
}
|
||||
|
||||
// workerStat tracks one in-flight request's restart count (local to Run).
|
||||
type workerStat struct {
|
||||
id int
|
||||
restarts int
|
||||
}
|
||||
|
||||
// NewPool builds a pool from opts, filling defaults.
|
||||
func NewPool(opts PoolOptions) (*Pool, error) {
|
||||
if opts.Launcher == nil {
|
||||
return nil, errors.New("daemon: pool requires a Launcher")
|
||||
}
|
||||
if opts.Size <= 0 {
|
||||
opts.Size = defaultPoolSize
|
||||
}
|
||||
if opts.MaxAttempts <= 0 {
|
||||
opts.MaxAttempts = defaultMaxAttempts
|
||||
}
|
||||
if opts.KillTimeout <= 0 {
|
||||
opts.KillTimeout = defaultKillTimeout
|
||||
}
|
||||
if opts.TempfailDelay <= 0 {
|
||||
opts.TempfailDelay = 30 * time.Second
|
||||
}
|
||||
if opts.Backoff == nil {
|
||||
opts.Backoff = BackoffWithJitter
|
||||
}
|
||||
return &Pool{
|
||||
opts: opts,
|
||||
slots: make(chan struct{}, opts.Size),
|
||||
active: map[int]WorkerHandle{},
|
||||
drained: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Size returns the configured max concurrency.
|
||||
func (p *Pool) Size() int { return p.opts.Size }
|
||||
|
||||
func (p *Pool) logf(format string, args ...any) {
|
||||
if p.opts.Log != nil {
|
||||
p.opts.Log(fmt.Sprintf(format, args...))
|
||||
}
|
||||
}
|
||||
|
||||
// acquire takes a lease slot, queuing until one frees or ctx is cancelled / the
|
||||
// pool drains.
|
||||
func (p *Pool) acquire(ctx context.Context) error {
|
||||
p.mu.Lock()
|
||||
draining := p.draining
|
||||
p.mu.Unlock()
|
||||
if draining {
|
||||
return ErrPoolDraining
|
||||
}
|
||||
select {
|
||||
case p.slots <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-p.drained:
|
||||
return ErrPoolDraining
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pool) release() { <-p.slots }
|
||||
|
||||
// QueueDepth reports how many lease slots are currently in use.
|
||||
func (p *Pool) QueueDepth() int { return len(p.slots) }
|
||||
|
||||
// Sink receives a worker's stream-json output lines for one request.
|
||||
type Sink interface {
|
||||
Line(line string)
|
||||
}
|
||||
|
||||
// Run leases a worker slot and dispatches spec to a worker, streaming its
|
||||
// stream-json lines to sink. It is at-least-once with bounded retries: a worker
|
||||
// that crashes (non-zero, non-permanent) is retried on a fresh worker after a
|
||||
// backoff; ExitPermanent or exhausting MaxAttempts returns ErrPermanent. Run
|
||||
// queues when all slots are busy. The returned int is the final worker exit code.
|
||||
func (p *Pool) Run(ctx context.Context, spec WorkerSpec, sink Sink) (int, error) {
|
||||
if err := p.acquire(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer p.release()
|
||||
|
||||
// Lease hook: a sink that tracks queue->running state is notified the instant
|
||||
// a worker slot is leased (mirrors lease.js acquiring a worker for a session).
|
||||
if s, ok := sink.(interface{ Started() }); ok {
|
||||
s.Started()
|
||||
}
|
||||
|
||||
stat := p.newStat()
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= p.opts.MaxAttempts; attempt++ {
|
||||
if p.isDraining() {
|
||||
return 0, ErrPoolDraining
|
||||
}
|
||||
code, err := p.runOnce(ctx, stat.id, spec, sink)
|
||||
switch {
|
||||
case err != nil:
|
||||
lastErr = err
|
||||
if ctx.Err() != nil {
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
p.logf("worker %d launch/run error: %v", stat.id, err)
|
||||
case code == 0:
|
||||
return 0, nil // clean success
|
||||
case code == ExitPermanent:
|
||||
p.logf("worker %d exited permanently (code=%d) — not retrying", stat.id, code)
|
||||
return code, ErrPermanent
|
||||
case code == ExitTempfail:
|
||||
lastErr = fmt.Errorf("worker %d tempfail (code=%d)", stat.id, code)
|
||||
p.logf("worker %d tempfail — retry after %s", stat.id, p.opts.TempfailDelay)
|
||||
if !p.sleep(ctx, p.opts.TempfailDelay) {
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
continue // tempfail retries do not count against the crash backoff
|
||||
default:
|
||||
lastErr = fmt.Errorf("worker %d exited code=%d", stat.id, code)
|
||||
}
|
||||
|
||||
if attempt == p.opts.MaxAttempts {
|
||||
break
|
||||
}
|
||||
stat.restarts++
|
||||
delay := p.opts.Backoff(stat.restarts)
|
||||
p.logf("worker %d restart %d after backoff %s", stat.id, stat.restarts, delay)
|
||||
if !p.sleep(ctx, delay) {
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = ErrPermanent
|
||||
}
|
||||
return ExitPermanent, fmt.Errorf("%w: %v", ErrPermanent, lastErr)
|
||||
}
|
||||
|
||||
// runOnce launches a single worker, pumps its output to sink, and returns its
|
||||
// exit code. The worker handle is tracked so Drain can kill it.
|
||||
func (p *Pool) runOnce(ctx context.Context, id int, spec WorkerSpec, sink Sink) (int, error) {
|
||||
handle, err := p.opts.Launcher(ctx, spec)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
p.track(handle)
|
||||
defer p.untrack(handle)
|
||||
|
||||
// Pump stdout lines until the stream ends.
|
||||
lines := handle.Stdout()
|
||||
for {
|
||||
line, ok, lerr := lines.Next()
|
||||
if lerr != nil {
|
||||
break
|
||||
}
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if sink != nil {
|
||||
sink.Line(line)
|
||||
}
|
||||
}
|
||||
return handle.Wait()
|
||||
}
|
||||
|
||||
func (p *Pool) newStat() *workerStat {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.nextID++
|
||||
return &workerStat{id: p.nextID}
|
||||
}
|
||||
|
||||
func (p *Pool) track(h WorkerHandle) {
|
||||
p.mu.Lock()
|
||||
p.active[h.Pid()] = h
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *Pool) untrack(h WorkerHandle) {
|
||||
p.mu.Lock()
|
||||
delete(p.active, h.Pid())
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *Pool) isDraining() bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.draining
|
||||
}
|
||||
|
||||
// sleep waits d, returning false if ctx is cancelled first. A non-positive d
|
||||
// returns immediately.
|
||||
func (p *Pool) sleep(ctx context.Context, d time.Duration) bool {
|
||||
if d <= 0 {
|
||||
return ctx.Err() == nil
|
||||
}
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Drain stops accepting new work, gives in-flight workers a grace window
|
||||
// (KillTimeout) to finish on their own, then force-kills any straggler. It
|
||||
// returns as soon as the pool is idle (graceful) or the window elapses. Safe to
|
||||
// call once; subsequent calls are no-ops.
|
||||
func (p *Pool) Drain() {
|
||||
p.drainOnce.Do(func() {
|
||||
p.mu.Lock()
|
||||
p.draining = true
|
||||
p.mu.Unlock()
|
||||
close(p.drained)
|
||||
|
||||
// Grace window: poll until idle or the deadline.
|
||||
deadline := time.Now().Add(p.opts.KillTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
p.mu.Lock()
|
||||
n := len(p.active)
|
||||
p.mu.Unlock()
|
||||
if n == 0 {
|
||||
return // all workers drained gracefully
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Timed out: force-kill remaining workers.
|
||||
p.mu.Lock()
|
||||
handles := make([]WorkerHandle, 0, len(p.active))
|
||||
for _, h := range p.active {
|
||||
handles = append(handles, h)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
for _, h := range handles {
|
||||
p.logf("drain: killing straggler worker pid=%d", h.Pid())
|
||||
_ = h.Kill()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// WorkerStats returns a snapshot of the currently-active (busy) workers, bounded
|
||||
// by the pool size. The on-demand pool keeps no idle workers, so an empty result
|
||||
// means the pool is idle.
|
||||
func (p *Pool) WorkerStats() []WorkerStatus {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
out := make([]WorkerStatus, 0, len(p.active))
|
||||
for pid := range p.active {
|
||||
out = append(out, WorkerStatus{PID: pid, State: "busy"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- test doubles ---------------------------------------------------------
|
||||
|
||||
type fakeLines struct {
|
||||
lines []string
|
||||
i int
|
||||
}
|
||||
|
||||
func (f *fakeLines) Next() (string, bool, error) {
|
||||
if f.i >= len(f.lines) {
|
||||
return "", false, nil
|
||||
}
|
||||
l := f.lines[f.i]
|
||||
f.i++
|
||||
return l, true, nil
|
||||
}
|
||||
|
||||
type fakeWorker struct {
|
||||
pid int
|
||||
out []string
|
||||
exitCode int
|
||||
killed int32
|
||||
waitCh chan struct{} // when non-nil, Wait blocks until closed (drain tests)
|
||||
}
|
||||
|
||||
func (w *fakeWorker) Stdout() Lines { return &fakeLines{lines: w.out} }
|
||||
func (w *fakeWorker) Wait() (int, error) {
|
||||
if w.waitCh != nil {
|
||||
<-w.waitCh
|
||||
}
|
||||
return w.exitCode, nil
|
||||
}
|
||||
func (w *fakeWorker) Kill() error {
|
||||
atomic.StoreInt32(&w.killed, 1)
|
||||
if w.waitCh != nil {
|
||||
select {
|
||||
case <-w.waitCh:
|
||||
default:
|
||||
close(w.waitCh)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (w *fakeWorker) Pid() int { return w.pid }
|
||||
|
||||
// seqLauncher hands out the given workers in order, then errors.
|
||||
func seqLauncher(workers ...*fakeWorker) (Launcher, *int32) {
|
||||
var calls int32
|
||||
l := func(_ context.Context, _ WorkerSpec) (WorkerHandle, error) {
|
||||
n := atomic.AddInt32(&calls, 1)
|
||||
idx := int(n) - 1
|
||||
if idx >= len(workers) {
|
||||
return nil, errors.New("no more fake workers")
|
||||
}
|
||||
return workers[idx], nil
|
||||
}
|
||||
return l, &calls
|
||||
}
|
||||
|
||||
type collectSink struct {
|
||||
mu sync.Mutex
|
||||
lines []string
|
||||
}
|
||||
|
||||
func (c *collectSink) Line(line string) {
|
||||
c.mu.Lock()
|
||||
c.lines = append(c.lines, line)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// --- tests ----------------------------------------------------------------
|
||||
|
||||
func TestPoolRunStreamsAndSucceeds(t *testing.T) {
|
||||
launcher, calls := seqLauncher(&fakeWorker{pid: 1, out: []string{"a", "b"}, exitCode: 0})
|
||||
pool, err := NewPool(PoolOptions{Size: 2, Launcher: launcher})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPool: %v", err)
|
||||
}
|
||||
sink := &collectSink{}
|
||||
code, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, sink)
|
||||
if err != nil || code != 0 {
|
||||
t.Fatalf("Run = (%d,%v), want (0,nil)", code, err)
|
||||
}
|
||||
if len(sink.lines) != 2 || sink.lines[0] != "a" || sink.lines[1] != "b" {
|
||||
t.Fatalf("sink lines = %v, want [a b]", sink.lines)
|
||||
}
|
||||
if *calls != 1 {
|
||||
t.Fatalf("launcher calls = %d, want 1", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolRunRetriesWithBackoff(t *testing.T) {
|
||||
launcher, calls := seqLauncher(
|
||||
&fakeWorker{pid: 1, exitCode: 1}, // crash
|
||||
&fakeWorker{pid: 2, exitCode: 1}, // crash
|
||||
&fakeWorker{pid: 3, exitCode: 0, out: []string{"ok"}}, // success
|
||||
)
|
||||
var backoffArgs []int
|
||||
pool, _ := NewPool(PoolOptions{
|
||||
Size: 1, Launcher: launcher, MaxAttempts: 5,
|
||||
Backoff: func(attempt int) time.Duration { backoffArgs = append(backoffArgs, attempt); return 0 },
|
||||
})
|
||||
sink := &collectSink{}
|
||||
code, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, sink)
|
||||
if err != nil || code != 0 {
|
||||
t.Fatalf("Run = (%d,%v), want (0,nil)", code, err)
|
||||
}
|
||||
if *calls != 3 {
|
||||
t.Fatalf("launcher calls = %d, want 3 (2 crashes + success)", *calls)
|
||||
}
|
||||
// Backoff invoked between attempts with an increasing 1-based restart count.
|
||||
if len(backoffArgs) != 2 || backoffArgs[0] != 1 || backoffArgs[1] != 2 {
|
||||
t.Fatalf("backoff attempts = %v, want [1 2]", backoffArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolRunPermanentStopsImmediately(t *testing.T) {
|
||||
launcher, calls := seqLauncher(&fakeWorker{pid: 1, exitCode: ExitPermanent})
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher, Backoff: func(int) time.Duration { return 0 }})
|
||||
code, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, &collectSink{})
|
||||
if !errors.Is(err, ErrPermanent) {
|
||||
t.Fatalf("Run err = %v, want ErrPermanent", err)
|
||||
}
|
||||
if code != ExitPermanent {
|
||||
t.Fatalf("Run code = %d, want %d", code, ExitPermanent)
|
||||
}
|
||||
if *calls != 1 {
|
||||
t.Fatalf("permanent exit must not retry; launcher calls = %d, want 1", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolRunCapExhausted(t *testing.T) {
|
||||
launcher, calls := seqLauncher(
|
||||
&fakeWorker{pid: 1, exitCode: 1},
|
||||
&fakeWorker{pid: 2, exitCode: 1},
|
||||
&fakeWorker{pid: 3, exitCode: 1},
|
||||
)
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher, MaxAttempts: 3, Backoff: func(int) time.Duration { return 0 }})
|
||||
_, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, &collectSink{})
|
||||
if !errors.Is(err, ErrPermanent) {
|
||||
t.Fatalf("Run err = %v, want ErrPermanent after cap", err)
|
||||
}
|
||||
if *calls != 3 {
|
||||
t.Fatalf("launcher calls = %d, want 3 (== MaxAttempts)", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolRunTempfailRetries(t *testing.T) {
|
||||
launcher, calls := seqLauncher(
|
||||
&fakeWorker{pid: 1, exitCode: ExitTempfail},
|
||||
&fakeWorker{pid: 2, exitCode: 0, out: []string{"ok"}},
|
||||
)
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher, TempfailDelay: time.Millisecond})
|
||||
code, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, &collectSink{})
|
||||
if err != nil || code != 0 {
|
||||
t.Fatalf("Run = (%d,%v), want (0,nil)", code, err)
|
||||
}
|
||||
if *calls != 2 {
|
||||
t.Fatalf("launcher calls = %d, want 2 (tempfail + success)", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolQueuesWhenFull(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
first := &fakeWorker{pid: 1, waitCh: block}
|
||||
second := &fakeWorker{pid: 2, exitCode: 0}
|
||||
launcher, _ := seqLauncher(first, second)
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
|
||||
started := make(chan struct{})
|
||||
go func() {
|
||||
close(started)
|
||||
_, _ = pool.Run(context.Background(), WorkerSpec{Session: "a"}, &collectSink{})
|
||||
}()
|
||||
<-started
|
||||
// Wait until the first run holds the only slot.
|
||||
waitFor(t, func() bool { return pool.QueueDepth() == 1 })
|
||||
|
||||
secondDone := make(chan struct{})
|
||||
go func() {
|
||||
_, _ = pool.Run(context.Background(), WorkerSpec{Session: "b"}, &collectSink{})
|
||||
close(secondDone)
|
||||
}()
|
||||
// The second run must NOT complete while the slot is held.
|
||||
select {
|
||||
case <-secondDone:
|
||||
t.Fatal("second run completed while the only slot was busy (no queueing)")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
// Free the slot; the queued run now proceeds.
|
||||
close(block)
|
||||
select {
|
||||
case <-secondDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("queued run did not proceed after slot freed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolDrainKillsStraggler(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
straggler := &fakeWorker{pid: 1, waitCh: block}
|
||||
launcher, _ := seqLauncher(straggler)
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher, MaxAttempts: 1, KillTimeout: 10 * time.Millisecond})
|
||||
|
||||
runDone := make(chan struct{})
|
||||
go func() {
|
||||
_, _ = pool.Run(context.Background(), WorkerSpec{Session: "a"}, &collectSink{})
|
||||
close(runDone)
|
||||
}()
|
||||
waitFor(t, func() bool { return pool.QueueDepth() == 1 })
|
||||
|
||||
pool.Drain() // KillTimeout elapses, straggler is force-killed
|
||||
if atomic.LoadInt32(&straggler.killed) != 1 {
|
||||
t.Fatal("Drain must kill a straggler still running after KillTimeout")
|
||||
}
|
||||
select {
|
||||
case <-runDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("run did not finish after drain kill")
|
||||
}
|
||||
|
||||
// A new Run after drain is refused.
|
||||
if _, err := pool.Run(context.Background(), WorkerSpec{Session: "b"}, &collectSink{}); !errors.Is(err, ErrPoolDraining) {
|
||||
t.Fatalf("Run after drain err = %v, want ErrPoolDraining", err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatal("condition not met within timeout")
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Package daemon implements zero's long-running daemon/server mode: a local
|
||||
// control server that supervises a pool of headless `zero exec` worker processes
|
||||
// and routes multiple agent sessions to them over an owner-only local socket.
|
||||
//
|
||||
// It reuses zero's existing building blocks rather than inventing new ones:
|
||||
// - internal/background : child-process group setup + cross-platform terminate.
|
||||
// - internal/streamjson : the line-based agent event protocol on worker stdio.
|
||||
// - internal/cli (exec) : a worker is a `zero exec -i/-o stream-json` process.
|
||||
//
|
||||
// The control plane (this file) is a small framed codec mirrored from the
|
||||
// reference daemon's protocol.js: a 4-byte big-endian length prefix, a 1-byte
|
||||
// frame kind, then a JSON control payload, with a 1 MiB frame cap and a
|
||||
// version-negotiation handshake. The agent event stream itself stays stream-json.
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Wire constants. Mirrors reference-daemon-code-agent-js/protocol.js
|
||||
// (FRAME_DATA/FRAME_CTRL, HEADER_SIZE, MAX_FRAME_SIZE, PROTO_VERSION).
|
||||
const (
|
||||
// ProtoVersion is the control-protocol version advertised in the handshake.
|
||||
ProtoVersion = 1
|
||||
// MaxFrameSize caps a single control frame payload at 1 MiB. A larger
|
||||
// advertised length is rejected before any allocation (fail closed).
|
||||
MaxFrameSize = 1 << 20
|
||||
// frameHeaderSize is 4 bytes big-endian length + 1 byte kind.
|
||||
frameHeaderSize = 5
|
||||
)
|
||||
|
||||
// FrameKind tags a frame's payload. Mirrors protocol.js FRAME_DATA/FRAME_CTRL.
|
||||
type FrameKind uint8
|
||||
|
||||
const (
|
||||
// KindData carries an opaque payload (e.g. a stream-json line). Reserved for
|
||||
// future use; the control plane uses KindCtrl.
|
||||
KindData FrameKind = 0
|
||||
// KindCtrl carries a JSON-encoded control message (see Ctrl).
|
||||
KindCtrl FrameKind = 1
|
||||
)
|
||||
|
||||
// ErrFrameTooLarge is returned when a frame's advertised payload length exceeds
|
||||
// MaxFrameSize. The reader rejects it without allocating the buffer.
|
||||
var ErrFrameTooLarge = errors.New("daemon: control frame exceeds size cap")
|
||||
|
||||
// ErrUnknownFrameKind is returned for a frame whose kind byte is not recognized.
|
||||
var ErrUnknownFrameKind = errors.New("daemon: unknown control frame kind")
|
||||
|
||||
// WriteFrame writes a single framed message: [uint32BE len][uint8 kind][payload].
|
||||
// It rejects an over-cap payload before writing anything so a buggy/hostile caller
|
||||
// cannot emit a frame a conforming reader would refuse.
|
||||
func WriteFrame(w io.Writer, kind FrameKind, payload []byte) error {
|
||||
if len(payload) > MaxFrameSize {
|
||||
return fmt.Errorf("%w (%d > %d)", ErrFrameTooLarge, len(payload), MaxFrameSize)
|
||||
}
|
||||
header := make([]byte, frameHeaderSize)
|
||||
binary.BigEndian.PutUint32(header[:4], uint32(len(payload)))
|
||||
header[4] = byte(kind)
|
||||
if _, err := w.Write(header); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := w.Write(payload)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReadFrame reads a single framed message. It validates the advertised length
|
||||
// against MaxFrameSize BEFORE allocating, so an oversize or hostile frame is
|
||||
// rejected (ErrFrameTooLarge) rather than triggering a huge allocation. An
|
||||
// unrecognized kind byte yields ErrUnknownFrameKind after the payload is drained
|
||||
// so the stream stays in sync.
|
||||
func ReadFrame(r io.Reader) (FrameKind, []byte, error) {
|
||||
header := make([]byte, frameHeaderSize)
|
||||
if _, err := io.ReadFull(r, header); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
length := binary.BigEndian.Uint32(header[:4])
|
||||
if length > MaxFrameSize {
|
||||
return 0, nil, fmt.Errorf("%w (%d > %d)", ErrFrameTooLarge, length, MaxFrameSize)
|
||||
}
|
||||
kind := FrameKind(header[4])
|
||||
payload := make([]byte, length)
|
||||
if length > 0 {
|
||||
if _, err := io.ReadFull(r, payload); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
}
|
||||
if kind != KindData && kind != KindCtrl {
|
||||
return kind, nil, ErrUnknownFrameKind
|
||||
}
|
||||
return kind, payload, nil
|
||||
}
|
||||
|
||||
// CtrlType is the discriminator for a control message.
|
||||
type CtrlType string
|
||||
|
||||
const (
|
||||
// CtrlHello is the client's opening handshake; Version is the highest control
|
||||
// version it speaks.
|
||||
CtrlHello CtrlType = "hello"
|
||||
// CtrlHelloOK is the daemon's handshake reply with the negotiated Version.
|
||||
CtrlHelloOK CtrlType = "hello_ok"
|
||||
// CtrlRun asks the daemon to create/route a session and stream its output.
|
||||
CtrlRun CtrlType = "run"
|
||||
// CtrlAttach asks the daemon to attach the client to a running session's stream.
|
||||
CtrlAttach CtrlType = "attach"
|
||||
// CtrlStatus requests daemon/worker/session status.
|
||||
CtrlStatus CtrlType = "status"
|
||||
// CtrlStatusResult carries the status payload (see StatusReport in Status).
|
||||
CtrlStatusResult CtrlType = "status_result"
|
||||
// CtrlData carries one stream-json line of agent output back to the client.
|
||||
CtrlData CtrlType = "data"
|
||||
// CtrlAck acknowledges receipt/dispatch of a request (at-least-once dispatch).
|
||||
CtrlAck CtrlType = "ack"
|
||||
// CtrlEnd marks the end of a session's stream.
|
||||
CtrlEnd CtrlType = "end"
|
||||
// CtrlShutdown asks the daemon to drain and stop.
|
||||
CtrlShutdown CtrlType = "shutdown"
|
||||
// CtrlError carries a human-readable error for the client.
|
||||
CtrlError CtrlType = "error"
|
||||
)
|
||||
|
||||
// Ctrl is a control message carried in a KindCtrl frame as JSON. Unused fields
|
||||
// are omitted so frames stay small.
|
||||
type Ctrl struct {
|
||||
Type CtrlType `json:"type"`
|
||||
Version int `json:"version,omitempty"`
|
||||
Session string `json:"session,omitempty"`
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
// Line is one stream-json event line (for CtrlData).
|
||||
Line string `json:"line,omitempty"`
|
||||
// Message is a human-readable detail (for CtrlError / CtrlAck).
|
||||
Message string `json:"message,omitempty"`
|
||||
// Status is populated on CtrlStatusResult.
|
||||
Status *StatusReport `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// WriteControl marshals a control message into a KindCtrl frame.
|
||||
func WriteControl(w io.Writer, msg Ctrl) error {
|
||||
payload, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteFrame(w, KindCtrl, payload)
|
||||
}
|
||||
|
||||
// ReadControl reads the next frame and decodes it as a control message. A
|
||||
// non-control frame yields ErrUnknownFrameKind so the caller fails closed.
|
||||
func ReadControl(r io.Reader) (Ctrl, error) {
|
||||
kind, payload, err := ReadFrame(r)
|
||||
if err != nil {
|
||||
return Ctrl{}, err
|
||||
}
|
||||
if kind != KindCtrl {
|
||||
return Ctrl{}, ErrUnknownFrameKind
|
||||
}
|
||||
var msg Ctrl
|
||||
if err := json.Unmarshal(payload, &msg); err != nil {
|
||||
return Ctrl{}, fmt.Errorf("daemon: decode control message: %w", err)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// NegotiateVersion picks the protocol version both ends can speak: the lower of
|
||||
// the client's advertised version and ProtoVersion. A non-positive client
|
||||
// version is invalid and yields ok=false so the daemon can reject the connection.
|
||||
func NegotiateVersion(clientVersion int) (int, bool) {
|
||||
if clientVersion <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
if clientVersion < ProtoVersion {
|
||||
return clientVersion, true
|
||||
}
|
||||
return ProtoVersion, true
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFrameRoundTrip(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
payloads := [][]byte{[]byte("hello"), {}, []byte(strings.Repeat("x", 4096))}
|
||||
for _, p := range payloads {
|
||||
if err := WriteFrame(&buf, KindCtrl, p); err != nil {
|
||||
t.Fatalf("WriteFrame: %v", err)
|
||||
}
|
||||
}
|
||||
for i, want := range payloads {
|
||||
kind, got, err := ReadFrame(&buf)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFrame[%d]: %v", i, err)
|
||||
}
|
||||
if kind != KindCtrl {
|
||||
t.Fatalf("frame[%d] kind = %d, want KindCtrl", i, kind)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("frame[%d] payload = %q, want %q", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlRoundTrip(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
msg := Ctrl{Type: CtrlRun, Session: "s1", Cwd: "/tmp", Args: []string{"--prompt", "hi"}}
|
||||
if err := WriteControl(&buf, msg); err != nil {
|
||||
t.Fatalf("WriteControl: %v", err)
|
||||
}
|
||||
got, err := ReadControl(&buf)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadControl: %v", err)
|
||||
}
|
||||
if got.Type != CtrlRun || got.Session != "s1" || got.Cwd != "/tmp" || len(got.Args) != 2 {
|
||||
t.Fatalf("round-trip mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFrameRejectsOversize(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := WriteFrame(&buf, KindCtrl, make([]byte, MaxFrameSize+1))
|
||||
if !errors.Is(err, ErrFrameTooLarge) {
|
||||
t.Fatalf("WriteFrame oversize err = %v, want ErrFrameTooLarge", err)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("oversize WriteFrame must not emit any bytes, wrote %d", buf.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFrameRejectsOversizeBeforeAlloc(t *testing.T) {
|
||||
// A header advertising a 4 GiB payload must be rejected without reading or
|
||||
// allocating that payload.
|
||||
header := make([]byte, frameHeaderSize)
|
||||
binary.BigEndian.PutUint32(header[:4], MaxFrameSize+1)
|
||||
header[4] = byte(KindCtrl)
|
||||
_, _, err := ReadFrame(bytes.NewReader(header))
|
||||
if !errors.Is(err, ErrFrameTooLarge) {
|
||||
t.Fatalf("ReadFrame oversize err = %v, want ErrFrameTooLarge", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFrameUnknownKind(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
// Hand-write a frame with an unknown kind byte (7) and a small payload.
|
||||
header := make([]byte, frameHeaderSize)
|
||||
binary.BigEndian.PutUint32(header[:4], 2)
|
||||
header[4] = 7
|
||||
buf.Write(header)
|
||||
buf.Write([]byte("ab"))
|
||||
_, _, err := ReadFrame(&buf)
|
||||
if !errors.Is(err, ErrUnknownFrameKind) {
|
||||
t.Fatalf("unknown kind err = %v, want ErrUnknownFrameKind", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFrameTruncated(t *testing.T) {
|
||||
// Header promises 10 bytes but only 3 follow → unexpected EOF.
|
||||
var buf bytes.Buffer
|
||||
header := make([]byte, frameHeaderSize)
|
||||
binary.BigEndian.PutUint32(header[:4], 10)
|
||||
header[4] = byte(KindCtrl)
|
||||
buf.Write(header)
|
||||
buf.Write([]byte("abc"))
|
||||
_, _, err := ReadFrame(&buf)
|
||||
if err == nil || errors.Is(err, ErrFrameTooLarge) {
|
||||
t.Fatalf("truncated frame err = %v, want a read error", err)
|
||||
}
|
||||
if !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("truncated frame err = %v, want io.ErrUnexpectedEOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNegotiateVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
client int
|
||||
want int
|
||||
wantOK bool
|
||||
}{
|
||||
{client: 1, want: 1, wantOK: true},
|
||||
{client: 99, want: ProtoVersion, wantOK: true}, // clamp down to ours
|
||||
{client: 0, want: 0, wantOK: false},
|
||||
{client: -3, want: 0, wantOK: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := NegotiateVersion(tc.client)
|
||||
if got != tc.want || ok != tc.wantOK {
|
||||
t.Fatalf("NegotiateVersion(%d) = (%d,%v), want (%d,%v)", tc.client, got, ok, tc.want, tc.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Package remote adds an OPT-IN, TLS-only network bridge on top of zero's local
|
||||
// daemon (internal/daemon). It lets a remote client drive the SAME daemon
|
||||
// SessionManager/Pool over the network, behind bearer-token authentication and a
|
||||
// protocol-version floor. The local Unix-socket daemon is unchanged and remains
|
||||
// the default; nothing here activates unless `zero daemon serve-remote` is run.
|
||||
//
|
||||
// Security (fail closed):
|
||||
// - TLS is mandatory; the bridge refuses to serve without a cert/key.
|
||||
// - A connection is authenticated BEFORE any control frame is dispatched; a
|
||||
// failed/absent token closes the connection (after a small backoff) and no
|
||||
// session frame is ever processed.
|
||||
// - Tokens are compared in constant time and are never logged.
|
||||
// - The control-frame size cap and version negotiation from internal/daemon are
|
||||
// reused unchanged; oversize/old-version handshakes are rejected.
|
||||
// - A remote-driven session runs through the same daemon dispatch, so it stays
|
||||
// under the same sandbox + risk model — remote never bypasses local controls.
|
||||
package remote
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/daemon"
|
||||
)
|
||||
|
||||
// Env vars the bridge reads for its bearer token.
|
||||
const (
|
||||
EnvToken = "ZERO_DAEMON_REMOTE_TOKEN"
|
||||
EnvTokenFile = "ZERO_DAEMON_REMOTE_TOKEN_FILE"
|
||||
)
|
||||
|
||||
// ErrUnauthorized is returned when a token does not match.
|
||||
var ErrUnauthorized = errors.New("remote: unauthorized")
|
||||
|
||||
// Authenticator verifies a bearer token presented by a remote client.
|
||||
type Authenticator interface {
|
||||
Authenticate(token string) error
|
||||
}
|
||||
|
||||
// TokenAuthenticator compares a presented token against a fixed secret in
|
||||
// constant time.
|
||||
type TokenAuthenticator struct {
|
||||
token string
|
||||
}
|
||||
|
||||
// NewTokenAuthenticator builds a token authenticator, refusing an empty secret
|
||||
// (fail closed — a bridge must never accept everyone).
|
||||
func NewTokenAuthenticator(token string) (*TokenAuthenticator, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil, errors.New("remote: a non-empty auth token is required")
|
||||
}
|
||||
return &TokenAuthenticator{token: token}, nil
|
||||
}
|
||||
|
||||
// Authenticate reports nil when token matches the configured secret.
|
||||
func (a *TokenAuthenticator) Authenticate(token string) error {
|
||||
if subtle.ConstantTimeCompare([]byte(token), []byte(a.token)) == 1 {
|
||||
return nil
|
||||
}
|
||||
return ErrUnauthorized
|
||||
}
|
||||
|
||||
// TokenFromEnv resolves the bridge token from EnvToken, or a file named by
|
||||
// EnvTokenFile. It never logs the token.
|
||||
func TokenFromEnv() (string, error) {
|
||||
if t := strings.TrimSpace(os.Getenv(EnvToken)); t != "" {
|
||||
return t, nil
|
||||
}
|
||||
if file := strings.TrimSpace(os.Getenv(EnvTokenFile)); file != "" {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("remote: read token file: %w", err)
|
||||
}
|
||||
t := strings.TrimSpace(string(data))
|
||||
if t == "" {
|
||||
return "", errors.New("remote: token file is empty")
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
return "", fmt.Errorf("remote: set %s or %s", EnvToken, EnvTokenFile)
|
||||
}
|
||||
|
||||
// Attestation is an optional post-token hook (e.g. workload attestation). The
|
||||
// default is a no-op; a deployment can supply a stricter implementation.
|
||||
type Attestation interface {
|
||||
Verify(meta map[string]string) error
|
||||
}
|
||||
|
||||
type noopAttestation struct{}
|
||||
|
||||
func (noopAttestation) Verify(map[string]string) error { return nil }
|
||||
|
||||
// authRequest is the first frame a remote client sends (before the daemon
|
||||
// hello). Token is never logged.
|
||||
type authRequest struct {
|
||||
Token string `json:"token"`
|
||||
Version int `json:"version"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// authResponse is the bridge's reply to the auth handshake.
|
||||
type authResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Version int `json:"version,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func writeAuthRequest(w io.Writer, req authRequest) error {
|
||||
payload, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return daemon.WriteFrame(w, daemon.KindCtrl, payload)
|
||||
}
|
||||
|
||||
func readAuthRequest(r io.Reader) (authRequest, error) {
|
||||
kind, payload, err := daemon.ReadFrame(r)
|
||||
if err != nil {
|
||||
return authRequest{}, err
|
||||
}
|
||||
if kind != daemon.KindCtrl {
|
||||
return authRequest{}, errors.New("remote: expected auth frame")
|
||||
}
|
||||
var req authRequest
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
return authRequest{}, fmt.Errorf("remote: decode auth request: %w", err)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func writeAuthResponse(w io.Writer, resp authResponse) error {
|
||||
payload, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return daemon.WriteFrame(w, daemon.KindCtrl, payload)
|
||||
}
|
||||
|
||||
func readAuthResponse(r io.Reader) (authResponse, error) {
|
||||
kind, payload, err := daemon.ReadFrame(r)
|
||||
if err != nil {
|
||||
return authResponse{}, err
|
||||
}
|
||||
if kind != daemon.KindCtrl {
|
||||
return authResponse{}, errors.New("remote: expected auth response frame")
|
||||
}
|
||||
var resp authResponse
|
||||
if err := json.Unmarshal(payload, &resp); err != nil {
|
||||
return authResponse{}, fmt.Errorf("remote: decode auth response: %w", err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTokenAuthenticator(t *testing.T) {
|
||||
if _, err := NewTokenAuthenticator(" "); err == nil {
|
||||
t.Fatal("empty token must be rejected (fail closed)")
|
||||
}
|
||||
a, err := NewTokenAuthenticator("s3cret")
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenAuthenticator: %v", err)
|
||||
}
|
||||
if err := a.Authenticate("s3cret"); err != nil {
|
||||
t.Fatalf("matching token should authenticate: %v", err)
|
||||
}
|
||||
if err := a.Authenticate("wrong"); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("mismatch err = %v, want ErrUnauthorized", err)
|
||||
}
|
||||
if err := a.Authenticate(""); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("empty presented token err = %v, want ErrUnauthorized", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenFromEnv(t *testing.T) {
|
||||
// Clear both so the no-config path errors.
|
||||
t.Setenv(EnvToken, "")
|
||||
t.Setenv(EnvTokenFile, "")
|
||||
if _, err := TokenFromEnv(); err == nil {
|
||||
t.Fatal("TokenFromEnv with neither var set must error")
|
||||
}
|
||||
// Direct env.
|
||||
t.Setenv(EnvToken, "from-env")
|
||||
if tok, err := TokenFromEnv(); err != nil || tok != "from-env" {
|
||||
t.Fatalf("TokenFromEnv(env) = %q, %v", tok, err)
|
||||
}
|
||||
// File (env takes precedence, so clear env first).
|
||||
t.Setenv(EnvToken, "")
|
||||
file := filepath.Join(t.TempDir(), "tok")
|
||||
if err := os.WriteFile(file, []byte(" from-file\n"), 0o600); err != nil {
|
||||
t.Fatalf("write token file: %v", err)
|
||||
}
|
||||
t.Setenv(EnvTokenFile, file)
|
||||
if tok, err := TokenFromEnv(); err != nil || tok != "from-file" {
|
||||
t.Fatalf("TokenFromEnv(file) = %q, %v", tok, err)
|
||||
}
|
||||
// Empty file fails closed.
|
||||
if err := os.WriteFile(file, []byte(" \n"), 0o600); err != nil {
|
||||
t.Fatalf("rewrite token file: %v", err)
|
||||
}
|
||||
if _, err := TokenFromEnv(); err == nil {
|
||||
t.Fatal("empty token file must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerTLSConfigRequiresCertKey(t *testing.T) {
|
||||
if _, err := ServerTLSConfig("", ""); err == nil {
|
||||
t.Fatal("ServerTLSConfig must require a cert and key (TLS mandatory)")
|
||||
}
|
||||
if _, err := ServerTLSConfig("/nope/cert.pem", "/nope/key.pem"); err == nil {
|
||||
t.Fatal("ServerTLSConfig must error on unreadable cert/key")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/daemon"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMaxConnections = 32
|
||||
defaultHandshakeTimeout = 10 * time.Second
|
||||
defaultAuthFailDelay = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
// Bridge serves authenticated remote connections and drives the local daemon's
|
||||
// control dispatch for each one.
|
||||
type Bridge struct {
|
||||
server *daemon.Server
|
||||
auth Authenticator
|
||||
attest Attestation
|
||||
minVersion int
|
||||
handshakeTimeout time.Duration
|
||||
authFailDelay time.Duration
|
||||
log func(string)
|
||||
sem chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// BridgeOptions configures a Bridge.
|
||||
type BridgeOptions struct {
|
||||
// Server is the local daemon to drive (required).
|
||||
Server *daemon.Server
|
||||
// Authenticator verifies bearer tokens (required).
|
||||
Authenticator Authenticator
|
||||
// Attestation is an optional post-token hook; nil => no-op.
|
||||
Attestation Attestation
|
||||
// MinVersion is the lowest control-protocol version accepted; 0 =>
|
||||
// daemon.ProtoVersion.
|
||||
MinVersion int
|
||||
// MaxConnections bounds concurrent remote connections; 0 => default.
|
||||
MaxConnections int
|
||||
// HandshakeTimeout bounds the auth handshake; 0 => default.
|
||||
HandshakeTimeout time.Duration
|
||||
// AuthFailDelay slows brute-force attempts; <0 => none, 0 => default.
|
||||
AuthFailDelay time.Duration
|
||||
Log func(string)
|
||||
}
|
||||
|
||||
// NewBridge validates options and builds a Bridge.
|
||||
func NewBridge(opts BridgeOptions) (*Bridge, error) {
|
||||
if opts.Server == nil {
|
||||
return nil, errors.New("remote: bridge requires a daemon server")
|
||||
}
|
||||
if opts.Authenticator == nil {
|
||||
return nil, errors.New("remote: bridge requires an authenticator")
|
||||
}
|
||||
minVersion := opts.MinVersion
|
||||
if minVersion <= 0 {
|
||||
minVersion = daemon.ProtoVersion
|
||||
}
|
||||
maxConns := opts.MaxConnections
|
||||
if maxConns <= 0 {
|
||||
maxConns = defaultMaxConnections
|
||||
}
|
||||
handshakeTimeout := opts.HandshakeTimeout
|
||||
if handshakeTimeout <= 0 {
|
||||
handshakeTimeout = defaultHandshakeTimeout
|
||||
}
|
||||
authFailDelay := opts.AuthFailDelay
|
||||
switch {
|
||||
case authFailDelay < 0:
|
||||
authFailDelay = 0
|
||||
case authFailDelay == 0:
|
||||
authFailDelay = defaultAuthFailDelay
|
||||
}
|
||||
attest := opts.Attestation
|
||||
if attest == nil {
|
||||
attest = noopAttestation{}
|
||||
}
|
||||
return &Bridge{
|
||||
server: opts.Server,
|
||||
auth: opts.Authenticator,
|
||||
attest: attest,
|
||||
minVersion: minVersion,
|
||||
handshakeTimeout: handshakeTimeout,
|
||||
authFailDelay: authFailDelay,
|
||||
log: opts.Log,
|
||||
sem: make(chan struct{}, maxConns),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *Bridge) logf(format string, args ...any) {
|
||||
if b.log != nil {
|
||||
b.log(fmt.Sprintf(format, args...))
|
||||
}
|
||||
}
|
||||
|
||||
// ListenAndServeTLS binds a TLS listener on addr and serves until the listener
|
||||
// is closed (via Close). It refuses to serve without a TLS config (no plaintext
|
||||
// remote bridge).
|
||||
func (b *Bridge) ListenAndServeTLS(addr string, tlsConfig *tls.Config) error {
|
||||
if tlsConfig == nil {
|
||||
return errors.New("remote: TLS config is required; refusing to serve a plaintext remote bridge")
|
||||
}
|
||||
listener, err := tls.Listen("tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remote: listen %s: %w", addr, err)
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.listener = listener
|
||||
b.mu.Unlock()
|
||||
return b.Serve(listener)
|
||||
}
|
||||
|
||||
// Close stops the listener started by ListenAndServeTLS, causing Serve to
|
||||
// return. Safe to call before serving or more than once.
|
||||
func (b *Bridge) Close() error {
|
||||
b.mu.Lock()
|
||||
listener := b.listener
|
||||
b.listener = nil // clear so a repeat Close is a no-op, not a closed-listener error
|
||||
b.mu.Unlock()
|
||||
if listener != nil {
|
||||
return listener.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Serve accepts connections from listener until it returns an error (e.g. it is
|
||||
// closed). Each connection is authenticated, then handed to the daemon's control
|
||||
// dispatch. Connections beyond MaxConnections are refused immediately.
|
||||
func (b *Bridge) Serve(listener net.Listener) error {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case b.sem <- struct{}{}:
|
||||
default:
|
||||
// At capacity: refuse fast (fail closed) rather than queueing.
|
||||
b.logf("remote: refused connection (at capacity)")
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
go func() {
|
||||
defer func() { <-b.sem }()
|
||||
b.handle(conn)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// handle authenticates conn then drives the daemon dispatch. It always closes
|
||||
// conn (directly on a failed handshake, or via ServeConn on success).
|
||||
func (b *Bridge) handle(conn net.Conn) {
|
||||
// Bound the auth handshake so a stalled peer cannot hold a connection slot.
|
||||
_ = conn.SetDeadline(time.Now().Add(b.handshakeTimeout))
|
||||
req, err := readAuthRequest(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
if req.Version < b.minVersion {
|
||||
b.deny(conn, "unsupported protocol version")
|
||||
return
|
||||
}
|
||||
if err := b.auth.Authenticate(req.Token); err != nil {
|
||||
b.deny(conn, "unauthorized")
|
||||
return
|
||||
}
|
||||
if err := b.attest.Verify(req.Meta); err != nil {
|
||||
b.deny(conn, "attestation failed")
|
||||
return
|
||||
}
|
||||
if err := writeAuthResponse(conn, authResponse{OK: true, Version: daemon.ProtoVersion}); err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
// Clear the handshake deadline: a session may stream for a long time.
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
b.server.ServeConn(conn) // performs the daemon handshake + one command, then closes conn
|
||||
}
|
||||
|
||||
// deny rejects an unauthenticated connection after a small backoff (to slow
|
||||
// brute force). The reason is generic and never includes any token material.
|
||||
func (b *Bridge) deny(conn net.Conn, message string) {
|
||||
if b.authFailDelay > 0 {
|
||||
time.Sleep(b.authFailDelay)
|
||||
}
|
||||
_ = writeAuthResponse(conn, authResponse{OK: false, Message: message})
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
// ServerTLSConfig builds a server TLS config from a cert/key pair, refusing if
|
||||
// either is missing (TLS is mandatory for the remote bridge).
|
||||
func ServerTLSConfig(certFile, keyFile string) (*tls.Config, error) {
|
||||
certFile = strings.TrimSpace(certFile)
|
||||
keyFile = strings.TrimSpace(keyFile)
|
||||
if certFile == "" || keyFile == "" {
|
||||
return nil, errors.New("remote: a TLS cert and key are required for serve-remote")
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remote: load TLS keypair: %w", err)
|
||||
}
|
||||
return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, nil
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/daemon"
|
||||
)
|
||||
|
||||
// ---- fake daemon worker (the daemon's test fakes are unexported) -------------
|
||||
|
||||
type fakeLines struct{ ch <-chan string }
|
||||
|
||||
func (l fakeLines) Next() (string, bool, error) {
|
||||
line, ok := <-l.ch
|
||||
return line, ok, nil
|
||||
}
|
||||
|
||||
type fakeWorker struct {
|
||||
ch chan string
|
||||
pid int
|
||||
}
|
||||
|
||||
func (w *fakeWorker) Stdout() daemon.Lines { return fakeLines{ch: w.ch} }
|
||||
func (w *fakeWorker) Wait() (int, error) { return 0, nil }
|
||||
func (w *fakeWorker) Kill() error { return nil }
|
||||
func (w *fakeWorker) Pid() int { return w.pid }
|
||||
|
||||
// staticLauncher emits the given lines then ends the stream.
|
||||
func staticLauncher(lines ...string) daemon.Launcher {
|
||||
return func(_ context.Context, _ daemon.WorkerSpec) (daemon.WorkerHandle, error) {
|
||||
ch := make(chan string, len(lines))
|
||||
for _, l := range lines {
|
||||
ch <- l
|
||||
}
|
||||
close(ch)
|
||||
return &fakeWorker{ch: ch, pid: 1}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// blockingLauncher returns a worker whose stream stays open until release is
|
||||
// closed, so a session (and its bridge connection slot) is held open.
|
||||
func blockingLauncher(release <-chan struct{}) daemon.Launcher {
|
||||
return func(_ context.Context, _ daemon.WorkerSpec) (daemon.WorkerHandle, error) {
|
||||
ch := make(chan string)
|
||||
go func() {
|
||||
<-release
|
||||
close(ch)
|
||||
}()
|
||||
return &fakeWorker{ch: ch, pid: 1}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func newBridgeServer(t *testing.T, launcher daemon.Launcher) *daemon.Server {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
pool, err := daemon.NewPool(daemon.PoolOptions{Size: 4, Launcher: launcher, KillTimeout: 200 * time.Millisecond})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPool: %v", err)
|
||||
}
|
||||
mgr, err := daemon.NewSessionManager(daemon.SessionManagerOptions{Pool: pool})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
srv, err := daemon.NewServer(daemon.ServerOptions{
|
||||
Paths: daemon.Paths{Socket: filepath.Join(dir, "s.sock"), Lock: filepath.Join(dir, "s.lock"), Status: filepath.Join(dir, "s.status")},
|
||||
Manager: mgr,
|
||||
Pool: pool,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
t.Cleanup(srv.Shutdown)
|
||||
return srv
|
||||
}
|
||||
|
||||
// genTestCert writes a self-signed cert/key (valid for 127.0.0.1) to temp files.
|
||||
func genTestCert(t *testing.T) (certFile, keyFile string) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("genkey: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "zero-remote-test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
IsCA: true,
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
|
||||
DNSNames: []string{"localhost"},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
certFile = filepath.Join(dir, "cert.pem")
|
||||
keyFile = filepath.Join(dir, "key.pem")
|
||||
certOut, _ := os.Create(certFile)
|
||||
_ = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
_ = certOut.Close()
|
||||
keyDER, _ := x509.MarshalECPrivateKey(key)
|
||||
keyOut, _ := os.Create(keyFile)
|
||||
_ = pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
_ = keyOut.Close()
|
||||
return certFile, keyFile
|
||||
}
|
||||
|
||||
// startBridge starts a TLS bridge on 127.0.0.1:0 and returns its address + cert.
|
||||
func startBridge(t *testing.T, srv *daemon.Server, opts BridgeOptions) (addr, caCert string) {
|
||||
t.Helper()
|
||||
certFile, keyFile := genTestCert(t)
|
||||
tlsConfig, err := ServerTLSConfig(certFile, keyFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ServerTLSConfig: %v", err)
|
||||
}
|
||||
opts.Server = srv
|
||||
bridge, err := NewBridge(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("NewBridge: %v", err)
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
tlsLn := tls.NewListener(ln, tlsConfig)
|
||||
go func() { _ = bridge.Serve(tlsLn) }()
|
||||
t.Cleanup(func() { _ = tlsLn.Close() })
|
||||
return ln.Addr().String(), certFile
|
||||
}
|
||||
|
||||
func TestBridgeRunStatusRoundTrip(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher(`{"type":"event","seq":1}`, `{"type":"event","seq":2}`))
|
||||
auth, _ := NewTokenAuthenticator("tok")
|
||||
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth})
|
||||
|
||||
client, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
|
||||
if err != nil {
|
||||
t.Fatalf("DialRemote: %v", err)
|
||||
}
|
||||
var got []string
|
||||
if err := client.Run("sess-1", "", "hello", nil, func(l string) { got = append(got, l) }); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
_ = client.Close()
|
||||
if len(got) != 2 || got[0] != `{"type":"event","seq":1}` {
|
||||
t.Fatalf("run output = %v", got)
|
||||
}
|
||||
|
||||
// Status over a fresh remote connection (same protocol as local).
|
||||
statusClient, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
|
||||
if err != nil {
|
||||
t.Fatalf("DialRemote(status): %v", err)
|
||||
}
|
||||
report, err := statusClient.Status()
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
_ = statusClient.Close()
|
||||
if report == nil || report.PID == 0 {
|
||||
t.Fatalf("status report = %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeRejectsBadToken(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher())
|
||||
auth, _ := NewTokenAuthenticator("correct")
|
||||
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, AuthFailDelay: -1})
|
||||
|
||||
_, err := DialRemote(RemoteConfig{Address: addr, Token: "wrong", CACertFile: ca})
|
||||
if !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("DialRemote(bad token) err = %v, want ErrUnauthorized", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeRejectsLowVersion(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher())
|
||||
auth, _ := NewTokenAuthenticator("tok")
|
||||
// Require a version higher than the client speaks (daemon.ProtoVersion).
|
||||
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, MinVersion: daemon.ProtoVersion + 1, AuthFailDelay: -1})
|
||||
|
||||
_, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
|
||||
if err == nil {
|
||||
t.Fatal("DialRemote with a below-min version must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeRequiresVerifiableCert(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher())
|
||||
auth, _ := NewTokenAuthenticator("tok")
|
||||
addr, _ := startBridge(t, srv, BridgeOptions{Authenticator: auth})
|
||||
// No CA configured + a self-signed server cert => verification must fail
|
||||
// (NEVER InsecureSkipVerify).
|
||||
if _, err := DialRemote(RemoteConfig{Address: addr, Token: "tok"}); err == nil {
|
||||
t.Fatal("DialRemote must reject an untrusted self-signed cert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenAndServeTLSRequiresConfig(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher())
|
||||
auth, _ := NewTokenAuthenticator("tok")
|
||||
bridge, _ := NewBridge(BridgeOptions{Server: srv, Authenticator: auth})
|
||||
if err := bridge.ListenAndServeTLS("127.0.0.1:0", nil); err == nil {
|
||||
t.Fatal("ListenAndServeTLS(nil) must refuse to serve plaintext")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenAndServeTLSServesAndCloses(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher(`{"type":"event","seq":1}`))
|
||||
auth, _ := NewTokenAuthenticator("tok")
|
||||
certFile, keyFile := genTestCert(t)
|
||||
tlsConfig, err := ServerTLSConfig(certFile, keyFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ServerTLSConfig: %v", err)
|
||||
}
|
||||
bridge, err := NewBridge(BridgeOptions{Server: srv, Authenticator: auth})
|
||||
if err != nil {
|
||||
t.Fatalf("NewBridge: %v", err)
|
||||
}
|
||||
// Bind an ephemeral port, discover it, then serve via ListenAndServeTLS by
|
||||
// passing the same addr (it rebinds) — instead we bind here to learn the port
|
||||
// and serve through the public entry on that exact address.
|
||||
probe, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("probe listen: %v", err)
|
||||
}
|
||||
addr := probe.Addr().String()
|
||||
_ = probe.Close()
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- bridge.ListenAndServeTLS(addr, tlsConfig) }()
|
||||
|
||||
// Wait until it accepts a connection, then drive one run.
|
||||
var client *daemon.Client
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
client, err = DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: certFile, Timeout: 300 * time.Millisecond})
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("bridge never came up: %v", err)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
var got []string
|
||||
if err := client.Run("s", "", "hi", nil, func(l string) { got = append(got, l) }); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
_ = client.Close()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d lines, want 1", len(got))
|
||||
}
|
||||
// Close stops the accept loop; a second Close is a no-op (not an error).
|
||||
if err := bridge.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
if err := bridge.Close(); err != nil {
|
||||
t.Fatalf("second Close should be a no-op, got: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-serveErr: // ListenAndServeTLS returned after Close
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("ListenAndServeTLS did not return after Close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeResumeAfterReconnect(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher(`{"type":"event","seq":1}`, `{"type":"event","seq":2}`))
|
||||
auth, _ := NewTokenAuthenticator("tok")
|
||||
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth})
|
||||
|
||||
// First connection runs the session to completion.
|
||||
c1, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
|
||||
if err != nil {
|
||||
t.Fatalf("DialRemote 1: %v", err)
|
||||
}
|
||||
if err := c1.Run("sess-resume", "", "hi", nil, func(string) {}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
_ = c1.Close()
|
||||
|
||||
// A new connection re-attaches and replays the buffered session output —
|
||||
// the dropped first connection did not lose the session.
|
||||
c2, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
|
||||
if err != nil {
|
||||
t.Fatalf("DialRemote 2: %v", err)
|
||||
}
|
||||
defer c2.Close()
|
||||
var replayed []string
|
||||
if err := c2.Attach("sess-resume", func(l string) { replayed = append(replayed, l) }); err != nil {
|
||||
t.Fatalf("Attach: %v", err)
|
||||
}
|
||||
if len(replayed) != 2 {
|
||||
t.Fatalf("re-attach replayed %d lines, want 2", len(replayed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeConnectionCap(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
srv := newBridgeServer(t, blockingLauncher(release))
|
||||
auth, _ := NewTokenAuthenticator("tok")
|
||||
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, MaxConnections: 1})
|
||||
|
||||
// First connection holds the only slot with a blocking run.
|
||||
c1, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
|
||||
if err != nil {
|
||||
t.Fatalf("DialRemote 1: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
runErr := make(chan error, 1)
|
||||
go func() { runErr <- c1.Run("sess-block", "", "hi", nil, func(string) {}) }()
|
||||
|
||||
// Wait until the slot is in use, then a second dial must be refused.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
c2, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca, Timeout: 500 * time.Millisecond})
|
||||
if err != nil {
|
||||
return // refused as expected
|
||||
}
|
||||
_ = c2.Close()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("second connection was not refused at the connection cap")
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/daemon"
|
||||
)
|
||||
|
||||
const defaultDialTimeout = 15 * time.Second
|
||||
|
||||
// RemoteConfig configures a remote dial.
|
||||
type RemoteConfig struct {
|
||||
// Address is host:port of the remote bridge (required).
|
||||
Address string
|
||||
// Token is the bearer token (required).
|
||||
Token string
|
||||
// CACertFile, when set, is the only CA trusted for the server cert (use for a
|
||||
// self-signed bridge). Empty => system roots. InsecureSkipVerify is never used.
|
||||
CACertFile string
|
||||
// ServerName overrides the TLS/SNI verification name; empty => host of Address.
|
||||
ServerName string
|
||||
// Timeout bounds the dial + auth handshake; 0 => default.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// DialRemote establishes a verified TLS connection to a remote bridge,
|
||||
// authenticates with the bearer token, and returns a daemon.Client ready for
|
||||
// Run/Attach/Status — the same client the local socket uses, so remote and local
|
||||
// share one protocol. The server certificate is always verified (never
|
||||
// InsecureSkipVerify).
|
||||
func DialRemote(cfg RemoteConfig) (*daemon.Client, error) {
|
||||
address := strings.TrimSpace(cfg.Address)
|
||||
if address == "" {
|
||||
return nil, errors.New("remote: address is required")
|
||||
}
|
||||
token := strings.TrimSpace(cfg.Token)
|
||||
if token == "" {
|
||||
return nil, errors.New("remote: a non-empty token is required")
|
||||
}
|
||||
tlsConfig, err := clientTLSConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultDialTimeout
|
||||
}
|
||||
// Context-aware TLS dial (tls.DialWithDialer is deprecated and not
|
||||
// cancellation-aware). The context bounds the dial + TLS handshake only; the
|
||||
// returned conn is independent of it for the subsequent auth/stream I/O.
|
||||
dialCtx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
dialer := &tls.Dialer{NetDialer: &net.Dialer{}, Config: tlsConfig}
|
||||
conn, err := dialer.DialContext(dialCtx, "tcp", address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remote: dial %s: %w", address, err)
|
||||
}
|
||||
// Bound the auth handshake; the daemon handshake + stream run without a deadline.
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
if err := writeAuthRequest(conn, authRequest{Token: token, Version: daemon.ProtoVersion}); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("remote: send auth: %w", err)
|
||||
}
|
||||
resp, err := readAuthResponse(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("remote: auth handshake: %w", err)
|
||||
}
|
||||
if !resp.OK {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnauthorized, resp.Message)
|
||||
}
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
return daemon.NewClientConn(conn)
|
||||
}
|
||||
|
||||
// clientTLSConfig builds a verifying client TLS config. It never disables
|
||||
// verification; for a self-signed bridge, set CACertFile to the bridge's cert.
|
||||
func clientTLSConfig(cfg RemoteConfig) (*tls.Config, error) {
|
||||
tc := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
serverName := strings.TrimSpace(cfg.ServerName)
|
||||
if serverName == "" {
|
||||
if host, _, err := net.SplitHostPort(cfg.Address); err == nil {
|
||||
serverName = host
|
||||
}
|
||||
}
|
||||
tc.ServerName = serverName
|
||||
if ca := strings.TrimSpace(cfg.CACertFile); ca != "" {
|
||||
pem, err := os.ReadFile(ca)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remote: read CA cert: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, errors.New("remote: CA cert file contained no certificates")
|
||||
}
|
||||
tc.RootCAs = pool
|
||||
}
|
||||
return tc, nil
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Server is the daemon control plane. Mirrors reference-daemon-code-agent-js/
|
||||
// supervisor.js (single-instance lock, status file, lifecycle) + the accept loop
|
||||
// that routes framed control requests to the SessionManager/Pool. It listens on
|
||||
// an owner-only local Unix socket and NEVER binds a TCP port.
|
||||
type Server struct {
|
||||
opts ServerOptions
|
||||
startedAt time.Time
|
||||
|
||||
listener net.Listener
|
||||
lock *fileLock
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
wg sync.WaitGroup
|
||||
shutdownOnce sync.Once
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// ServerOptions configures a Server.
|
||||
type ServerOptions struct {
|
||||
Paths Paths
|
||||
Manager *SessionManager
|
||||
Pool *Pool
|
||||
Version int
|
||||
Now func() time.Time
|
||||
Log func(string)
|
||||
isAlive func(int) bool // test hook for the single-instance lock
|
||||
}
|
||||
|
||||
// NewServer validates options and builds a Server.
|
||||
func NewServer(opts ServerOptions) (*Server, error) {
|
||||
if opts.Manager == nil || opts.Pool == nil {
|
||||
return nil, errors.New("daemon: server requires a Pool and SessionManager")
|
||||
}
|
||||
if opts.Paths.Socket == "" || opts.Paths.Lock == "" || opts.Paths.Status == "" {
|
||||
return nil, errors.New("daemon: server requires socket, lock, and status paths")
|
||||
}
|
||||
if opts.Version <= 0 {
|
||||
opts.Version = ProtoVersion
|
||||
}
|
||||
if opts.Now == nil {
|
||||
opts.Now = time.Now
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Server{
|
||||
opts: opts,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
done: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) logf(format string, args ...any) {
|
||||
if s.opts.Log != nil {
|
||||
s.opts.Log(fmt.Sprintf(format, args...))
|
||||
}
|
||||
}
|
||||
|
||||
// Serve acquires the single-instance lock, binds the owner-only control socket,
|
||||
// writes the status file, and serves connections until Shutdown. It blocks. On
|
||||
// return it has released the lock and removed the socket/status files.
|
||||
func (s *Server) Serve() error {
|
||||
if err := checkSocketPathLength(s.opts.Paths.Socket); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := secureSocketParent(s.opts.Paths.Socket); err != nil {
|
||||
return err
|
||||
}
|
||||
lock, err := acquireLock(s.opts.Paths.Lock, s.opts.isAlive)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.lock = lock
|
||||
defer s.cleanup()
|
||||
|
||||
// A leftover socket file from an unclean exit would make Listen fail with
|
||||
// "address already in use"; we hold the lock, so any socket here is stale.
|
||||
_ = os.Remove(s.opts.Paths.Socket)
|
||||
|
||||
listener, err := net.Listen("unix", s.opts.Paths.Socket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon: bind control socket: %w", err)
|
||||
}
|
||||
s.listener = listener
|
||||
if err := hardenSocketFile(s.opts.Paths.Socket); err != nil {
|
||||
return fmt.Errorf("daemon: harden control socket: %w", err)
|
||||
}
|
||||
s.startedAt = s.opts.Now()
|
||||
if err := s.writeStatusFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logf("daemon listening on %s (pid %d)", s.opts.Paths.Socket, os.Getpid())
|
||||
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-s.done:
|
||||
s.wg.Wait()
|
||||
return nil // clean shutdown
|
||||
default:
|
||||
// Transient accept error during normal operation.
|
||||
s.logf("accept error: %v", err)
|
||||
return fmt.Errorf("daemon: accept: %w", err)
|
||||
}
|
||||
}
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
s.handleConn(conn)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown stops accepting connections, cancels in-flight runs, drains the pool,
|
||||
// and removes the socket/lock/status files. Safe to call multiple times.
|
||||
func (s *Server) Shutdown() {
|
||||
s.shutdownOnce.Do(func() {
|
||||
close(s.done)
|
||||
s.cancel() // stop in-flight pool runs
|
||||
if s.listener != nil {
|
||||
_ = s.listener.Close()
|
||||
}
|
||||
s.opts.Pool.Drain()
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) cleanup() {
|
||||
if s.listener != nil {
|
||||
_ = s.listener.Close()
|
||||
}
|
||||
_ = os.Remove(s.opts.Paths.Socket)
|
||||
_ = os.Remove(s.opts.Paths.Status)
|
||||
if s.lock != nil {
|
||||
_ = s.lock.release()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) writeStatusFile() error {
|
||||
status := StatusFile{
|
||||
PID: os.Getpid(),
|
||||
Socket: s.opts.Paths.Socket,
|
||||
Version: s.opts.Version,
|
||||
StartedAt: s.startedAt,
|
||||
}
|
||||
data, err := json.MarshalIndent(status, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(s.opts.Paths.Status, data, 0o600); err != nil {
|
||||
return fmt.Errorf("daemon: write status file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServeConn runs the control protocol (handshake + one command) on an
|
||||
// already-established connection, reusing the exact local dispatch path. The
|
||||
// remote bridge calls it AFTER authenticating a TLS connection, so a remote
|
||||
// session is handled identically to a local one (same SessionManager/Pool, same
|
||||
// sandbox/risk model) — remote never bypasses the local controls. It closes conn.
|
||||
func (s *Server) ServeConn(conn net.Conn) { s.handleConn(conn) }
|
||||
|
||||
// handleConn performs the handshake then dispatches a single control command.
|
||||
func (s *Server) handleConn(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
hello, err := ReadControl(conn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if hello.Type != CtrlHello {
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: "expected hello"})
|
||||
return
|
||||
}
|
||||
version, ok := NegotiateVersion(hello.Version)
|
||||
if !ok {
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: "unsupported protocol version"})
|
||||
return
|
||||
}
|
||||
if err := WriteControl(conn, Ctrl{Type: CtrlHelloOK, Version: version}); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
cmd, err := ReadControl(conn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
switch cmd.Type {
|
||||
case CtrlRun:
|
||||
s.handleRun(conn, cmd)
|
||||
case CtrlAttach:
|
||||
s.handleAttach(conn, cmd)
|
||||
case CtrlStatus:
|
||||
s.handleStatus(conn)
|
||||
case CtrlShutdown:
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlAck, Message: "shutting down"})
|
||||
s.Shutdown()
|
||||
default:
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: fmt.Sprintf("unknown command %q", cmd.Type)})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleRun(conn net.Conn, cmd Ctrl) {
|
||||
if cmd.Session == "" {
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: "run requires a session id"})
|
||||
return
|
||||
}
|
||||
args := cmd.Args
|
||||
if cmd.Prompt != "" {
|
||||
args = append(args, "--prompt", cmd.Prompt)
|
||||
}
|
||||
sess, err := s.opts.Manager.Start(s.ctx, WorkerSpec{Session: cmd.Session, Cwd: cmd.Cwd, Args: args})
|
||||
if err != nil {
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlAck, Session: sess.ID()})
|
||||
buffered, live, cancel := sess.Subscribe()
|
||||
defer cancel()
|
||||
s.streamToClient(conn, sess, buffered, live)
|
||||
}
|
||||
|
||||
func (s *Server) handleAttach(conn net.Conn, cmd Ctrl) {
|
||||
if cmd.Session == "" {
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: "attach requires a session id"})
|
||||
return
|
||||
}
|
||||
buffered, live, cancel, err := s.opts.Manager.Attach(cmd.Session)
|
||||
if err != nil {
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
defer cancel()
|
||||
sess, _ := s.opts.Manager.Get(cmd.Session)
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlAck, Session: cmd.Session})
|
||||
s.streamToClient(conn, sess, buffered, live)
|
||||
}
|
||||
|
||||
// streamToClient writes the buffered history then live lines as CtrlData frames,
|
||||
// finishing with CtrlEnd (or CtrlError if the session failed). A write error
|
||||
// (client disconnected) ends the stream without affecting the session.
|
||||
func (s *Server) streamToClient(conn net.Conn, sess *Session, buffered []string, live <-chan string) {
|
||||
for _, line := range buffered {
|
||||
if err := WriteControl(conn, Ctrl{Type: CtrlData, Line: line}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case line, ok := <-live:
|
||||
if !ok {
|
||||
s.finishStream(conn, sess)
|
||||
return
|
||||
}
|
||||
if err := WriteControl(conn, Ctrl{Type: CtrlData, Line: line}); err != nil {
|
||||
return
|
||||
}
|
||||
case <-s.done:
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlEnd, Message: "daemon shutting down"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) finishStream(conn net.Conn, sess *Session) {
|
||||
if sess != nil {
|
||||
if err := sess.Err(); err != nil {
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Session: sess.ID(), Message: err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlEnd})
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(conn net.Conn) {
|
||||
report := StatusReport{
|
||||
PID: os.Getpid(),
|
||||
Version: s.opts.Version,
|
||||
Socket: s.opts.Paths.Socket,
|
||||
StartedAt: s.startedAt,
|
||||
PoolSize: s.opts.Pool.Size(),
|
||||
Workers: s.opts.Pool.WorkerStats(),
|
||||
Sessions: s.opts.Manager.Statuses(),
|
||||
QueueDepth: s.opts.Pool.QueueDepth(),
|
||||
}
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlStatusResult, Status: &report})
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T, launcher Launcher) (*Server, Paths) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
paths := Paths{
|
||||
Socket: filepath.Join(dir, "d.sock"),
|
||||
Lock: filepath.Join(dir, "d.lock"),
|
||||
Status: filepath.Join(dir, "d.status"),
|
||||
}
|
||||
pool, err := NewPool(PoolOptions{Size: 2, Launcher: launcher, KillTimeout: 200 * time.Millisecond})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPool: %v", err)
|
||||
}
|
||||
mgr, err := NewSessionManager(SessionManagerOptions{Pool: pool})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
srv, err := NewServer(ServerOptions{Paths: paths, Manager: mgr, Pool: pool})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
return srv, paths
|
||||
}
|
||||
|
||||
func waitForFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("file %s did not appear within timeout", path)
|
||||
}
|
||||
|
||||
func TestServerEndToEnd(t *testing.T) {
|
||||
out := []string{`{"type":"event","seq":1}`, `{"type":"event","seq":2}`}
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, out: out, exitCode: 0})
|
||||
srv, paths := newTestServer(t, launcher)
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve() }()
|
||||
waitForFile(t, paths.Status)
|
||||
|
||||
// --- run a session and collect its stream-json output ---
|
||||
runClient, err := Dial(paths.Socket)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial(run): %v", err)
|
||||
}
|
||||
var got []string
|
||||
if err := runClient.Run("s1", "", "hello", nil, func(line string) { got = append(got, line) }); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
runClient.Close()
|
||||
if len(got) != 2 || got[0] != out[0] || got[1] != out[1] {
|
||||
t.Fatalf("streamed lines = %v, want %v", got, out)
|
||||
}
|
||||
|
||||
// --- status reflects the session ---
|
||||
statusClient, err := Dial(paths.Socket)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial(status): %v", err)
|
||||
}
|
||||
report, err := statusClient.Status()
|
||||
statusClient.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
if report.PoolSize != 2 {
|
||||
t.Fatalf("status PoolSize = %d, want 2", report.PoolSize)
|
||||
}
|
||||
foundSession := false
|
||||
for _, s := range report.Sessions {
|
||||
if s.ID == "s1" {
|
||||
foundSession = true
|
||||
if s.State != string(SessionDone) {
|
||||
t.Fatalf("session s1 state = %s, want done", s.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundSession {
|
||||
t.Fatalf("status missing session s1: %+v", report.Sessions)
|
||||
}
|
||||
|
||||
// --- attach to the finished session sees the buffered history ---
|
||||
attachClient, err := Dial(paths.Socket)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial(attach): %v", err)
|
||||
}
|
||||
var attached []string
|
||||
if err := attachClient.Attach("s1", func(line string) { attached = append(attached, line) }); err != nil {
|
||||
t.Fatalf("Attach: %v", err)
|
||||
}
|
||||
attachClient.Close()
|
||||
if len(attached) != 2 {
|
||||
t.Fatalf("attach lines = %v, want 2 buffered", attached)
|
||||
}
|
||||
|
||||
// --- graceful stop, daemon cleans up ---
|
||||
stopClient, err := Dial(paths.Socket)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial(stop): %v", err)
|
||||
}
|
||||
if err := stopClient.Shutdown(); err != nil {
|
||||
t.Fatalf("Shutdown: %v", err)
|
||||
}
|
||||
stopClient.Close()
|
||||
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
if err != nil {
|
||||
t.Fatalf("Serve returned error: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Serve did not return after shutdown")
|
||||
}
|
||||
// Socket, status, and lock files are removed on exit.
|
||||
for _, p := range []string{paths.Socket, paths.Status, paths.Lock} {
|
||||
if _, err := os.Stat(p); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("file %s not cleaned up after shutdown: %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerSecondInstanceFails(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
defer close(block)
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block})
|
||||
srv1, paths := newTestServer(t, launcher)
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv1.Serve() }()
|
||||
waitForFile(t, paths.Status)
|
||||
|
||||
// A second server on the same paths must refuse to start (single instance).
|
||||
pool2, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
mgr2, _ := NewSessionManager(SessionManagerOptions{Pool: pool2})
|
||||
srv2, _ := NewServer(ServerOptions{Paths: paths, Manager: mgr2, Pool: pool2})
|
||||
if err := srv2.Serve(); !errors.Is(err, ErrAlreadyRunning) {
|
||||
t.Fatalf("second Serve err = %v, want ErrAlreadyRunning", err)
|
||||
}
|
||||
|
||||
srv1.Shutdown()
|
||||
<-serveErr
|
||||
}
|
||||
|
||||
func TestServerRejectsUnknownCommand(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
defer close(block)
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block})
|
||||
srv, paths := newTestServer(t, launcher)
|
||||
go func() { _ = srv.Serve() }()
|
||||
defer srv.Shutdown()
|
||||
waitForFile(t, paths.Status)
|
||||
|
||||
client, err := Dial(paths.Socket)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
// Run with an empty session id is rejected with an error frame.
|
||||
err = client.Run("", "", "hi", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("run with empty session id must return an error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Session/lease/roster model. Mirrors reference-daemon-code-agent-js/
|
||||
// session-manager.js (sessionID -> worker + metrics), lease.js (one active
|
||||
// request per worker) and roster.js (the live session registry). Because zero's
|
||||
// `exec` worker is one-shot, each session is served by exactly one worker via
|
||||
// Pool.Run, so "one active request per worker" holds inherently and the pool's
|
||||
// bounded slots provide the queue-when-full behavior.
|
||||
|
||||
// SessionState is a session's lifecycle phase.
|
||||
type SessionState string
|
||||
|
||||
const (
|
||||
// SessionQueued: created, waiting for a free pool slot.
|
||||
SessionQueued SessionState = "queued"
|
||||
// SessionRunning: a worker is leased and producing output.
|
||||
SessionRunning SessionState = "running"
|
||||
// SessionDone: the worker finished cleanly.
|
||||
SessionDone SessionState = "done"
|
||||
// SessionFailed: the worker failed permanently / the run errored.
|
||||
SessionFailed SessionState = "failed"
|
||||
)
|
||||
|
||||
// ErrSessionExists is returned when starting a session ID already in use.
|
||||
var ErrSessionExists = errors.New("daemon: session already exists")
|
||||
|
||||
// ErrSessionNotFound is returned by Get/Attach for an unknown session ID.
|
||||
var ErrSessionNotFound = errors.New("daemon: session not found")
|
||||
|
||||
const defaultSessionBuffer = 1024
|
||||
|
||||
// Session is one routed agent run. It buffers recent output (so a late `attach`
|
||||
// sees history) and fans live lines out to subscribers. It implements the pool's
|
||||
// Sink (Line) and the lease "started" hook (Started).
|
||||
type Session struct {
|
||||
id string
|
||||
cwd string
|
||||
|
||||
mu sync.Mutex
|
||||
state SessionState
|
||||
buffer []string
|
||||
maxBuffer int
|
||||
lines int
|
||||
finished bool
|
||||
err error
|
||||
exitCode int
|
||||
subscribers map[int]chan string
|
||||
nextSub int
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newSession(id, cwd string, maxBuffer int) *Session {
|
||||
if maxBuffer <= 0 {
|
||||
maxBuffer = defaultSessionBuffer
|
||||
}
|
||||
return &Session{
|
||||
id: id,
|
||||
cwd: cwd,
|
||||
state: SessionQueued,
|
||||
maxBuffer: maxBuffer,
|
||||
subscribers: map[int]chan string{},
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// ID returns the session ID.
|
||||
func (s *Session) ID() string { return s.id }
|
||||
|
||||
// Done is closed when the session finishes (success or failure).
|
||||
func (s *Session) Done() <-chan struct{} { return s.done }
|
||||
|
||||
// Err returns the terminal error (nil on clean completion); valid after Done.
|
||||
func (s *Session) Err() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.err
|
||||
}
|
||||
|
||||
// State returns the current lifecycle phase.
|
||||
func (s *Session) State() SessionState {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.state
|
||||
}
|
||||
|
||||
// Started marks the session running once the pool leases a worker (lease hook).
|
||||
func (s *Session) Started() {
|
||||
s.mu.Lock()
|
||||
if s.state == SessionQueued {
|
||||
s.state = SessionRunning
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Line records and broadcasts one stream-json output line.
|
||||
func (s *Session) Line(line string) {
|
||||
s.mu.Lock()
|
||||
s.lines++
|
||||
s.buffer = append(s.buffer, line)
|
||||
if len(s.buffer) > s.maxBuffer {
|
||||
// Drop the oldest line; the ring keeps only the most recent maxBuffer.
|
||||
s.buffer = s.buffer[len(s.buffer)-s.maxBuffer:]
|
||||
}
|
||||
subs := make([]chan string, 0, len(s.subscribers))
|
||||
for _, ch := range s.subscribers {
|
||||
subs = append(subs, ch)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for _, ch := range subs {
|
||||
// Non-blocking: a slow/stalled subscriber must not stall the worker pump.
|
||||
// Dropped live lines remain available via the buffer for a fresh attach.
|
||||
select {
|
||||
case ch <- line:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) finish(exitCode int, err error) {
|
||||
s.mu.Lock()
|
||||
s.finished = true
|
||||
s.exitCode = exitCode
|
||||
s.err = err
|
||||
if err != nil {
|
||||
s.state = SessionFailed
|
||||
} else {
|
||||
s.state = SessionDone
|
||||
}
|
||||
subs := s.subscribers
|
||||
s.subscribers = map[int]chan string{}
|
||||
s.mu.Unlock()
|
||||
for _, ch := range subs {
|
||||
close(ch)
|
||||
}
|
||||
close(s.done)
|
||||
}
|
||||
|
||||
// Subscribe returns the buffered history plus a channel of subsequent live lines
|
||||
// and a cancel func. If the session has already finished, the live channel is
|
||||
// returned already closed. Mirrors attach.js.
|
||||
func (s *Session) Subscribe() (buffered []string, live <-chan string, cancel func()) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
buffered = append([]string(nil), s.buffer...)
|
||||
if s.finished {
|
||||
ch := make(chan string)
|
||||
close(ch)
|
||||
return buffered, ch, func() {}
|
||||
}
|
||||
id := s.nextSub
|
||||
s.nextSub++
|
||||
ch := make(chan string, 256)
|
||||
s.subscribers[id] = ch
|
||||
cancel = func() {
|
||||
s.mu.Lock()
|
||||
if c, ok := s.subscribers[id]; ok {
|
||||
delete(s.subscribers, id)
|
||||
close(c)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
return buffered, ch, cancel
|
||||
}
|
||||
|
||||
func (s *Session) status() SessionStatus {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return SessionStatus{ID: s.id, State: string(s.state), Lines: s.lines}
|
||||
}
|
||||
|
||||
func (s *Session) isFinished() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.finished
|
||||
}
|
||||
|
||||
// defaultMaxSessions bounds the retained session registry so a long-running
|
||||
// daemon does not accumulate finished sessions without limit.
|
||||
const defaultMaxSessions = 256
|
||||
|
||||
// SessionManager owns the live session registry and routes each session to a
|
||||
// pool worker. Mirrors session-manager.js + roster.js. Finished sessions are
|
||||
// retained (so a late `attach` sees history) up to MaxSessions, past which the
|
||||
// oldest FINISHED ones are evicted; running/queued sessions are never evicted.
|
||||
type SessionManager struct {
|
||||
pool *Pool
|
||||
maxBuffer int
|
||||
maxSessions int
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]*Session
|
||||
order []string // creation order, for FIFO eviction of finished sessions
|
||||
}
|
||||
|
||||
// SessionManagerOptions configures a SessionManager.
|
||||
type SessionManagerOptions struct {
|
||||
Pool *Pool
|
||||
MaxBuffer int // per-session output ring size; 0 => default
|
||||
// MaxSessions caps the retained session registry; 0 => default. The oldest
|
||||
// FINISHED sessions are evicted once the cap is exceeded.
|
||||
MaxSessions int
|
||||
}
|
||||
|
||||
// NewSessionManager builds a manager over pool.
|
||||
func NewSessionManager(opts SessionManagerOptions) (*SessionManager, error) {
|
||||
if opts.Pool == nil {
|
||||
return nil, errors.New("daemon: session manager requires a Pool")
|
||||
}
|
||||
maxSessions := opts.MaxSessions
|
||||
if maxSessions <= 0 {
|
||||
maxSessions = defaultMaxSessions
|
||||
}
|
||||
return &SessionManager{
|
||||
pool: opts.Pool,
|
||||
maxBuffer: opts.MaxBuffer,
|
||||
maxSessions: maxSessions,
|
||||
sessions: map[string]*Session{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start creates a session for spec and dispatches it to the pool. It returns the
|
||||
// Session immediately (non-blocking); the run proceeds in the background and the
|
||||
// session transitions queued -> running -> done/failed. A duplicate ID is
|
||||
// rejected with ErrSessionExists.
|
||||
func (m *SessionManager) Start(ctx context.Context, spec WorkerSpec) (*Session, error) {
|
||||
m.mu.Lock()
|
||||
if _, exists := m.sessions[spec.Session]; exists {
|
||||
m.mu.Unlock()
|
||||
return nil, ErrSessionExists
|
||||
}
|
||||
sess := newSession(spec.Session, spec.Cwd, m.maxBuffer)
|
||||
m.sessions[spec.Session] = sess
|
||||
m.order = append(m.order, spec.Session)
|
||||
m.pruneLocked()
|
||||
m.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
code, err := m.pool.Run(ctx, spec, sess)
|
||||
sess.finish(code, err)
|
||||
}()
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// pruneLocked evicts the oldest FINISHED sessions while the registry exceeds
|
||||
// maxSessions, so a long-running daemon does not retain finished sessions without
|
||||
// bound. Running/queued sessions are never evicted (a transient burst can exceed
|
||||
// the cap until those finish). Caller must hold m.mu.
|
||||
func (m *SessionManager) pruneLocked() {
|
||||
if len(m.sessions) <= m.maxSessions {
|
||||
return
|
||||
}
|
||||
kept := m.order[:0]
|
||||
for _, id := range m.order {
|
||||
s, ok := m.sessions[id]
|
||||
if !ok {
|
||||
continue // already removed
|
||||
}
|
||||
if len(m.sessions) > m.maxSessions && s.isFinished() {
|
||||
delete(m.sessions, id)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, id)
|
||||
}
|
||||
m.order = kept
|
||||
}
|
||||
|
||||
// Get returns a session by ID.
|
||||
func (m *SessionManager) Get(id string) (*Session, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
s, ok := m.sessions[id]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// Attach returns the buffered history + live stream for an existing session.
|
||||
func (m *SessionManager) Attach(id string) (buffered []string, live <-chan string, cancel func(), err error) {
|
||||
s, ok := m.Get(id)
|
||||
if !ok {
|
||||
return nil, nil, nil, ErrSessionNotFound
|
||||
}
|
||||
b, l, c := s.Subscribe()
|
||||
return b, l, c, nil
|
||||
}
|
||||
|
||||
// Statuses returns a snapshot of all sessions, sorted by ID for stable output.
|
||||
func (m *SessionManager) Statuses() []SessionStatus {
|
||||
m.mu.Lock()
|
||||
sessions := make([]*Session, 0, len(m.sessions))
|
||||
for _, s := range m.sessions {
|
||||
sessions = append(sessions, s)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
out := make([]SessionStatus, 0, len(sessions))
|
||||
for _, s := range sessions {
|
||||
out = append(out, s.status())
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func drain(t *testing.T, buffered []string, live <-chan string) []string {
|
||||
t.Helper()
|
||||
got := append([]string(nil), buffered...)
|
||||
timeout := time.After(2 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case line, ok := <-live:
|
||||
if !ok {
|
||||
return got
|
||||
}
|
||||
got = append(got, line)
|
||||
case <-timeout:
|
||||
t.Fatal("timed out draining session stream")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStartRoutesAndStreams(t *testing.T) {
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, out: []string{"e1", "e2", "e3"}, exitCode: 0})
|
||||
pool, _ := NewPool(PoolOptions{Size: 2, Launcher: launcher})
|
||||
mgr, err := NewSessionManager(SessionManagerOptions{Pool: pool})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
sess, err := mgr.Start(context.Background(), WorkerSpec{Session: "s1"})
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
buffered, live, cancel := sess.Subscribe()
|
||||
defer cancel()
|
||||
got := drain(t, buffered, live)
|
||||
if len(got) != 3 || got[0] != "e1" || got[2] != "e3" {
|
||||
t.Fatalf("streamed lines = %v, want [e1 e2 e3]", got)
|
||||
}
|
||||
<-sess.Done()
|
||||
if sess.State() != SessionDone {
|
||||
t.Fatalf("state = %s, want done", sess.State())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionDuplicateIDRejected(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
defer close(block)
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block})
|
||||
pool, _ := NewPool(PoolOptions{Size: 2, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool})
|
||||
if _, err := mgr.Start(context.Background(), WorkerSpec{Session: "dup"}); err != nil {
|
||||
t.Fatalf("first Start: %v", err)
|
||||
}
|
||||
if _, err := mgr.Start(context.Background(), WorkerSpec{Session: "dup"}); !errors.Is(err, ErrSessionExists) {
|
||||
t.Fatalf("second Start err = %v, want ErrSessionExists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLeaseQueuesWhenPoolFull(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
a := &fakeWorker{pid: 1, waitCh: block}
|
||||
b := &fakeWorker{pid: 2, out: []string{"b1"}, exitCode: 0}
|
||||
launcher, calls := seqLauncher(a, b)
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool})
|
||||
|
||||
sa, _ := mgr.Start(context.Background(), WorkerSpec{Session: "a"})
|
||||
waitFor(t, func() bool { return sa.State() == SessionRunning })
|
||||
|
||||
sb, _ := mgr.Start(context.Background(), WorkerSpec{Session: "b"})
|
||||
// b must stay queued (its worker not yet launched) while a holds the slot.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if sb.State() != SessionQueued {
|
||||
t.Fatalf("session b state = %s, want queued while pool full", sb.State())
|
||||
}
|
||||
if n := atomic.LoadInt32(calls); n != 1 {
|
||||
t.Fatalf("launcher calls = %d, want 1 (b not launched yet)", n)
|
||||
}
|
||||
|
||||
close(block) // a finishes, slot frees, b proceeds
|
||||
<-sb.Done()
|
||||
if sb.State() != SessionDone {
|
||||
t.Fatalf("session b state = %s, want done", sb.State())
|
||||
}
|
||||
if n := atomic.LoadInt32(calls); n != 2 {
|
||||
t.Fatalf("launcher calls = %d, want 2 after slot freed", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionAttachAfterFinishSeesBuffer(t *testing.T) {
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, out: []string{"x", "y"}, exitCode: 0})
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool})
|
||||
sess, _ := mgr.Start(context.Background(), WorkerSpec{Session: "s"})
|
||||
<-sess.Done()
|
||||
|
||||
buffered, live, cancel, err := mgr.Attach("s")
|
||||
if err != nil {
|
||||
t.Fatalf("Attach: %v", err)
|
||||
}
|
||||
defer cancel()
|
||||
got := drain(t, buffered, live)
|
||||
if len(got) != 2 || got[0] != "x" || got[1] != "y" {
|
||||
t.Fatalf("attach buffered = %v, want [x y]", got)
|
||||
}
|
||||
if _, _, _, err := mgr.Attach("missing"); !errors.Is(err, ErrSessionNotFound) {
|
||||
t.Fatalf("Attach(missing) err = %v, want ErrSessionNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerEvictsFinishedOverCap(t *testing.T) {
|
||||
launcher, _ := seqLauncher(
|
||||
&fakeWorker{pid: 1, out: []string{"a"}, exitCode: 0},
|
||||
&fakeWorker{pid: 2, out: []string{"b"}, exitCode: 0},
|
||||
&fakeWorker{pid: 3, out: []string{"c"}, exitCode: 0},
|
||||
)
|
||||
pool, _ := NewPool(PoolOptions{Size: 2, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool, MaxSessions: 2})
|
||||
|
||||
for _, id := range []string{"s1", "s2", "s3"} {
|
||||
s, err := mgr.Start(context.Background(), WorkerSpec{Session: id})
|
||||
if err != nil {
|
||||
t.Fatalf("Start(%s): %v", id, err)
|
||||
}
|
||||
<-s.Done() // finish before starting the next so prune can evict it
|
||||
}
|
||||
|
||||
// Past the cap of 2, the oldest FINISHED session (s1) is evicted; the two
|
||||
// newest are retained.
|
||||
if _, ok := mgr.Get("s1"); ok {
|
||||
t.Fatal("oldest finished session must be evicted past the cap")
|
||||
}
|
||||
if _, ok := mgr.Get("s2"); !ok {
|
||||
t.Fatal("s2 must be retained")
|
||||
}
|
||||
if _, ok := mgr.Get("s3"); !ok {
|
||||
t.Fatal("s3 (newest) must be retained")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerKeepsRunningOverCap(t *testing.T) {
|
||||
// All sessions are running (workers block), so none are evicted even past cap.
|
||||
block := make(chan struct{})
|
||||
defer close(block)
|
||||
launcher, _ := seqLauncher(
|
||||
&fakeWorker{pid: 1, waitCh: block},
|
||||
&fakeWorker{pid: 2, waitCh: block},
|
||||
)
|
||||
pool, _ := NewPool(PoolOptions{Size: 2, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool, MaxSessions: 1})
|
||||
|
||||
s1, _ := mgr.Start(context.Background(), WorkerSpec{Session: "r1"})
|
||||
s2, _ := mgr.Start(context.Background(), WorkerSpec{Session: "r2"})
|
||||
waitFor(t, func() bool { return s1.State() == SessionRunning && s2.State() == SessionRunning })
|
||||
if _, ok := mgr.Get("r1"); !ok {
|
||||
t.Fatal("a running session must never be evicted, even past the cap")
|
||||
}
|
||||
if _, ok := mgr.Get("r2"); !ok {
|
||||
t.Fatal("a running session must never be evicted, even past the cap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStatuses(t *testing.T) {
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, out: []string{"l"}, exitCode: 0})
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool})
|
||||
sess, _ := mgr.Start(context.Background(), WorkerSpec{Session: "only"})
|
||||
<-sess.Done()
|
||||
st := mgr.Statuses()
|
||||
if len(st) != 1 || st[0].ID != "only" || st[0].State != string(SessionDone) || st[0].Lines != 1 {
|
||||
t.Fatalf("statuses = %+v, want one done session with 1 line", st)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// maxUnixSocketPath bounds the socket path to the smallest platform sun_path
|
||||
// limit so we surface a clear error instead of a cryptic bind failure. macOS
|
||||
// allows 104 bytes incl. the NUL (so 103 chars); Linux allows 108. 103 is the
|
||||
// safe cross-platform ceiling.
|
||||
const maxUnixSocketPath = 103
|
||||
|
||||
// secureSocketParent creates the socket's parent directory owner-only (0700 on
|
||||
// POSIX; on Windows the per-user profile directory is already ACL-restricted).
|
||||
func secureSocketParent(socketPath string) error {
|
||||
return os.MkdirAll(filepath.Dir(socketPath), 0o700)
|
||||
}
|
||||
|
||||
// checkSocketPathLength rejects an over-long unix socket path before bind.
|
||||
func checkSocketPathLength(socketPath string) error {
|
||||
if len(socketPath) > maxUnixSocketPath {
|
||||
return fmt.Errorf("daemon: socket path too long (%d > %d): %s", len(socketPath), maxUnixSocketPath, socketPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import "os"
|
||||
|
||||
// hardenSocketFile restricts the control socket to its owner (0600) so no other
|
||||
// local user can connect. Combined with the 0700 parent directory this makes the
|
||||
// control plane owner-only, as required by the security model.
|
||||
func hardenSocketFile(path string) error {
|
||||
return os.Chmod(path, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
// hardenSocketFile is a no-op on Windows: AF_UNIX socket files do not honor POSIX
|
||||
// mode bits, and the daemon places the socket under the per-user profile
|
||||
// directory, which is already ACL-restricted to the owner. A dedicated restricted
|
||||
// ACL (or a named pipe with an explicit DACL) is a documented follow-up.
|
||||
func hardenSocketFile(string) error { return nil }
|
||||
@@ -0,0 +1,43 @@
|
||||
package daemon
|
||||
|
||||
import "time"
|
||||
|
||||
// StatusReport is the daemon/worker/session snapshot returned by `zero daemon
|
||||
// status` (CtrlStatusResult). Mirrors reference-daemon-code-agent-js/status.js.
|
||||
type StatusReport struct {
|
||||
PID int `json:"pid"`
|
||||
Version int `json:"version"`
|
||||
Socket string `json:"socket"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
PoolSize int `json:"poolSize"`
|
||||
Workers []WorkerStatus `json:"workers"`
|
||||
Sessions []SessionStatus `json:"sessions"`
|
||||
QueueDepth int `json:"queueDepth"`
|
||||
}
|
||||
|
||||
// WorkerStatus is one worker's lifecycle snapshot.
|
||||
type WorkerStatus struct {
|
||||
ID int `json:"id"`
|
||||
PID int `json:"pid"`
|
||||
State string `json:"state"`
|
||||
ConsecutiveCrashes int `json:"consecutiveCrashes"`
|
||||
Restarts int `json:"restarts"`
|
||||
Session string `json:"session,omitempty"`
|
||||
}
|
||||
|
||||
// SessionStatus is one session's routing/metrics snapshot.
|
||||
type SessionStatus struct {
|
||||
ID string `json:"id"`
|
||||
State string `json:"state"`
|
||||
Lines int `json:"lines"`
|
||||
}
|
||||
|
||||
// StatusFile is the on-disk daemon status document (pid, socket, version,
|
||||
// started-at), written next to the lock so `status`/`stop` work without a live
|
||||
// connection. Mirrors supervisor.js's status file.
|
||||
type StatusFile struct {
|
||||
PID int `json:"pid"`
|
||||
Socket string `json:"socket"`
|
||||
Version int `json:"version"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
}
|
||||
@@ -158,6 +158,9 @@ func providerModelCheck(profile config.ProviderProfile) Check {
|
||||
if emptyProviderProfile(profile) {
|
||||
return check("provider.model", "Provider model", StatusWarn, "Model validity was skipped because provider config is unavailable.", nil)
|
||||
}
|
||||
if strings.TrimSpace(profile.Model) == "" {
|
||||
return check("provider.model", "Provider model", StatusFail, "Provider model is required.", map[string]any{"provider": providerName(profile)})
|
||||
}
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
return check("provider.model", "Provider model", StatusFail, "Model registry could not be loaded: "+err.Error(), nil)
|
||||
@@ -165,7 +168,7 @@ func providerModelCheck(profile config.ProviderProfile) Check {
|
||||
model, err := registry.Require(profile.Model)
|
||||
if err != nil {
|
||||
if profile.ProviderKind == config.ProviderKindOpenAICompatible || profile.ProviderKind == config.ProviderKindAnthropicCompat {
|
||||
return check("provider.model", "Provider model", StatusWarn, fmt.Sprintf("Custom %s model was not found in the Zero registry; runtime will pass it through to the configured provider.", profile.ProviderKind), map[string]any{"model": profile.Model, "provider": providerName(profile)})
|
||||
return check("provider.model", "Provider model", StatusWarn, fmt.Sprintf("Custom %s model was not found in the Zero registry; runtime will pass it through to the configured provider. Run `zero doctor --connectivity` to validate the endpoint and auth.", profile.ProviderKind), map[string]any{"model": profile.Model, "provider": providerName(profile)})
|
||||
}
|
||||
return check("provider.model", "Provider model", StatusFail, "Provider model is invalid: "+err.Error(), map[string]any{"model": profile.Model})
|
||||
}
|
||||
|
||||
@@ -86,6 +86,96 @@ func TestRunReportWarnsForUnknownOpenAICompatibleModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderModelUnknownOpenAICompatibleModelWarnsPassThrough(t *testing.T) {
|
||||
report := Run(Options{
|
||||
Now: fixedDoctorClock("2026-06-04T15:45:00Z"),
|
||||
Runtime: "go",
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "openrouter",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://openrouter.ai/api/v1",
|
||||
Model: "vendor/new-dynamic-model",
|
||||
},
|
||||
})
|
||||
|
||||
if !report.OK {
|
||||
t.Fatalf("unknown openai-compatible model should warn, not fail: %#v", report)
|
||||
}
|
||||
check := report.Check("provider.model")
|
||||
if check == nil {
|
||||
t.Fatalf("expected provider.model check: %#v", report.Checks)
|
||||
}
|
||||
if check.Status != StatusWarn {
|
||||
t.Fatalf("provider.model status = %s, want warn: %#v", check.Status, check)
|
||||
}
|
||||
if !strings.Contains(check.Message, "runtime will pass it through") || !strings.Contains(check.Message, "doctor --connectivity") {
|
||||
t.Fatalf("provider.model message should explain pass-through connectivity validation, got %q", check.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderModelMissingModelFails(t *testing.T) {
|
||||
report := Run(Options{
|
||||
Now: fixedDoctorClock("2026-06-04T15:45:00Z"),
|
||||
Runtime: "go",
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "ollama-cloud",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://ollama.com/v1",
|
||||
Model: " ",
|
||||
},
|
||||
})
|
||||
|
||||
if report.OK {
|
||||
t.Fatalf("missing model should fail: %#v", report)
|
||||
}
|
||||
check := report.Check("provider.model")
|
||||
if check == nil || check.Status != StatusFail || !strings.Contains(check.Message, "required") {
|
||||
t.Fatalf("expected missing model failure: %#v", report.Checks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderModelBuiltInUnknownModelFails(t *testing.T) {
|
||||
report := Run(Options{
|
||||
Now: fixedDoctorClock("2026-06-04T15:45:00Z"),
|
||||
Runtime: "go",
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "openai",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
BaseURL: config.OpenAIBaseURL,
|
||||
Model: "vendor/new-dynamic-model",
|
||||
},
|
||||
})
|
||||
|
||||
if report.OK {
|
||||
t.Fatalf("built-in provider unknown model should fail: %#v", report)
|
||||
}
|
||||
check := report.Check("provider.model")
|
||||
if check == nil || check.Status != StatusFail || !strings.Contains(check.Message, "unknown Zero model") {
|
||||
t.Fatalf("expected built-in unknown model failure: %#v", report.Checks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderModelRegisteredProviderMismatchFails(t *testing.T) {
|
||||
report := Run(Options{
|
||||
Now: fixedDoctorClock("2026-06-04T15:45:00Z"),
|
||||
Runtime: "go",
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "openai",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
BaseURL: config.OpenAIBaseURL,
|
||||
Model: "gemini-2.5-pro",
|
||||
},
|
||||
})
|
||||
|
||||
if report.OK {
|
||||
t.Fatalf("registered provider mismatch should fail: %#v", report)
|
||||
}
|
||||
check := report.Check("provider.model")
|
||||
if check == nil || check.Status != StatusFail || !strings.Contains(check.Message, "not available for provider") {
|
||||
t.Fatalf("expected registered provider mismatch failure: %#v", report.Checks)
|
||||
}
|
||||
}
|
||||
|
||||
func writeDoctorConfig(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
+79
-187
@@ -2,9 +2,6 @@ package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -14,20 +11,56 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
)
|
||||
|
||||
// ServerAuthOAuth is the value of an MCP server's auth field that selects the
|
||||
// OAuth 2.0 + PKCE authorization-code flow.
|
||||
const ServerAuthOAuth = "oauth"
|
||||
|
||||
const (
|
||||
wellKnownAuthServerPath = "/.well-known/oauth-authorization-server"
|
||||
defaultLoginTimeout = 3 * time.Minute
|
||||
// pkceVerifierBytes yields a 43-character base64url verifier, the minimum
|
||||
// length permitted by the PKCE spec while remaining high-entropy.
|
||||
pkceVerifierBytes = 32
|
||||
stateBytes = 32
|
||||
)
|
||||
const defaultLoginTimeout = 3 * time.Minute
|
||||
|
||||
// MCP OAuth delegates its transport/identity-agnostic engine to internal/oauth
|
||||
// (PKCE, RFC 8414 discovery, authorize-URL build, token exchange/refresh) so the
|
||||
// two share one implementation. MCP keeps its own LoginOptions/Login
|
||||
// orchestration, OAuthConfig/StoredToken types, loopback handling, CLI, and
|
||||
// on-disk token format — all behavior-preserving. The shared engine also adds an
|
||||
// https-only token-endpoint guard (loopback exempt), a hardening MCP inherits.
|
||||
|
||||
// pkceToParams converts the shared PKCE pair to MCP's local type.
|
||||
func pkceToParams(p oauth.PKCE) pkceParams {
|
||||
return pkceParams{Verifier: p.Verifier, Challenge: p.Challenge, Method: p.Method}
|
||||
}
|
||||
|
||||
// pkceToOAuth converts MCP's local PKCE type back to the shared type.
|
||||
func pkceToOAuth(p pkceParams) oauth.PKCE {
|
||||
return oauth.PKCE{Verifier: p.Verifier, Challenge: p.Challenge, Method: p.Method}
|
||||
}
|
||||
|
||||
// configFor builds the shared oauth.Config from MCP's OAuthConfig + resolved
|
||||
// endpoints for a flow.
|
||||
func configFor(cfg OAuthConfig, metadata authServerMetadata) oauth.Config {
|
||||
return oauth.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
Scopes: cfg.Scopes,
|
||||
AuthorizationEndpoint: metadata.AuthorizationEndpoint,
|
||||
TokenEndpoint: metadata.TokenEndpoint,
|
||||
}
|
||||
}
|
||||
|
||||
// tokenToStored converts a shared oauth.Token to MCP's StoredToken (MCP does not
|
||||
// use the Account field).
|
||||
func tokenToStored(t oauth.Token) StoredToken {
|
||||
return StoredToken{
|
||||
AccessToken: t.AccessToken,
|
||||
RefreshToken: t.RefreshToken,
|
||||
TokenType: t.TokenType,
|
||||
Scopes: t.Scopes,
|
||||
ExpiresAt: t.ExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
// OAuthConfig describes how to authenticate to a remote MCP server using OAuth.
|
||||
// Endpoints may be discovered from the server's metadata document; explicit
|
||||
@@ -84,34 +117,20 @@ type authorizationFlow struct {
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// discoverAuthorizationServer fetches the OAuth 2.0 authorization server
|
||||
// metadata document published at the well-known path under the given base URL.
|
||||
// discoverAuthorizationServer fetches the RFC 8414 authorization server metadata
|
||||
// at the well-known path under baseURL, via the shared engine.
|
||||
func discoverAuthorizationServer(ctx context.Context, client *http.Client, baseURL string) (authServerMetadata, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
metadataURL, err := joinWellKnown(baseURL)
|
||||
meta, err := oauth.DiscoverAuthorizationServer(ctx, client, baseURL)
|
||||
if err != nil {
|
||||
return authServerMetadata{}, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, nil)
|
||||
if err != nil {
|
||||
return authServerMetadata{}, err
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return authServerMetadata{}, fmt.Errorf("fetch authorization server metadata: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
return authServerMetadata{}, fmt.Errorf("authorization server metadata returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
var metadata authServerMetadata
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&metadata); err != nil {
|
||||
return authServerMetadata{}, fmt.Errorf("decode authorization server metadata: %w", err)
|
||||
}
|
||||
return metadata, nil
|
||||
return authServerMetadata{
|
||||
Issuer: meta.Issuer,
|
||||
AuthorizationEndpoint: meta.AuthorizationEndpoint,
|
||||
TokenEndpoint: meta.TokenEndpoint,
|
||||
RegistrationEndpoint: meta.RegistrationEndpoint,
|
||||
ScopesSupported: meta.ScopesSupported,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveAuthorizationServer discovers metadata and applies explicit config
|
||||
@@ -149,44 +168,23 @@ func resolveAuthorizationServer(ctx context.Context, client *http.Client, baseUR
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// newPKCE generates a high-entropy code verifier and its S256 challenge.
|
||||
// newPKCE generates a high-entropy code verifier and its S256 challenge via the
|
||||
// shared engine.
|
||||
func newPKCE() (pkceParams, error) {
|
||||
verifierRaw := make([]byte, pkceVerifierBytes)
|
||||
if _, err := rand.Read(verifierRaw); err != nil {
|
||||
return pkceParams{}, fmt.Errorf("generate PKCE verifier: %w", err)
|
||||
p, err := oauth.NewPKCE()
|
||||
if err != nil {
|
||||
return pkceParams{}, err
|
||||
}
|
||||
verifier := base64.RawURLEncoding.EncodeToString(verifierRaw)
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
return pkceParams{Verifier: verifier, Challenge: challenge, Method: "S256"}, nil
|
||||
return pkceToParams(p), nil
|
||||
}
|
||||
|
||||
func newState() (string, error) {
|
||||
raw := make([]byte, stateBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("generate state: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
return oauth.NewState()
|
||||
}
|
||||
|
||||
// authorizationURL builds the authorization request URL.
|
||||
// authorizationURL builds the authorization request URL via the shared engine.
|
||||
func (flow *authorizationFlow) authorizationURL(redirectURI string) (string, error) {
|
||||
parsed, err := url.Parse(flow.metadata.AuthorizationEndpoint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse authorization endpoint: %w", err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set("response_type", "code")
|
||||
query.Set("client_id", flow.config.ClientID)
|
||||
query.Set("redirect_uri", redirectURI)
|
||||
query.Set("state", flow.state)
|
||||
query.Set("code_challenge", flow.pkce.Challenge)
|
||||
query.Set("code_challenge_method", flow.pkce.Method)
|
||||
if len(flow.config.Scopes) > 0 {
|
||||
query.Set("scope", strings.Join(flow.config.Scopes, " "))
|
||||
}
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String(), nil
|
||||
return oauth.BuildAuthorizationURL(configFor(flow.config, flow.metadata), pkceToOAuth(flow.pkce), flow.state, redirectURI, nil)
|
||||
}
|
||||
|
||||
// parseCallback validates the redirect query and returns the authorization
|
||||
@@ -209,117 +207,30 @@ func (flow *authorizationFlow) parseCallback(values url.Values) (string, error)
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// exchangeCode swaps an authorization code + PKCE verifier for tokens.
|
||||
// exchangeCode swaps an authorization code + PKCE verifier for tokens via the
|
||||
// shared engine.
|
||||
func (flow *authorizationFlow) exchangeCode(ctx context.Context, code string, redirectURI string) (StoredToken, error) {
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code", code)
|
||||
form.Set("redirect_uri", redirectURI)
|
||||
form.Set("client_id", flow.config.ClientID)
|
||||
form.Set("code_verifier", flow.pkce.Verifier)
|
||||
if secret := strings.TrimSpace(flow.config.ClientSecret); secret != "" {
|
||||
form.Set("client_secret", secret)
|
||||
token, err := oauth.ExchangeCode(ctx, flow.httpClient, configFor(flow.config, flow.metadata), code, flow.pkce.Verifier, redirectURI, flow.now)
|
||||
if err != nil {
|
||||
return StoredToken{}, err
|
||||
}
|
||||
return postTokenRequest(ctx, flow.httpClient, flow.metadata.TokenEndpoint, form, StoredToken{Scopes: flow.config.Scopes}, flow.now)
|
||||
return tokenToStored(token), nil
|
||||
}
|
||||
|
||||
// refreshAccessToken exchanges a refresh token for a fresh access token. A
|
||||
// response that omits a new refresh token preserves the previous one.
|
||||
// refreshAccessToken exchanges a refresh token for a fresh access token via the
|
||||
// shared engine. A response that omits a new refresh token preserves the
|
||||
// previous one.
|
||||
func refreshAccessToken(ctx context.Context, client *http.Client, cfg OAuthConfig, current StoredToken, now func() time.Time) (StoredToken, error) {
|
||||
refresh := strings.TrimSpace(current.RefreshToken)
|
||||
if refresh == "" {
|
||||
return StoredToken{}, errors.New("no refresh token available")
|
||||
}
|
||||
tokenEndpoint := strings.TrimSpace(cfg.TokenEndpoint)
|
||||
if tokenEndpoint == "" {
|
||||
return StoredToken{}, errors.New("no token endpoint configured for refresh")
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("refresh_token", refresh)
|
||||
form.Set("client_id", cfg.ClientID)
|
||||
if secret := strings.TrimSpace(cfg.ClientSecret); secret != "" {
|
||||
form.Set("client_secret", secret)
|
||||
}
|
||||
if len(cfg.Scopes) > 0 {
|
||||
form.Set("scope", strings.Join(cfg.Scopes, " "))
|
||||
}
|
||||
refreshed, err := postTokenRequest(ctx, client, tokenEndpoint, form, StoredToken{Scopes: current.Scopes, RefreshToken: refresh}, now)
|
||||
token, err := oauth.Refresh(ctx, client, oauth.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
Scopes: cfg.Scopes,
|
||||
TokenEndpoint: cfg.TokenEndpoint,
|
||||
}, oauth.Token{AccessToken: current.AccessToken, RefreshToken: current.RefreshToken, TokenType: current.TokenType, Scopes: current.Scopes, ExpiresAt: current.ExpiresAt}, now)
|
||||
if err != nil {
|
||||
return StoredToken{}, err
|
||||
}
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
// tokenResponse is the JSON returned by the token endpoint.
|
||||
type tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
|
||||
// postTokenRequest performs a token endpoint POST and maps the response onto a
|
||||
// StoredToken. The base token supplies values to preserve (e.g. an existing
|
||||
// refresh token or scope set) when the response omits them.
|
||||
func postTokenRequest(ctx context.Context, client *http.Client, tokenEndpoint string, form url.Values, base StoredToken, now func() time.Time) (StoredToken, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return StoredToken{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return StoredToken{}, fmt.Errorf("token request failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||
var parsed tokenResponse
|
||||
if len(body) > 0 {
|
||||
// A malformed body on an error status is reported as a status error below.
|
||||
_ = json.Unmarshal(body, &parsed)
|
||||
}
|
||||
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
if parsed.Error != "" {
|
||||
if parsed.ErrorDescription != "" {
|
||||
return StoredToken{}, fmt.Errorf("token endpoint error %q: %s", parsed.Error, parsed.ErrorDescription)
|
||||
}
|
||||
return StoredToken{}, fmt.Errorf("token endpoint error %q", parsed.Error)
|
||||
}
|
||||
return StoredToken{}, fmt.Errorf("token endpoint returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
if strings.TrimSpace(parsed.AccessToken) == "" {
|
||||
return StoredToken{}, errors.New("token endpoint returned no access token")
|
||||
}
|
||||
|
||||
token := base
|
||||
token.AccessToken = parsed.AccessToken
|
||||
if strings.TrimSpace(parsed.RefreshToken) != "" {
|
||||
token.RefreshToken = parsed.RefreshToken
|
||||
}
|
||||
if strings.TrimSpace(parsed.TokenType) != "" {
|
||||
token.TokenType = parsed.TokenType
|
||||
}
|
||||
if parsed.ExpiresIn > 0 {
|
||||
token.ExpiresAt = now().Add(time.Duration(parsed.ExpiresIn) * time.Second).UTC()
|
||||
}
|
||||
if scope := strings.TrimSpace(parsed.Scope); scope != "" {
|
||||
token.Scopes = strings.Fields(scope)
|
||||
}
|
||||
return token, nil
|
||||
return tokenToStored(token), nil
|
||||
}
|
||||
|
||||
// registerClient performs dynamic client registration against the registration
|
||||
@@ -505,22 +416,3 @@ func Login(ctx context.Context, options LoginOptions) (StoredToken, error) {
|
||||
return StoredToken{}, fmt.Errorf("timed out waiting for OAuth authorization callback: %w", loginCtx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func joinWellKnown(baseURL string) (string, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(baseURL))
|
||||
if err != nil || parsed.Host == "" {
|
||||
return "", fmt.Errorf("invalid base URL for metadata discovery: %q", baseURL)
|
||||
}
|
||||
// RFC 8414: the well-known segment is inserted between the host and any issuer
|
||||
// path component, so a path-based issuer (https://host/tenant-a) is probed at
|
||||
// https://host/.well-known/oauth-authorization-server/tenant-a — not at the
|
||||
// host root (which would break tenant-scoped discovery).
|
||||
issuerPath := strings.Trim(parsed.Path, "/")
|
||||
parsed.Path = wellKnownAuthServerPath
|
||||
if issuerPath != "" {
|
||||
parsed.Path = strings.TrimRight(wellKnownAuthServerPath, "/") + "/" + issuerPath
|
||||
}
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return parsed.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const deviceGrantType = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
|
||||
// defaultDeviceCodeLifetime bounds a device authorization that omits expires_in,
|
||||
// so the expiry gate in PollDeviceToken is always active (fail closed).
|
||||
const defaultDeviceCodeLifetime = 10 * time.Minute
|
||||
|
||||
// DeviceAuth is the result of an RFC 8628 device-authorization request.
|
||||
type DeviceAuth struct {
|
||||
DeviceCode string
|
||||
UserCode string
|
||||
VerificationURI string
|
||||
VerificationURIComplete string
|
||||
Interval time.Duration
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type deviceAuthResponse struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Interval int64 `json:"interval"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
|
||||
// RequestDeviceCode performs the RFC 8628 device-authorization request. This is
|
||||
// the headless/SSH login path (no browser, no loopback).
|
||||
func RequestDeviceCode(ctx context.Context, client *http.Client, cfg Config, now func() time.Time) (DeviceAuth, error) {
|
||||
endpoint := trimmed(cfg.DeviceAuthorizationEndpoint)
|
||||
if endpoint == "" {
|
||||
return DeviceAuth{}, errors.New("oauth: provider has no device authorization endpoint")
|
||||
}
|
||||
if err := validateTokenEndpoint(endpoint); err != nil {
|
||||
return DeviceAuth{}, err
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("client_id", cfg.ClientID)
|
||||
if secret := trimmed(cfg.ClientSecret); secret != "" {
|
||||
// Confidential clients must authenticate on the device endpoint too,
|
||||
// consistent with the token poll (pollDeviceOnce).
|
||||
form.Set("client_secret", secret)
|
||||
}
|
||||
if len(cfg.Scopes) > 0 {
|
||||
form.Set("scope", strings.Join(cfg.Scopes, " "))
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return DeviceAuth{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return DeviceAuth{}, fmt.Errorf("oauth: device authorization request failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, tokenResponseLimit))
|
||||
var parsed deviceAuthResponse
|
||||
if len(body) > 0 {
|
||||
_ = json.Unmarshal(body, &parsed)
|
||||
}
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
if parsed.Error != "" {
|
||||
return DeviceAuth{}, fmt.Errorf("oauth: device authorization error %q", parsed.Error)
|
||||
}
|
||||
return DeviceAuth{}, fmt.Errorf("oauth: device authorization returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
if trimmed(parsed.DeviceCode) == "" || trimmed(parsed.UserCode) == "" {
|
||||
return DeviceAuth{}, errors.New("oauth: device authorization response missing device_code/user_code")
|
||||
}
|
||||
interval := time.Duration(parsed.Interval) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 5 * time.Second // RFC 8628 default
|
||||
}
|
||||
auth := DeviceAuth{
|
||||
DeviceCode: parsed.DeviceCode,
|
||||
UserCode: parsed.UserCode,
|
||||
VerificationURI: parsed.VerificationURI,
|
||||
VerificationURIComplete: parsed.VerificationURIComplete,
|
||||
Interval: interval,
|
||||
}
|
||||
lifetime := time.Duration(parsed.ExpiresIn) * time.Second
|
||||
if lifetime <= 0 {
|
||||
// Fail closed: a response without expires_in must still get a bounded
|
||||
// lifetime so the poll loop's expiry gate stays active.
|
||||
lifetime = defaultDeviceCodeLifetime
|
||||
}
|
||||
auth.ExpiresAt = now().Add(lifetime)
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
// PollDeviceToken polls the token endpoint for the device grant until the user
|
||||
// approves, the device code expires, ctx is done, or a terminal error occurs. It
|
||||
// honors authorization_pending (keep waiting), slow_down (increase the
|
||||
// interval), and expired_token.
|
||||
func PollDeviceToken(ctx context.Context, client *http.Client, cfg Config, auth DeviceAuth, now func() time.Time) (Token, error) {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
interval := auth.Interval
|
||||
if interval <= 0 {
|
||||
interval = 5 * time.Second
|
||||
}
|
||||
for {
|
||||
if !auth.ExpiresAt.IsZero() && !auth.ExpiresAt.After(now()) {
|
||||
return Token{}, errors.New("oauth: device code expired before authorization")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Token{}, fmt.Errorf("oauth: device authorization canceled: %w", ctx.Err())
|
||||
case <-time.After(interval):
|
||||
}
|
||||
// Re-check expiry after sleeping: slow_down (or a long interval) can push
|
||||
// the next poll past ExpiresAt, and we must not poll after the deadline.
|
||||
if !auth.ExpiresAt.IsZero() && !auth.ExpiresAt.After(now()) {
|
||||
return Token{}, errors.New("oauth: device code expired before authorization")
|
||||
}
|
||||
token, err := pollDeviceOnce(ctx, client, cfg, auth.DeviceCode, now)
|
||||
switch {
|
||||
case err == nil:
|
||||
return token, nil
|
||||
case errors.Is(err, ErrAuthorizationPending):
|
||||
// keep waiting at the current interval
|
||||
case errors.Is(err, ErrSlowDown):
|
||||
interval += 5 * time.Second // RFC 8628: add 5s on slow_down
|
||||
default:
|
||||
return Token{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pollDeviceOnce makes a single device-token poll, translating the RFC 8628
|
||||
// error codes into ErrAuthorizationPending / ErrSlowDown / terminal errors.
|
||||
func pollDeviceOnce(ctx context.Context, client *http.Client, cfg Config, deviceCode string, now func() time.Time) (Token, error) {
|
||||
if err := validateTokenEndpoint(cfg.TokenEndpoint); err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", deviceGrantType)
|
||||
form.Set("device_code", deviceCode)
|
||||
form.Set("client_id", cfg.ClientID)
|
||||
if secret := trimmed(cfg.ClientSecret); secret != "" {
|
||||
form.Set("client_secret", secret)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, trimmed(cfg.TokenEndpoint), strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return Token{}, fmt.Errorf("oauth: device token poll failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, tokenResponseLimit))
|
||||
var parsed tokenResponse
|
||||
if len(body) > 0 {
|
||||
_ = json.Unmarshal(body, &parsed)
|
||||
}
|
||||
if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices && trimmed(parsed.AccessToken) != "" {
|
||||
token := Token{Scopes: cfg.Scopes}
|
||||
token.AccessToken = parsed.AccessToken
|
||||
token.RefreshToken = parsed.RefreshToken
|
||||
token.TokenType = parsed.TokenType
|
||||
if parsed.ExpiresIn > 0 {
|
||||
token.ExpiresAt = now().Add(time.Duration(parsed.ExpiresIn) * time.Second).UTC()
|
||||
}
|
||||
if scope := trimmed(parsed.Scope); scope != "" {
|
||||
token.Scopes = strings.Fields(scope)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
switch parsed.Error {
|
||||
case "authorization_pending":
|
||||
return Token{}, ErrAuthorizationPending
|
||||
case "slow_down":
|
||||
return Token{}, ErrSlowDown
|
||||
case "expired_token":
|
||||
return Token{}, errors.New("oauth: device code expired before authorization")
|
||||
case "access_denied":
|
||||
return Token{}, errors.New("oauth: authorization denied by the user")
|
||||
case "":
|
||||
return Token{}, fmt.Errorf("oauth: device token poll returned HTTP %d", response.StatusCode)
|
||||
default:
|
||||
return Token{}, fmt.Errorf("oauth: device token error %q", parsed.Error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRequestDeviceCodeSendsClientSecret(t *testing.T) {
|
||||
var gotSecret string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
gotSecret = r.Form.Get("client_secret")
|
||||
_, _ = w.Write([]byte(`{"device_code":"dc","user_code":"U","verification_uri":"https://x","expires_in":600,"interval":5}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
// A confidential client must authenticate on the device endpoint too.
|
||||
_, err := RequestDeviceCode(context.Background(), server.Client(), Config{ClientID: "c", ClientSecret: "shh", DeviceAuthorizationEndpoint: server.URL}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RequestDeviceCode: %v", err)
|
||||
}
|
||||
if gotSecret != "shh" {
|
||||
t.Fatalf("client_secret = %q, want shh", gotSecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestDeviceCode(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"device_code":"dc","user_code":"WXYZ-1234","verification_uri":"https://x/dev","verification_uri_complete":"https://x/dev?c=WXYZ","expires_in":600,"interval":3}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := Config{ClientID: "c", DeviceAuthorizationEndpoint: server.URL, Scopes: []string{"a"}}
|
||||
auth, err := RequestDeviceCode(context.Background(), server.Client(), cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RequestDeviceCode: %v", err)
|
||||
}
|
||||
if auth.DeviceCode != "dc" || auth.UserCode != "WXYZ-1234" {
|
||||
t.Fatalf("device auth = %+v", auth)
|
||||
}
|
||||
if auth.Interval != 3*time.Second {
|
||||
t.Fatalf("interval = %v, want 3s", auth.Interval)
|
||||
}
|
||||
if auth.VerificationURIComplete == "" {
|
||||
t.Fatal("missing verification_uri_complete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestDeviceCodeDefaultsInterval(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"device_code":"dc","user_code":"U","verification_uri":"https://x"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
auth, err := RequestDeviceCode(context.Background(), server.Client(), Config{ClientID: "c", DeviceAuthorizationEndpoint: server.URL}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RequestDeviceCode: %v", err)
|
||||
}
|
||||
if auth.Interval != 5*time.Second {
|
||||
t.Fatalf("default interval = %v, want 5s", auth.Interval)
|
||||
}
|
||||
// A response without expires_in must still get a bounded expiry (fail closed),
|
||||
// so the poll loop's expiry gate stays active.
|
||||
if auth.ExpiresAt.IsZero() {
|
||||
t.Fatal("missing expires_in must default to a bounded ExpiresAt, got zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollDeviceTokenHonorsPendingThenSucceeds(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
n := calls.Add(1)
|
||||
if n < 3 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"authorization_pending"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"access_token":"at","token_type":"Bearer","expires_in":3600}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
|
||||
// Small interval + future expiry so the loop is fast.
|
||||
auth := DeviceAuth{DeviceCode: "dc", Interval: 5 * time.Millisecond, ExpiresAt: time.Now().Add(5 * time.Second)}
|
||||
tok, err := PollDeviceToken(context.Background(), server.Client(), cfg, auth, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PollDeviceToken: %v", err)
|
||||
}
|
||||
if tok.AccessToken != "at" {
|
||||
t.Fatalf("token = %+v", tok)
|
||||
}
|
||||
if calls.Load() != 3 {
|
||||
t.Fatalf("polled %d times, want 3 (2 pending + 1 success)", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollDeviceTokenSlowDown(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if calls.Add(1) == 1 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"slow_down"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"access_token":"at"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
|
||||
// Expiry well beyond the slow_down back-off (+5s) so the success path never
|
||||
// depends on polling after the device code has expired.
|
||||
auth := DeviceAuth{DeviceCode: "dc", Interval: 5 * time.Millisecond, ExpiresAt: time.Now().Add(30 * time.Second)}
|
||||
tok, err := PollDeviceToken(context.Background(), server.Client(), cfg, auth, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PollDeviceToken (slow_down): %v", err)
|
||||
}
|
||||
if tok.AccessToken != "at" {
|
||||
t.Fatalf("token = %+v", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollDeviceTokenExpired(t *testing.T) {
|
||||
cfg := Config{ClientID: "c", TokenEndpoint: "https://unused/token"}
|
||||
auth := DeviceAuth{DeviceCode: "dc", Interval: time.Millisecond, ExpiresAt: time.Now().Add(-time.Second)}
|
||||
_, err := PollDeviceToken(context.Background(), http.DefaultClient, cfg, auth, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected expired error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollDeviceTokenAccessDenied(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"access_denied"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
|
||||
auth := DeviceAuth{DeviceCode: "dc", Interval: 5 * time.Millisecond, ExpiresAt: time.Now().Add(5 * time.Second)}
|
||||
_, err := PollDeviceToken(context.Background(), server.Client(), cfg, auth, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "denied") {
|
||||
t.Fatalf("err = %v, want access denied", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const wellKnownAuthServerPath = "/.well-known/oauth-authorization-server"
|
||||
|
||||
// ServerMetadata is the subset of an RFC 8414 authorization-server metadata
|
||||
// document the engine consumes.
|
||||
type ServerMetadata struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
|
||||
RegistrationEndpoint string `json:"registration_endpoint"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
}
|
||||
|
||||
// DiscoverAuthorizationServer fetches the RFC 8414 metadata document at the
|
||||
// well-known path under baseURL.
|
||||
func DiscoverAuthorizationServer(ctx context.Context, client *http.Client, baseURL string) (ServerMetadata, error) {
|
||||
metadataURL, err := joinWellKnown(baseURL)
|
||||
if err != nil {
|
||||
return ServerMetadata{}, err
|
||||
}
|
||||
return fetchMetadata(ctx, client, metadataURL)
|
||||
}
|
||||
|
||||
// fetchMetadata GETs and decodes an authorization-server metadata document at an
|
||||
// already-resolved URL (no well-known recomputation), so callers can also use it
|
||||
// for the OIDC openid-configuration path.
|
||||
func fetchMetadata(ctx context.Context, client *http.Client, metadataURL string) (ServerMetadata, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, nil)
|
||||
if err != nil {
|
||||
return ServerMetadata{}, err
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return ServerMetadata{}, fmt.Errorf("oauth: fetch authorization server metadata: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
return ServerMetadata{}, fmt.Errorf("oauth: authorization server metadata returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
var metadata ServerMetadata
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, tokenResponseLimit)).Decode(&metadata); err != nil {
|
||||
return ServerMetadata{}, fmt.Errorf("oauth: decode authorization server metadata: %w", err)
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// joinWellKnown builds the RFC 8414 metadata URL, inserting the well-known
|
||||
// segment between host and any issuer path component (so a path-based issuer is
|
||||
// probed tenant-scoped, not at the host root).
|
||||
func joinWellKnown(baseURL string) (string, error) {
|
||||
parsed, err := url.Parse(trimmed(baseURL))
|
||||
if err != nil || parsed.Host == "" {
|
||||
return "", fmt.Errorf("oauth: invalid base URL for metadata discovery: %q", baseURL)
|
||||
}
|
||||
issuerPath := strings.Trim(parsed.Path, "/")
|
||||
parsed.Path = wellKnownAuthServerPath
|
||||
if issuerPath != "" {
|
||||
parsed.Path = strings.TrimRight(wellKnownAuthServerPath, "/") + "/" + issuerPath
|
||||
}
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return parsed.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tokenResponseLimit = 1 << 20 // 1 MiB cap on token-endpoint bodies
|
||||
|
||||
// tokenResponse is the JSON returned by a token endpoint.
|
||||
type tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
|
||||
// BuildAuthorizationURL constructs the authorization request URL for an
|
||||
// authorization-code + PKCE flow. PKCE S256 is always included; a non-S256
|
||||
// method is refused (ErrPKCEDowngrade). extraParams (and cfg.ExtraAuthParams)
|
||||
// are appended last.
|
||||
func BuildAuthorizationURL(cfg Config, pkce PKCE, state, redirectURI string, extraParams map[string]string) (string, error) {
|
||||
if pkce.Method != MethodS256 {
|
||||
return "", ErrPKCEDowngrade
|
||||
}
|
||||
endpoint := trimmed(cfg.AuthorizationEndpoint)
|
||||
if endpoint == "" {
|
||||
return "", errors.New("oauth: no authorization endpoint configured")
|
||||
}
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("oauth: parse authorization endpoint: %w", err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set("response_type", "code")
|
||||
query.Set("client_id", cfg.ClientID)
|
||||
query.Set("redirect_uri", redirectURI)
|
||||
query.Set("state", state)
|
||||
query.Set("code_challenge", pkce.Challenge)
|
||||
query.Set("code_challenge_method", pkce.Method)
|
||||
if len(cfg.Scopes) > 0 {
|
||||
query.Set("scope", strings.Join(cfg.Scopes, " "))
|
||||
}
|
||||
// Extra params must never override the reserved OAuth/PKCE fields above
|
||||
// (e.g. forcing code_challenge_method=plain or rewriting state/redirect_uri),
|
||||
// which would break the flow's security guarantees.
|
||||
for k, v := range cfg.ExtraAuthParams {
|
||||
if isReservedAuthParam(k) {
|
||||
continue
|
||||
}
|
||||
query.Set(k, v)
|
||||
}
|
||||
for k, v := range extraParams {
|
||||
if isReservedAuthParam(k) {
|
||||
continue
|
||||
}
|
||||
query.Set(k, v)
|
||||
}
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
// isReservedAuthParam reports whether a query key is one the engine controls and
|
||||
// must not be overridable by caller-supplied extra params.
|
||||
func isReservedAuthParam(key string) bool {
|
||||
switch key {
|
||||
case "response_type", "client_id", "redirect_uri", "state", "code_challenge", "code_challenge_method":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// validateTokenEndpoint refuses to send a credential to a token endpoint that is
|
||||
// not https, unless it is a loopback host (mirrors oauth2-client.js
|
||||
// validateTokenEndpointURL). This prevents leaking codes/tokens over cleartext.
|
||||
func validateTokenEndpoint(endpoint string) error {
|
||||
parsed, err := url.Parse(trimmed(endpoint))
|
||||
if err != nil || parsed.Host == "" {
|
||||
return fmt.Errorf("oauth: invalid token endpoint %q", endpoint)
|
||||
}
|
||||
if parsed.Scheme == "https" {
|
||||
return nil
|
||||
}
|
||||
if parsed.Scheme == "http" && isLoopbackHost(parsed.Hostname()) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: %s", ErrInsecureTokenEndpoint, parsed.Scheme+"://"+parsed.Host)
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return ip.IsLoopback()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ExchangeCode swaps an authorization code + PKCE verifier for tokens.
|
||||
func ExchangeCode(ctx context.Context, client *http.Client, cfg Config, code, verifier, redirectURI string, now func() time.Time) (Token, error) {
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code", code)
|
||||
form.Set("redirect_uri", redirectURI)
|
||||
form.Set("client_id", cfg.ClientID)
|
||||
form.Set("code_verifier", verifier)
|
||||
if secret := trimmed(cfg.ClientSecret); secret != "" {
|
||||
form.Set("client_secret", secret)
|
||||
}
|
||||
return PostToken(ctx, client, cfg.TokenEndpoint, form, Token{Scopes: cfg.Scopes}, now)
|
||||
}
|
||||
|
||||
// Refresh exchanges a refresh token for a fresh access token. A response that
|
||||
// omits a new refresh token preserves the current one.
|
||||
func Refresh(ctx context.Context, client *http.Client, cfg Config, current Token, now func() time.Time) (Token, error) {
|
||||
refresh := trimmed(current.RefreshToken)
|
||||
if refresh == "" {
|
||||
return Token{}, ErrNoRefreshToken
|
||||
}
|
||||
if trimmed(cfg.TokenEndpoint) == "" {
|
||||
return Token{}, errors.New("oauth: no token endpoint configured for refresh")
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("refresh_token", refresh)
|
||||
form.Set("client_id", cfg.ClientID)
|
||||
if secret := trimmed(cfg.ClientSecret); secret != "" {
|
||||
form.Set("client_secret", secret)
|
||||
}
|
||||
if len(cfg.Scopes) > 0 {
|
||||
form.Set("scope", strings.Join(cfg.Scopes, " "))
|
||||
}
|
||||
base := Token{Scopes: current.Scopes, RefreshToken: refresh, Account: current.Account}
|
||||
return PostToken(ctx, client, cfg.TokenEndpoint, form, base, now)
|
||||
}
|
||||
|
||||
// PostToken performs a token-endpoint POST and maps the response onto a Token.
|
||||
// The https guard is applied first. The base token supplies values to preserve
|
||||
// (e.g. an existing refresh token or scopes) when the response omits them.
|
||||
// Error messages carry only the server's error/error_description — never the raw
|
||||
// body — so token material in an unexpected payload is not leaked.
|
||||
func PostToken(ctx context.Context, client *http.Client, tokenEndpoint string, form url.Values, base Token, now func() time.Time) (Token, error) {
|
||||
if err := validateTokenEndpoint(tokenEndpoint); err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, trimmed(tokenEndpoint), strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return Token{}, fmt.Errorf("oauth: token request failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, tokenResponseLimit))
|
||||
var parsed tokenResponse
|
||||
if len(body) > 0 {
|
||||
_ = json.Unmarshal(body, &parsed)
|
||||
}
|
||||
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
if parsed.Error != "" {
|
||||
if parsed.ErrorDescription != "" {
|
||||
return Token{}, fmt.Errorf("oauth: token endpoint error %q: %s", parsed.Error, parsed.ErrorDescription)
|
||||
}
|
||||
return Token{}, fmt.Errorf("oauth: token endpoint error %q", parsed.Error)
|
||||
}
|
||||
return Token{}, fmt.Errorf("oauth: token endpoint returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
if trimmed(parsed.AccessToken) == "" {
|
||||
return Token{}, errors.New("oauth: token endpoint returned no access token")
|
||||
}
|
||||
|
||||
token := base
|
||||
token.AccessToken = parsed.AccessToken
|
||||
if trimmed(parsed.RefreshToken) != "" {
|
||||
token.RefreshToken = parsed.RefreshToken
|
||||
}
|
||||
if trimmed(parsed.TokenType) != "" {
|
||||
token.TokenType = parsed.TokenType
|
||||
}
|
||||
if parsed.ExpiresIn > 0 {
|
||||
token.ExpiresAt = now().Add(time.Duration(parsed.ExpiresIn) * time.Second).UTC()
|
||||
}
|
||||
if scope := trimmed(parsed.Scope); scope != "" {
|
||||
token.Scopes = strings.Fields(scope)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewPKCE(t *testing.T) {
|
||||
p, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatalf("NewPKCE: %v", err)
|
||||
}
|
||||
if p.Method != MethodS256 {
|
||||
t.Fatalf("method = %q, want S256", p.Method)
|
||||
}
|
||||
if len(p.Verifier) != 43 { // base64url(32 bytes) = 43 chars
|
||||
t.Fatalf("verifier length = %d, want 43", len(p.Verifier))
|
||||
}
|
||||
sum := sha256.Sum256([]byte(p.Verifier))
|
||||
want := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
if p.Challenge != want {
|
||||
t.Fatalf("challenge = %q, want base64url(sha256(verifier))", p.Challenge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStateUnique(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 100; i++ {
|
||||
s, err := NewState()
|
||||
if err != nil {
|
||||
t.Fatalf("NewState: %v", err)
|
||||
}
|
||||
if s == "" || seen[s] {
|
||||
t.Fatalf("state not unique/non-empty: %q", s)
|
||||
}
|
||||
seen[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthorizationURL(t *testing.T) {
|
||||
cfg := Config{
|
||||
ClientID: "client-123",
|
||||
Scopes: []string{"read", "write"},
|
||||
AuthorizationEndpoint: "https://auth.example.com/authorize",
|
||||
ExtraAuthParams: map[string]string{"login_hint": "[email protected]"},
|
||||
}
|
||||
pkce := PKCE{Verifier: "v", Challenge: "chal", Method: MethodS256}
|
||||
raw, err := BuildAuthorizationURL(cfg, pkce, "state-xyz", "http://127.0.0.1:9/callback", map[string]string{"prompt": "login"})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildAuthorizationURL: %v", err)
|
||||
}
|
||||
u, _ := url.Parse(raw)
|
||||
q := u.Query()
|
||||
checks := map[string]string{
|
||||
"response_type": "code",
|
||||
"client_id": "client-123",
|
||||
"redirect_uri": "http://127.0.0.1:9/callback",
|
||||
"state": "state-xyz",
|
||||
"code_challenge": "chal",
|
||||
"code_challenge_method": "S256",
|
||||
"scope": "read write",
|
||||
"login_hint": "[email protected]",
|
||||
"prompt": "login",
|
||||
}
|
||||
for k, want := range checks {
|
||||
if got := q.Get(k); got != want {
|
||||
t.Errorf("query %q = %q, want %q", k, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthorizationURLIgnoresReservedExtras(t *testing.T) {
|
||||
cfg := Config{
|
||||
ClientID: "c",
|
||||
AuthorizationEndpoint: "https://auth.example.com/authorize",
|
||||
// A hostile config tries to weaken the flow via extra params.
|
||||
ExtraAuthParams: map[string]string{"state": "attacker", "code_challenge_method": "plain", "login_hint": "keep-me"},
|
||||
}
|
||||
pkce := PKCE{Challenge: "chal", Method: MethodS256}
|
||||
raw, err := BuildAuthorizationURL(cfg, pkce, "real-state", "http://127.0.0.1:9/cb", map[string]string{"redirect_uri": "http://evil/cb"})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildAuthorizationURL: %v", err)
|
||||
}
|
||||
parsed, _ := url.Parse(raw)
|
||||
q := parsed.Query()
|
||||
if q.Get("state") != "real-state" {
|
||||
t.Fatalf("state overridden: %q", q.Get("state"))
|
||||
}
|
||||
if q.Get("code_challenge_method") != "S256" {
|
||||
t.Fatalf("PKCE method downgraded: %q", q.Get("code_challenge_method"))
|
||||
}
|
||||
if q.Get("redirect_uri") != "http://127.0.0.1:9/cb" {
|
||||
t.Fatalf("redirect_uri overridden: %q", q.Get("redirect_uri"))
|
||||
}
|
||||
if q.Get("login_hint") != "keep-me" {
|
||||
t.Fatalf("non-reserved extra param should still apply: %q", q.Get("login_hint"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthorizationURLRejectsPlain(t *testing.T) {
|
||||
_, err := BuildAuthorizationURL(Config{AuthorizationEndpoint: "https://a/x"}, PKCE{Method: "plain"}, "s", "r", nil)
|
||||
if !errors.Is(err, ErrPKCEDowngrade) {
|
||||
t.Fatalf("err = %v, want ErrPKCEDowngrade", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenEndpoint(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"https://auth.example.com/token": true,
|
||||
"http://127.0.0.1:1455/token": true, // loopback exempt
|
||||
"http://localhost:1455/token": true,
|
||||
"http://[::1]:1455/token": true,
|
||||
"http://auth.example.com/token": false, // non-https, non-loopback
|
||||
"ftp://auth.example.com/token": false,
|
||||
}
|
||||
for endpoint, ok := range cases {
|
||||
err := validateTokenEndpoint(endpoint)
|
||||
if ok && err != nil {
|
||||
t.Errorf("validateTokenEndpoint(%q) = %v, want nil", endpoint, err)
|
||||
}
|
||||
if !ok && err == nil {
|
||||
t.Errorf("validateTokenEndpoint(%q) = nil, want error", endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeCodeSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
if r.FormValue("grant_type") != "authorization_code" || r.FormValue("code_verifier") != "verifier" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600,"scope":"read write"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
now := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
|
||||
tok, err := ExchangeCode(context.Background(), server.Client(), cfg, "code", "verifier", "http://127.0.0.1/cb", func() time.Time { return now })
|
||||
if err != nil {
|
||||
t.Fatalf("ExchangeCode: %v", err)
|
||||
}
|
||||
if tok.AccessToken != "at" || tok.RefreshToken != "rt" || tok.TokenType != "Bearer" {
|
||||
t.Fatalf("token = %+v", tok)
|
||||
}
|
||||
if !tok.ExpiresAt.Equal(now.Add(time.Hour)) {
|
||||
t.Fatalf("expiresAt = %v, want now+1h", tok.ExpiresAt)
|
||||
}
|
||||
if strings.Join(tok.Scopes, " ") != "read write" {
|
||||
t.Fatalf("scopes = %v", tok.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeCodeErrorRedactsBody(t *testing.T) {
|
||||
// The error body contains a stray access_token-looking field; it must NOT
|
||||
// surface in the returned error (only error/error_description do).
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"bad code","access_token":"SECRET-LEAK"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
|
||||
_, err := ExchangeCode(context.Background(), server.Client(), cfg, "code", "v", "http://127.0.0.1/cb", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid_grant") || !strings.Contains(err.Error(), "bad code") {
|
||||
t.Fatalf("error should carry error/description, got %q", err.Error())
|
||||
}
|
||||
if strings.Contains(err.Error(), "SECRET-LEAK") {
|
||||
t.Fatalf("error leaked raw body token material: %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostTokenRefusesInsecureEndpoint(t *testing.T) {
|
||||
_, err := PostToken(context.Background(), http.DefaultClient, "http://auth.example.com/token", url.Values{}, Token{}, nil)
|
||||
if !errors.Is(err, ErrInsecureTokenEndpoint) {
|
||||
t.Fatalf("err = %v, want ErrInsecureTokenEndpoint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshNoToken(t *testing.T) {
|
||||
_, err := Refresh(context.Background(), http.DefaultClient, Config{TokenEndpoint: "https://a/token"}, Token{}, nil)
|
||||
if !errors.Is(err, ErrNoRefreshToken) {
|
||||
t.Fatalf("err = %v, want ErrNoRefreshToken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshPreservesRefreshTokenWhenOmitted(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"access_token":"new-at","expires_in":3600}`)) // no refresh_token in response
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
|
||||
tok, err := Refresh(context.Background(), server.Client(), cfg, Token{RefreshToken: "keep-me"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Refresh: %v", err)
|
||||
}
|
||||
if tok.AccessToken != "new-at" || tok.RefreshToken != "keep-me" {
|
||||
t.Fatalf("refresh should preserve old refresh token: %+v", tok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
fileLockTimeout = 5 * time.Second
|
||||
fileLockStaleAfter = 30 * time.Second
|
||||
)
|
||||
|
||||
var lockSeq atomic.Uint64
|
||||
|
||||
// acquireFileLock takes a cross-process exclusive lock by creating lockPath with
|
||||
// O_EXCL. It retries with a short backoff until a timeout, breaking a lock whose
|
||||
// file is older than fileLockStaleAfter (so a crashed holder cannot deadlock the
|
||||
// store). Release is ownership-aware: it removes the lock only if it still holds
|
||||
// our token, so a stale-broken holder cannot delete a newer holder's lock.
|
||||
func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), now().UnixNano(), lockSeq.Add(1))
|
||||
deadline := now().Add(fileLockTimeout)
|
||||
for {
|
||||
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
// A partial/failed write would leave a lock file without our token, so
|
||||
// the releaser below could never delete it — stranding the lock for
|
||||
// other processes. Fail closed: remove the file and surface the error.
|
||||
if _, werr := f.WriteString(token); werr != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(lockPath)
|
||||
return nil, fmt.Errorf("oauth: write token lock: %w", werr)
|
||||
}
|
||||
if cerr := f.Close(); cerr != nil {
|
||||
_ = os.Remove(lockPath)
|
||||
return nil, fmt.Errorf("oauth: close token lock: %w", cerr)
|
||||
}
|
||||
var released bool
|
||||
return func() {
|
||||
if released {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
|
||||
_ = os.Remove(lockPath)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrExist) {
|
||||
return nil, fmt.Errorf("oauth: acquire token lock: %w", err)
|
||||
}
|
||||
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > fileLockStaleAfter {
|
||||
stale, _ := os.ReadFile(lockPath)
|
||||
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == string(stale) {
|
||||
_ = os.Remove(lockPath)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if now().After(deadline) {
|
||||
return nil, fmt.Errorf("oauth: timed out acquiring token lock %s", filepath.Base(lockPath))
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoopbackListener is a single-use loopback HTTP server that captures an OAuth
|
||||
// redirect (?code=&state=) on 127.0.0.1 only: bind an OS-assigned loopback port,
|
||||
// verify the CSRF state, hand back the code, then close.
|
||||
type LoopbackListener struct {
|
||||
listener net.Listener
|
||||
state string
|
||||
result chan callbackResult
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
type callbackResult struct {
|
||||
code string
|
||||
err error
|
||||
}
|
||||
|
||||
// NewLoopbackListener binds 127.0.0.1:0 (loopback only, OS-assigned port) and
|
||||
// begins serving. state is the CSRF value the callback must echo back. Call
|
||||
// RedirectURI to build the redirect_uri, then Wait for the code. Always Close.
|
||||
func NewLoopbackListener(state string) (*LoopbackListener, error) {
|
||||
// Refuse an empty state: the callback check compares the query's state to this
|
||||
// value, so an empty state would match a callback carrying no state at all,
|
||||
// defeating CSRF protection (fail closed).
|
||||
if strings.TrimSpace(state) == "" {
|
||||
return nil, errors.New("oauth: loopback listener requires a non-empty CSRF state")
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: start loopback redirect listener: %w", err)
|
||||
}
|
||||
l := &LoopbackListener{
|
||||
listener: ln,
|
||||
state: state,
|
||||
result: make(chan callbackResult, 1),
|
||||
}
|
||||
l.server = &http.Server{Handler: http.HandlerFunc(l.handle)}
|
||||
go func() { _ = l.server.Serve(ln) }()
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// RedirectURI returns the http://127.0.0.1:<port>/callback redirect URI.
|
||||
func (l *LoopbackListener) RedirectURI() string {
|
||||
return fmt.Sprintf("http://%s/callback", l.listener.Addr().String())
|
||||
}
|
||||
|
||||
func (l *LoopbackListener) handle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/callback" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
code, err := parseCallback(r.URL.Query(), l.state)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = io.WriteString(w, "Authorization failed. You may close this window.")
|
||||
} else {
|
||||
_, _ = io.WriteString(w, "Authorization complete. You may close this window.")
|
||||
}
|
||||
select {
|
||||
case l.result <- callbackResult{code: code, err: err}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Wait blocks until the callback arrives or ctx is done, returning the
|
||||
// authorization code.
|
||||
func (l *LoopbackListener) Wait(ctx context.Context) (string, error) {
|
||||
select {
|
||||
case res := <-l.result:
|
||||
return res.code, res.err
|
||||
case <-ctx.Done():
|
||||
return "", fmt.Errorf("oauth: timed out waiting for authorization callback: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
// Close shuts the listener down (bounded), idempotent.
|
||||
func (l *LoopbackListener) Close() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
_ = l.server.Shutdown(shutdownCtx)
|
||||
}
|
||||
|
||||
// parseCallback validates the redirect query and returns the authorization code,
|
||||
// rejecting a mismatched state (CSRF) and surfacing provider errors.
|
||||
func parseCallback(values url.Values, wantState string) (string, error) {
|
||||
if got := values.Get("state"); got != wantState {
|
||||
return "", ErrStateMismatch
|
||||
}
|
||||
if providerErr := strings.TrimSpace(values.Get("error")); providerErr != "" {
|
||||
if desc := strings.TrimSpace(values.Get("error_description")); desc != "" {
|
||||
return "", fmt.Errorf("oauth: authorization server returned error %q: %s", providerErr, desc)
|
||||
}
|
||||
return "", fmt.Errorf("oauth: authorization server returned error %q", providerErr)
|
||||
}
|
||||
code := strings.TrimSpace(values.Get("code"))
|
||||
if code == "" {
|
||||
return "", errors.New("oauth: callback missing authorization code")
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoopbackCapturesCode(t *testing.T) {
|
||||
l, err := NewLoopbackListener("the-state")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoopbackListener: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
if !strings.HasPrefix(l.RedirectURI(), "http://127.0.0.1:") {
|
||||
t.Fatalf("redirect URI not loopback: %q", l.RedirectURI())
|
||||
}
|
||||
go func() {
|
||||
_, _ = http.Get(l.RedirectURI() + "?code=auth-code&state=the-state")
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
code, err := l.Wait(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Wait: %v", err)
|
||||
}
|
||||
if code != "auth-code" {
|
||||
t.Fatalf("code = %q, want auth-code", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLoopbackListenerRejectsEmptyState(t *testing.T) {
|
||||
if _, err := NewLoopbackListener(" "); err == nil {
|
||||
t.Fatal("an empty CSRF state must be rejected (fail closed)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopbackRejectsStateMismatch(t *testing.T) {
|
||||
l, err := NewLoopbackListener("expected-state")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoopbackListener: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
go func() {
|
||||
_, _ = http.Get(l.RedirectURI() + "?code=x&state=WRONG")
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_, err = l.Wait(ctx)
|
||||
if !errors.Is(err, ErrStateMismatch) {
|
||||
t.Fatalf("Wait err = %v, want ErrStateMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopbackTimesOut(t *testing.T) {
|
||||
l, err := NewLoopbackListener("s")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoopbackListener: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := l.Wait(ctx); err == nil {
|
||||
t.Fatal("Wait should time out cleanly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopbackProviderError(t *testing.T) {
|
||||
l, err := NewLoopbackListener("s")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoopbackListener: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
go func() {
|
||||
_, _ = http.Get(l.RedirectURI() + "?error=access_denied&error_description=nope&state=s")
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_, err = l.Wait(ctx)
|
||||
if err == nil || !strings.Contains(err.Error(), "access_denied") {
|
||||
t.Fatalf("Wait err = %v, want provider error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// defaultRefreshBuffer refreshes a token this long before its hard expiry.
|
||||
const defaultRefreshBuffer = 60 * time.Second
|
||||
|
||||
const oidcWellKnownPath = "/.well-known/openid-configuration"
|
||||
|
||||
// Manager ties the token store, provider registry, and HTTP client together to
|
||||
// run logins and serve fresh access tokens. It is the high-level entrypoint the
|
||||
// CLI and request paths use.
|
||||
type Manager struct {
|
||||
store *Store
|
||||
registry *Registry
|
||||
client *http.Client
|
||||
env map[string]string
|
||||
now func() time.Time
|
||||
buffer time.Duration
|
||||
out io.Writer
|
||||
// openBrowser is invoked with the authorization URL for loopback logins.
|
||||
// Tests inject a function that drives the loopback redirect.
|
||||
openBrowser func(authURL string) error
|
||||
}
|
||||
|
||||
// ManagerOptions configures a Manager.
|
||||
type ManagerOptions struct {
|
||||
Store *Store
|
||||
Registry *Registry
|
||||
HTTPClient *http.Client
|
||||
Env map[string]string
|
||||
Now func() time.Time
|
||||
RefreshBuffer time.Duration
|
||||
Out io.Writer
|
||||
OpenBrowser func(authURL string) error
|
||||
}
|
||||
|
||||
// NewManager builds a Manager, filling defaults.
|
||||
func NewManager(opts ManagerOptions) (*Manager, error) {
|
||||
if opts.Store == nil {
|
||||
return nil, fmt.Errorf("oauth: manager requires a store")
|
||||
}
|
||||
registry := opts.Registry
|
||||
if registry == nil {
|
||||
registry = NewRegistry()
|
||||
}
|
||||
client := opts.HTTPClient
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
now := opts.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
buffer := opts.RefreshBuffer
|
||||
if buffer <= 0 {
|
||||
buffer = defaultRefreshBuffer
|
||||
}
|
||||
out := opts.Out
|
||||
if out == nil {
|
||||
out = io.Discard
|
||||
}
|
||||
open := opts.OpenBrowser
|
||||
if open == nil {
|
||||
open = func(string) error { return nil }
|
||||
}
|
||||
return &Manager{
|
||||
store: opts.Store, registry: registry, client: client,
|
||||
env: opts.Env, now: now, buffer: buffer, out: out, openBrowser: open,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LoginOptions configures a single provider login.
|
||||
type LoginOptions struct {
|
||||
Provider string
|
||||
Device bool // force device-code flow
|
||||
ExtraScopes []string // appended to the provider's scopes
|
||||
Timeout time.Duration // bounds the whole interactive login
|
||||
}
|
||||
|
||||
// Login runs the provider login (loopback by default, device-code when
|
||||
// requested or when the provider only supports device), stores the token under
|
||||
// "provider:<name>", and returns a redaction-safe status.
|
||||
func (m *Manager) Login(ctx context.Context, opts LoginOptions) (Status, error) {
|
||||
cfg, flow, err := m.registry.ResolveConfig(opts.Provider, m.env)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
if len(opts.ExtraScopes) > 0 {
|
||||
cfg.Scopes = append(append([]string{}, cfg.Scopes...), opts.ExtraScopes...)
|
||||
}
|
||||
timeout := opts.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Minute
|
||||
}
|
||||
loginCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
cfg, err = m.resolveEndpoints(loginCtx, cfg)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
|
||||
useDevice := opts.Device || flow == FlowDevice
|
||||
var token Token
|
||||
if useDevice {
|
||||
token, err = m.loginDevice(loginCtx, cfg)
|
||||
} else {
|
||||
token, err = m.loginLoopback(loginCtx, cfg)
|
||||
}
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
|
||||
key := ProviderKey(opts.Provider)
|
||||
if err := m.store.Save(key, token); err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
return m.statusFor(key)
|
||||
}
|
||||
|
||||
// resolveEndpoints fills missing authorize/token/device endpoints from issuer
|
||||
// discovery (RFC 8414 then OIDC), leaving any explicitly-pinned endpoint intact.
|
||||
func (m *Manager) resolveEndpoints(ctx context.Context, cfg Config) (Config, error) {
|
||||
if trimmed(cfg.IssuerURL) == "" {
|
||||
return cfg, nil
|
||||
}
|
||||
if cfg.AuthorizationEndpoint != "" && cfg.TokenEndpoint != "" && cfg.DeviceAuthorizationEndpoint != "" {
|
||||
return cfg, nil
|
||||
}
|
||||
// Discovery is best-effort: a failure is non-fatal because pinned endpoints
|
||||
// may already be sufficient for the chosen flow. Only merge on success (and
|
||||
// never return from the error branch, which would trip the nilerr linter).
|
||||
if meta, err := m.discover(ctx, cfg.IssuerURL); err == nil {
|
||||
if cfg.AuthorizationEndpoint == "" {
|
||||
cfg.AuthorizationEndpoint = meta.AuthorizationEndpoint
|
||||
}
|
||||
if cfg.TokenEndpoint == "" {
|
||||
cfg.TokenEndpoint = meta.TokenEndpoint
|
||||
}
|
||||
if cfg.DeviceAuthorizationEndpoint == "" {
|
||||
cfg.DeviceAuthorizationEndpoint = meta.DeviceAuthorizationEndpoint
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// discover tries the OAuth (RFC 8414) well-known path, then the OIDC
|
||||
// openid-configuration path (some issuers publish only the latter).
|
||||
func (m *Manager) discover(ctx context.Context, issuer string) (ServerMetadata, error) {
|
||||
meta, err := DiscoverAuthorizationServer(ctx, m.client, issuer)
|
||||
if err == nil && (meta.AuthorizationEndpoint != "" || meta.TokenEndpoint != "") {
|
||||
return meta, nil
|
||||
}
|
||||
oidcURL := strings.TrimRight(strings.TrimSpace(issuer), "/") + oidcWellKnownPath
|
||||
return fetchMetadata(ctx, m.client, oidcURL)
|
||||
}
|
||||
|
||||
func (m *Manager) loginLoopback(ctx context.Context, cfg Config) (Token, error) {
|
||||
if trimmed(cfg.AuthorizationEndpoint) == "" {
|
||||
return Token{}, fmt.Errorf("oauth: no authorization endpoint (set the authorize URL or a discoverable issuer)")
|
||||
}
|
||||
state, err := NewState()
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
pkce, err := NewPKCE()
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
listener, err := NewLoopbackListener(state)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
defer listener.Close()
|
||||
redirectURI := listener.RedirectURI()
|
||||
authURL, err := BuildAuthorizationURL(cfg, pkce, state, redirectURI, nil)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
fmt.Fprintf(m.out, "Open this URL to authorize:\n %s\n", authURL)
|
||||
if err := m.openBrowser(authURL); err != nil {
|
||||
return Token{}, fmt.Errorf("oauth: open authorization URL: %w", err)
|
||||
}
|
||||
code, err := listener.Wait(ctx)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
return ExchangeCode(ctx, m.client, cfg, code, pkce.Verifier, redirectURI, m.now)
|
||||
}
|
||||
|
||||
func (m *Manager) loginDevice(ctx context.Context, cfg Config) (Token, error) {
|
||||
auth, err := RequestDeviceCode(ctx, m.client, cfg, m.now)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
target := auth.VerificationURIComplete
|
||||
if target == "" {
|
||||
target = auth.VerificationURI
|
||||
}
|
||||
// user_code is meant to be displayed to the user; it is not a secret.
|
||||
fmt.Fprintf(m.out, "To authorize, visit:\n %s\nand enter code: %s\n", target, auth.UserCode)
|
||||
return PollDeviceToken(ctx, m.client, cfg, auth, m.now)
|
||||
}
|
||||
|
||||
// GetFresh returns a valid access token for key, refreshing on-demand if the
|
||||
// stored token is expired or within the refresh buffer. Mirrors
|
||||
// checkAndRefreshOAuthTokenIfNeeded.
|
||||
func (m *Manager) GetFresh(ctx context.Context, key string) (string, error) {
|
||||
token, cfg, err := m.loadForKey(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !token.NeedsRefresh(m.now(), m.buffer) {
|
||||
return token.AccessToken, nil
|
||||
}
|
||||
return m.refreshAndSave(ctx, key, cfg, token)
|
||||
}
|
||||
|
||||
// Handle401 forces a refresh after an upstream 401, returning the new access
|
||||
// token. Mirrors handleOAuth401Error.
|
||||
func (m *Manager) Handle401(ctx context.Context, key string) (string, error) {
|
||||
token, cfg, err := m.loadForKey(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return m.refreshAndSave(ctx, key, cfg, token)
|
||||
}
|
||||
|
||||
func (m *Manager) refreshAndSave(ctx context.Context, key string, cfg Config, current Token) (string, error) {
|
||||
refreshed, err := Refresh(ctx, m.client, cfg, current, m.now)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := m.store.Save(key, refreshed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return refreshed.AccessToken, nil
|
||||
}
|
||||
|
||||
// loadForKey loads a stored token and resolves the provider config for its key.
|
||||
func (m *Manager) loadForKey(key string) (Token, Config, error) {
|
||||
if err := ValidateKey(key); err != nil {
|
||||
return Token{}, Config{}, err
|
||||
}
|
||||
token, ok, err := m.store.Load(key)
|
||||
if err != nil {
|
||||
return Token{}, Config{}, err
|
||||
}
|
||||
if !ok {
|
||||
return Token{}, Config{}, fmt.Errorf("oauth: no stored token for %q", key)
|
||||
}
|
||||
name := strings.TrimPrefix(key, KeyPrefixProvider)
|
||||
if name == key {
|
||||
return Token{}, Config{}, fmt.Errorf("oauth: refresh is only supported for provider tokens (got %q)", key)
|
||||
}
|
||||
cfg, _, err := m.registry.ResolveConfig(name, m.env)
|
||||
if err != nil {
|
||||
return Token{}, Config{}, err
|
||||
}
|
||||
return token, cfg, nil
|
||||
}
|
||||
|
||||
// Logout removes a provider's stored token, reporting whether one was present.
|
||||
func (m *Manager) Logout(name string) (bool, error) {
|
||||
return m.store.Delete(ProviderKey(name))
|
||||
}
|
||||
|
||||
// StatusAll returns the status of every provider login.
|
||||
func (m *Manager) StatusAll() ([]Status, error) {
|
||||
return m.store.Status(KeyPrefixProvider)
|
||||
}
|
||||
|
||||
func (m *Manager) statusFor(key string) (Status, error) {
|
||||
statuses, err := m.store.Status(KeyPrefixProvider)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
for _, st := range statuses {
|
||||
if st.Key == key {
|
||||
return st, nil
|
||||
}
|
||||
}
|
||||
return Status{Key: key}, nil
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeProvider is an httptest server that plays a token + device endpoint.
|
||||
type fakeProvider struct {
|
||||
server *httptest.Server
|
||||
tokenHits atomic.Int32
|
||||
}
|
||||
|
||||
func newFakeProvider(t *testing.T, tokenJSON string) *fakeProvider {
|
||||
t.Helper()
|
||||
fp := &fakeProvider{}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) {
|
||||
fp.tokenHits.Add(1)
|
||||
_, _ = io.WriteString(w, tokenJSON)
|
||||
})
|
||||
mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// Approve immediately on poll; short interval so the test is fast.
|
||||
_, _ = io.WriteString(w, `{"device_code":"dc","user_code":"U-1","verification_uri":"https://example/dev","expires_in":600,"interval":1}`)
|
||||
})
|
||||
fp.server = httptest.NewServer(mux)
|
||||
t.Cleanup(fp.server.Close)
|
||||
return fp
|
||||
}
|
||||
|
||||
func managerFor(t *testing.T, env map[string]string, openBrowser func(string) error) *Manager {
|
||||
t.Helper()
|
||||
store, err := NewStore(StoreOptions{FilePath: filepath.Join(t.TempDir(), "tok.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
m, err := NewManager(ManagerOptions{
|
||||
Store: store,
|
||||
Env: env,
|
||||
OpenBrowser: openBrowser,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestManagerLoginLoopback(t *testing.T) {
|
||||
fp := newFakeProvider(t, `{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "client",
|
||||
"ZERO_OAUTH_DEMO_AUTHORIZE_URL": "https://auth.example.com/authorize",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": fp.server.URL + "/token",
|
||||
}
|
||||
// The fake browser drives the loopback redirect with the captured state.
|
||||
openBrowser := func(authURL string) error {
|
||||
u, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
redirect := u.Query().Get("redirect_uri")
|
||||
state := u.Query().Get("state")
|
||||
_, err = http.Get(redirect + "?code=the-code&state=" + url.QueryEscape(state))
|
||||
return err
|
||||
}
|
||||
m := managerFor(t, env, openBrowser)
|
||||
|
||||
status, err := m.Login(context.Background(), LoginOptions{Provider: "demo"})
|
||||
if err != nil {
|
||||
t.Fatalf("Login: %v", err)
|
||||
}
|
||||
if !status.HasToken || status.Key != ProviderKey("demo") {
|
||||
t.Fatalf("status = %+v", status)
|
||||
}
|
||||
// The token is persisted and GetFresh returns it without a needless refresh.
|
||||
access, err := m.GetFresh(context.Background(), ProviderKey("demo"))
|
||||
if err != nil {
|
||||
t.Fatalf("GetFresh: %v", err)
|
||||
}
|
||||
if access != "at" {
|
||||
t.Fatalf("access = %q, want at", access)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerLoginDevice(t *testing.T) {
|
||||
fp := newFakeProvider(t, `{"access_token":"dev-at","token_type":"Bearer","expires_in":3600}`)
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMODEV_CLIENT_ID": "client",
|
||||
"ZERO_OAUTH_DEMODEV_TOKEN_URL": fp.server.URL + "/token",
|
||||
"ZERO_OAUTH_DEMODEV_DEVICE_URL": fp.server.URL + "/device",
|
||||
}
|
||||
m := managerFor(t, env, nil)
|
||||
status, err := m.Login(context.Background(), LoginOptions{Provider: "demodev", Device: true})
|
||||
if err != nil {
|
||||
t.Fatalf("device Login: %v", err)
|
||||
}
|
||||
if !status.HasToken {
|
||||
t.Fatalf("device status = %+v", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerGetFreshRefreshesExpired(t *testing.T) {
|
||||
fp := newFakeProvider(t, `{"access_token":"fresh-at","expires_in":3600}`)
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "client",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": fp.server.URL + "/token",
|
||||
}
|
||||
m := managerFor(t, env, nil)
|
||||
// Seed an expired token with a refresh token.
|
||||
if err := m.store.Save(ProviderKey("demo"), Token{AccessToken: "stale", RefreshToken: "rt", ExpiresAt: time.Now().Add(-time.Hour)}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
access, err := m.GetFresh(context.Background(), ProviderKey("demo"))
|
||||
if err != nil {
|
||||
t.Fatalf("GetFresh: %v", err)
|
||||
}
|
||||
if access != "fresh-at" {
|
||||
t.Fatalf("access = %q, want fresh-at (refreshed)", access)
|
||||
}
|
||||
if fp.tokenHits.Load() != 1 {
|
||||
t.Fatalf("token endpoint hit %d times, want 1 refresh", fp.tokenHits.Load())
|
||||
}
|
||||
// The refreshed token is persisted.
|
||||
stored, _, _ := m.store.Load(ProviderKey("demo"))
|
||||
if stored.AccessToken != "fresh-at" {
|
||||
t.Fatalf("stored token not updated: %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerGetFreshSkipsValidToken(t *testing.T) {
|
||||
fp := newFakeProvider(t, `{"access_token":"should-not-be-used"}`)
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "client",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": fp.server.URL + "/token",
|
||||
}
|
||||
m := managerFor(t, env, nil)
|
||||
_ = m.store.Save(ProviderKey("demo"), Token{AccessToken: "valid", RefreshToken: "rt", ExpiresAt: time.Now().Add(time.Hour)})
|
||||
access, err := m.GetFresh(context.Background(), ProviderKey("demo"))
|
||||
if err != nil {
|
||||
t.Fatalf("GetFresh: %v", err)
|
||||
}
|
||||
if access != "valid" || fp.tokenHits.Load() != 0 {
|
||||
t.Fatalf("a valid token must not be refreshed (access=%q hits=%d)", access, fp.tokenHits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerHandle401ForcesRefresh(t *testing.T) {
|
||||
fp := newFakeProvider(t, `{"access_token":"after-401"}`)
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "client",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": fp.server.URL + "/token",
|
||||
}
|
||||
m := managerFor(t, env, nil)
|
||||
// Token is still valid by clock, but Handle401 forces a refresh anyway.
|
||||
_ = m.store.Save(ProviderKey("demo"), Token{AccessToken: "valid", RefreshToken: "rt", ExpiresAt: time.Now().Add(time.Hour)})
|
||||
access, err := m.Handle401(context.Background(), ProviderKey("demo"))
|
||||
if err != nil {
|
||||
t.Fatalf("Handle401: %v", err)
|
||||
}
|
||||
if access != "after-401" || fp.tokenHits.Load() != 1 {
|
||||
t.Fatalf("Handle401 must force a refresh (access=%q hits=%d)", access, fp.tokenHits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerLogout(t *testing.T) {
|
||||
m := managerFor(t, map[string]string{"ZERO_OAUTH_DEMO_CLIENT_ID": "c"}, nil)
|
||||
_ = m.store.Save(ProviderKey("demo"), Token{AccessToken: "a"})
|
||||
removed, err := m.Logout("demo")
|
||||
if err != nil || !removed {
|
||||
t.Fatalf("Logout = %v %v", removed, err)
|
||||
}
|
||||
if removed2, _ := m.Logout("demo"); removed2 {
|
||||
t.Fatal("second logout should report nothing removed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package oauth is zero's reusable OAuth 2.0 engine. It generalizes the
|
||||
// authorization-code + PKCE flow that internal/mcp already uses for MCP servers
|
||||
// into a transport/identity-agnostic core, and adds the pieces provider login
|
||||
// needs: a device-authorization (RFC 8628) grant, a provider registry, a
|
||||
// namespaced + refreshing token store, and a proactive refresh scheduler.
|
||||
//
|
||||
// Security invariants (enforced throughout, never relaxed):
|
||||
// - PKCE S256 is mandatory on every authorization-code flow; "plain" is refused.
|
||||
// - A per-flow CSRF state is generated and verified on the callback.
|
||||
// - The loopback callback server binds 127.0.0.1 only, on an OS-assigned port,
|
||||
// serves a single request, then closes.
|
||||
// - The token endpoint must be https (loopback exempt) before any credential is
|
||||
// sent — see validateTokenEndpoint.
|
||||
// - Tokens, codes, and verifiers are never logged; error bodies are redacted.
|
||||
// - Token files are 0600, base-confined, and file-locked.
|
||||
//
|
||||
// This package holds NO vendor secrets: provider client IDs and any overridable
|
||||
// endpoints come from config/env, so no third-party OAuth client identity is
|
||||
// baked into the binary.
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Token holds the credentials issued by an authorization server. The token
|
||||
// fields are sensitive: callers must never log them, and the store persists them
|
||||
// 0600. It mirrors the on-disk shape internal/mcp uses so the two stay
|
||||
// format-compatible.
|
||||
type Token struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
// Account is an optional non-secret identifier (email / account id) shown in
|
||||
// status output; never a credential.
|
||||
Account string `json:"account,omitempty"`
|
||||
}
|
||||
|
||||
// Expired reports whether the token has an expiry that is at or before now.
|
||||
// A zero ExpiresAt means "no known expiry" and is treated as not expired.
|
||||
func (t Token) Expired(now time.Time) bool {
|
||||
return !t.ExpiresAt.IsZero() && !t.ExpiresAt.After(now)
|
||||
}
|
||||
|
||||
// NeedsRefresh reports whether the token is expired or falls within buffer of
|
||||
// expiry (so a proactive/on-demand refresh should run). A token with no expiry
|
||||
// never needs a refresh on a timer.
|
||||
func (t Token) NeedsRefresh(now time.Time, buffer time.Duration) bool {
|
||||
if t.ExpiresAt.IsZero() {
|
||||
return false
|
||||
}
|
||||
return !t.ExpiresAt.After(now.Add(buffer))
|
||||
}
|
||||
|
||||
// Config describes how to talk to one authorization server for a flow.
|
||||
// Endpoints may be discovered (RFC 8414) and overridden here.
|
||||
type Config struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
Scopes []string
|
||||
|
||||
AuthorizationEndpoint string
|
||||
TokenEndpoint string
|
||||
DeviceAuthorizationEndpoint string
|
||||
RegistrationEndpoint string
|
||||
// IssuerURL is the base for metadata discovery when endpoints are not set.
|
||||
IssuerURL string
|
||||
|
||||
// ExtraAuthParams are appended to the authorization URL (e.g. login_hint).
|
||||
ExtraAuthParams map[string]string
|
||||
}
|
||||
|
||||
// Errors returned by the engine. Callers can match these with errors.Is.
|
||||
var (
|
||||
// ErrPKCEDowngrade is returned if a flow is asked to use anything but S256.
|
||||
ErrPKCEDowngrade = errors.New("oauth: PKCE S256 is mandatory; plain is refused")
|
||||
// ErrStateMismatch is returned when the callback state does not match (CSRF).
|
||||
ErrStateMismatch = errors.New("oauth: callback state mismatch; possible CSRF, login aborted")
|
||||
// ErrInsecureTokenEndpoint is returned when a credential would be sent over a
|
||||
// non-https, non-loopback endpoint.
|
||||
ErrInsecureTokenEndpoint = errors.New("oauth: refusing to send credential to a non-https token endpoint")
|
||||
// ErrNoRefreshToken is returned when a refresh is attempted without one.
|
||||
ErrNoRefreshToken = errors.New("oauth: no refresh token available")
|
||||
// ErrAuthorizationPending is the RFC 8628 "keep polling" signal.
|
||||
ErrAuthorizationPending = errors.New("oauth: authorization pending")
|
||||
// ErrSlowDown is the RFC 8628 "increase the poll interval" signal.
|
||||
ErrSlowDown = errors.New("oauth: slow down")
|
||||
)
|
||||
|
||||
// trimmed is a tiny helper used across the package.
|
||||
func trimmed(s string) string { return strings.TrimSpace(s) }
|
||||
@@ -0,0 +1,49 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
// pkceVerifierBytes yields a 43-character base64url verifier — the minimum
|
||||
// length the PKCE spec permits while remaining high-entropy.
|
||||
pkceVerifierBytes = 32
|
||||
stateBytes = 32
|
||||
// MethodS256 is the only code-challenge method this engine accepts.
|
||||
MethodS256 = "S256"
|
||||
)
|
||||
|
||||
// PKCE is a verifier/challenge pair for an authorization-code flow. The method
|
||||
// is always S256 (the engine refuses "plain").
|
||||
type PKCE struct {
|
||||
Verifier string
|
||||
Challenge string
|
||||
Method string
|
||||
}
|
||||
|
||||
// NewPKCE generates a high-entropy code verifier and its S256 challenge. It
|
||||
// matches internal/mcp's newPKCE byte-for-byte (RawURLEncoding of 32 random
|
||||
// bytes; challenge = base64url(SHA-256(verifier))) so the two engines are
|
||||
// interchangeable.
|
||||
func NewPKCE() (PKCE, error) {
|
||||
raw := make([]byte, pkceVerifierBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return PKCE{}, fmt.Errorf("oauth: generate PKCE verifier: %w", err)
|
||||
}
|
||||
verifier := base64.RawURLEncoding.EncodeToString(raw)
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
return PKCE{Verifier: verifier, Challenge: challenge, Method: MethodS256}, nil
|
||||
}
|
||||
|
||||
// NewState generates a high-entropy CSRF state value.
|
||||
func NewState() (string, error) {
|
||||
raw := make([]byte, stateBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("oauth: generate state: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Flow selects how a provider delivers the authorization result.
|
||||
type Flow string
|
||||
|
||||
const (
|
||||
// FlowLoopback uses a 127.0.0.1 callback server (browser required).
|
||||
FlowLoopback Flow = "loopback"
|
||||
// FlowDevice uses the RFC 8628 device-code flow (headless/SSH).
|
||||
FlowDevice Flow = "device"
|
||||
)
|
||||
|
||||
// providerNamePattern bounds a provider name to a safe identifier that is also a
|
||||
// valid store-key segment.
|
||||
var providerNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
|
||||
// ValidateProviderName reports whether name is a safe provider identifier.
|
||||
func ValidateProviderName(name string) error {
|
||||
if !providerNamePattern.MatchString(name) {
|
||||
return fmt.Errorf("oauth: invalid provider name %q", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Registry resolves a provider's Config from env/config. It ships NO built-in
|
||||
// providers, endpoints, or client identities: every provider is defined entirely
|
||||
// by the operator via ZERO_OAUTH_<NAME>_* variables, so no third-party brand or
|
||||
// OAuth client identity is baked into the binary.
|
||||
type Registry struct{}
|
||||
|
||||
// NewRegistry returns the (stateless) env-driven registry.
|
||||
func NewRegistry() *Registry { return &Registry{} }
|
||||
|
||||
// envKey builds the ZERO_OAUTH_<NAME>_<suffix> variable name for a provider.
|
||||
func envKey(name, suffix string) string {
|
||||
up := strings.ToUpper(name)
|
||||
up = strings.NewReplacer("-", "_", ".", "_").Replace(up)
|
||||
return "ZERO_OAUTH_" + up + "_" + suffix
|
||||
}
|
||||
|
||||
// ResolveConfig builds the oauth.Config and default Flow for a provider from its
|
||||
// env/config:
|
||||
//
|
||||
// ZERO_OAUTH_<NAME>_CLIENT_ID (required)
|
||||
// ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
|
||||
// ZERO_OAUTH_<NAME>_AUTHORIZE_URL (loopback flow; or discovered via issuer)
|
||||
// ZERO_OAUTH_<NAME>_TOKEN_URL (or discovered via issuer)
|
||||
// ZERO_OAUTH_<NAME>_DEVICE_URL (device flow; or discovered via issuer)
|
||||
// ZERO_OAUTH_<NAME>_ISSUER_URL (RFC 8414 / OIDC discovery base)
|
||||
// ZERO_OAUTH_<NAME>_SCOPES (space-separated)
|
||||
// ZERO_OAUTH_<NAME>_FLOW ("loopback" [default] | "device")
|
||||
//
|
||||
// Pinned credential-bearing endpoints must be https (loopback exempt).
|
||||
func (r *Registry) ResolveConfig(name string, env map[string]string) (Config, Flow, error) {
|
||||
if err := ValidateProviderName(name); err != nil {
|
||||
return Config{}, "", err
|
||||
}
|
||||
cfg := Config{
|
||||
ClientID: strings.TrimSpace(envValue(env, envKey(name, "CLIENT_ID"))),
|
||||
ClientSecret: strings.TrimSpace(envValue(env, envKey(name, "CLIENT_SECRET"))),
|
||||
AuthorizationEndpoint: strings.TrimSpace(envValue(env, envKey(name, "AUTHORIZE_URL"))),
|
||||
TokenEndpoint: strings.TrimSpace(envValue(env, envKey(name, "TOKEN_URL"))),
|
||||
DeviceAuthorizationEndpoint: strings.TrimSpace(envValue(env, envKey(name, "DEVICE_URL"))),
|
||||
IssuerURL: strings.TrimSpace(envValue(env, envKey(name, "ISSUER_URL"))),
|
||||
Scopes: strings.Fields(envValue(env, envKey(name, "SCOPES"))),
|
||||
}
|
||||
if cfg.ClientID == "" {
|
||||
return Config{}, "", fmt.Errorf("oauth: provider %q is not configured; set %s (and its endpoints or an issuer)", name, envKey(name, "CLIENT_ID"))
|
||||
}
|
||||
var flow Flow
|
||||
switch strings.ToLower(strings.TrimSpace(envValue(env, envKey(name, "FLOW")))) {
|
||||
case "", string(FlowLoopback):
|
||||
flow = FlowLoopback
|
||||
case string(FlowDevice):
|
||||
flow = FlowDevice
|
||||
default:
|
||||
return Config{}, "", fmt.Errorf("oauth: provider %q has invalid %s (want loopback or device)", name, envKey(name, "FLOW"))
|
||||
}
|
||||
// A token endpoint (for exchange/refresh) must be reachable directly or via
|
||||
// discovery. The per-flow authorize/device endpoints are checked at login
|
||||
// time (after discovery), so a refresh-only config needs only a token URL.
|
||||
if cfg.IssuerURL == "" && cfg.TokenEndpoint == "" {
|
||||
return Config{}, "", fmt.Errorf("oauth: provider %q needs %s or %s", name, envKey(name, "TOKEN_URL"), envKey(name, "ISSUER_URL"))
|
||||
}
|
||||
for _, ep := range []string{cfg.TokenEndpoint, cfg.AuthorizationEndpoint, cfg.DeviceAuthorizationEndpoint, cfg.IssuerURL} {
|
||||
if ep == "" {
|
||||
continue
|
||||
}
|
||||
if err := validateTokenEndpoint(ep); err != nil {
|
||||
return Config{}, "", err
|
||||
}
|
||||
}
|
||||
return cfg, flow, nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveConfigFromEnv(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "my-client",
|
||||
"ZERO_OAUTH_DEMO_CLIENT_SECRET": "shh",
|
||||
"ZERO_OAUTH_DEMO_SCOPES": "read write",
|
||||
"ZERO_OAUTH_DEMO_AUTHORIZE_URL": "https://auth.example.com/authorize",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": "https://auth.example.com/token",
|
||||
}
|
||||
cfg, flow, err := r.ResolveConfig("demo", env)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfig: %v", err)
|
||||
}
|
||||
if cfg.ClientID != "my-client" || cfg.ClientSecret != "shh" {
|
||||
t.Fatalf("client creds not applied: %+v", cfg)
|
||||
}
|
||||
if len(cfg.Scopes) != 2 || cfg.Scopes[0] != "read" {
|
||||
t.Fatalf("scopes = %v", cfg.Scopes)
|
||||
}
|
||||
if cfg.TokenEndpoint != "https://auth.example.com/token" {
|
||||
t.Fatalf("token endpoint = %q", cfg.TokenEndpoint)
|
||||
}
|
||||
if flow != FlowLoopback {
|
||||
t.Fatalf("flow = %q, want loopback default", flow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigDeviceFlow(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "c",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": "https://auth.example.com/token",
|
||||
"ZERO_OAUTH_DEMO_DEVICE_URL": "https://auth.example.com/device",
|
||||
"ZERO_OAUTH_DEMO_FLOW": "device",
|
||||
}
|
||||
_, flow, err := r.ResolveConfig("demo", env)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfig: %v", err)
|
||||
}
|
||||
if flow != FlowDevice {
|
||||
t.Fatalf("flow = %q, want device", flow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigRequiresClientID(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
if _, _, err := r.ResolveConfig("demo", map[string]string{}); err == nil {
|
||||
t.Fatal("missing client id must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigRequiresEndpointsOrIssuer(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
// client id but no token endpoint and no issuer => error.
|
||||
_, _, err := r.ResolveConfig("demo", map[string]string{"ZERO_OAUTH_DEMO_CLIENT_ID": "c"})
|
||||
if err == nil {
|
||||
t.Fatal("missing endpoints/issuer must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigRejectsInsecureEndpoint(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "c",
|
||||
"ZERO_OAUTH_DEMO_AUTHORIZE_URL": "https://auth.example.com/authorize",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": "http://insecure.example/token", // non-https, non-loopback
|
||||
}
|
||||
_, _, err := r.ResolveConfig("demo", env)
|
||||
if !errors.Is(err, ErrInsecureTokenEndpoint) {
|
||||
t.Fatalf("err = %v, want ErrInsecureTokenEndpoint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigInvalidName(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
for _, bad := range []string{"", "has space", "../escape", "a/b"} {
|
||||
if _, _, err := r.ResolveConfig(bad, nil); err == nil {
|
||||
t.Errorf("ResolveConfig(%q) should reject invalid name", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvKey(t *testing.T) {
|
||||
if got := envKey("my-svc", "CLIENT_ID"); got != "ZERO_OAUTH_MY_SVC_CLIENT_ID" {
|
||||
t.Fatalf("envKey = %q", got)
|
||||
}
|
||||
if got := envKey("two.part", "SCOPES"); got != "ZERO_OAUTH_TWO_PART_SCOPES" {
|
||||
t.Fatalf("envKey = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// maxSchedulerLoadErrors bounds consecutive token-load failures before the
|
||||
// scheduler gives up, so a transient error retries but a permanent one (e.g. a
|
||||
// deleted token) does not loop forever.
|
||||
const maxSchedulerLoadErrors = 5
|
||||
|
||||
// RefreshScheduler proactively refreshes a stored provider token shortly before
|
||||
// it expires. It is an OPTIMIZATION over the on-demand GetFresh path, never the
|
||||
// source of truth: a failed scheduled refresh is non-fatal and simply retried at
|
||||
// the next expiry. It is a no-op for a token with no refresh token or no expiry.
|
||||
type RefreshScheduler struct {
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
started bool
|
||||
}
|
||||
|
||||
// NewRefreshScheduler returns an idle scheduler.
|
||||
func NewRefreshScheduler() *RefreshScheduler {
|
||||
return &RefreshScheduler{}
|
||||
}
|
||||
|
||||
// Start begins proactively refreshing key via the manager until the context is
|
||||
// canceled or Stop is called. It returns immediately; refresh happens in a
|
||||
// goroutine. Calling Start twice is a no-op after the first.
|
||||
func (s *RefreshScheduler) Start(ctx context.Context, m *Manager, key string) {
|
||||
s.mu.Lock()
|
||||
if s.started {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
s.cancel = cancel
|
||||
s.done = make(chan struct{})
|
||||
s.started = true
|
||||
done := s.done
|
||||
s.mu.Unlock()
|
||||
|
||||
go s.loop(runCtx, m, key, done)
|
||||
}
|
||||
|
||||
func (s *RefreshScheduler) loop(ctx context.Context, m *Manager, key string, done chan struct{}) {
|
||||
defer close(done)
|
||||
loadErrors := 0
|
||||
for {
|
||||
token, _, err := m.loadForKey(key)
|
||||
if err != nil {
|
||||
// A load error may be transient (a concurrent store write, a brief I/O
|
||||
// hiccup): back off and retry rather than permanently stopping proactive
|
||||
// refresh. Give up only after repeated consecutive failures.
|
||||
loadErrors++
|
||||
if loadErrors >= maxSchedulerLoadErrors {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(m.buffer):
|
||||
}
|
||||
continue
|
||||
}
|
||||
loadErrors = 0
|
||||
// No refresh token or no expiry => nothing to schedule on a timer.
|
||||
if token.RefreshToken == "" || token.ExpiresAt.IsZero() {
|
||||
return
|
||||
}
|
||||
delay := s.delayUntilRefresh(token.ExpiresAt, m.buffer, m.now())
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(delay):
|
||||
}
|
||||
// Best-effort refresh; failure is non-fatal — reload and reschedule (the
|
||||
// on-demand GetFresh remains the safety net for callers).
|
||||
if _, err := m.GetFresh(ctx, key); err != nil {
|
||||
// Wait a bounded backoff before retrying so a persistently failing
|
||||
// refresh does not hot-loop.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(m.buffer):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// delayUntilRefresh computes how long to wait before refreshing: aim for buffer
|
||||
// before expiry, clamped to >= 0, plus a small deterministic jitter so many
|
||||
// sessions do not refresh in lockstep.
|
||||
func (s *RefreshScheduler) delayUntilRefresh(expiresAt time.Time, buffer time.Duration, now time.Time) time.Duration {
|
||||
target := expiresAt.Add(-buffer)
|
||||
delay := target.Sub(now)
|
||||
if delay < 0 {
|
||||
delay = 0
|
||||
}
|
||||
// Jitter up to ~10% of the buffer, derived from the expiry nanos (no RNG dep).
|
||||
if buffer > 0 {
|
||||
jitter := time.Duration(expiresAt.UnixNano()%int64(buffer/10+1)) % (buffer/10 + 1)
|
||||
delay += jitter
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
// Stop cancels the scheduler and waits for its goroutine to exit. Safe to call
|
||||
// more than once and on a never-started scheduler.
|
||||
func (s *RefreshScheduler) Stop() {
|
||||
s.mu.Lock()
|
||||
cancel := s.cancel
|
||||
done := s.done
|
||||
s.cancel = nil
|
||||
s.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if done != nil {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRefreshSchedulerNoRefreshTokenIsNoop(t *testing.T) {
|
||||
m := managerFor(t, map[string]string{"ZERO_OAUTH_DEMO_CLIENT_ID": "c"}, nil)
|
||||
// Token without a refresh token => scheduler must exit promptly (no-op).
|
||||
if err := m.store.Save(ProviderKey("demo"), Token{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
|
||||
t.Fatalf("seed Save: %v", err)
|
||||
}
|
||||
s := NewRefreshScheduler()
|
||||
s.Start(context.Background(), m, ProviderKey("demo"))
|
||||
// Stop waits for the goroutine; if it didn't exit on its own this still returns
|
||||
// because Stop cancels — but the loop should already be done.
|
||||
s.Stop()
|
||||
}
|
||||
|
||||
func TestRefreshSchedulerRefreshesBeforeExpiry(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hits.Add(1)
|
||||
_, _ = io.WriteString(w, `{"access_token":"refreshed","expires_in":3600}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
store, err := NewStore(StoreOptions{FilePath: filepath.Join(t.TempDir(), "tok.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
m, err := NewManager(ManagerOptions{
|
||||
Store: store,
|
||||
Env: map[string]string{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "c",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": server.URL,
|
||||
},
|
||||
RefreshBuffer: 10 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager: %v", err)
|
||||
}
|
||||
// Expiry just past the buffer so the scheduled delay is ~0 and a refresh fires
|
||||
// almost immediately.
|
||||
if err := store.Save(ProviderKey("demo"), Token{AccessToken: "old", RefreshToken: "rt", ExpiresAt: time.Now().Add(15 * time.Millisecond)}); err != nil {
|
||||
t.Fatalf("seed Save: %v", err)
|
||||
}
|
||||
|
||||
s := NewRefreshScheduler()
|
||||
s.Start(context.Background(), m, ProviderKey("demo"))
|
||||
defer s.Stop()
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
stored, _, err := store.Load(ProviderKey("demo"))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if stored.AccessToken == "refreshed" {
|
||||
return // success
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("scheduler did not refresh before expiry (token endpoint hits=%d)", hits.Load())
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
storeSchemaVersion = 1
|
||||
// KeyPrefixProvider namespaces provider-login tokens; MCP server tokens live
|
||||
// under KeyPrefixMCP in the same format (so a future MCP migration is a key
|
||||
// rename, not a format change).
|
||||
KeyPrefixProvider = "provider:"
|
||||
KeyPrefixMCP = "mcp:"
|
||||
)
|
||||
|
||||
// keyPattern bounds a token key to a safe, single-segment namespaced identifier
|
||||
// so a key can never traverse or collide with store internals.
|
||||
var keyPattern = regexp.MustCompile(`^(provider|mcp):[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||||
|
||||
// ValidateKey reports whether key is a well-formed namespaced token key.
|
||||
func ValidateKey(key string) error {
|
||||
if !keyPattern.MatchString(key) {
|
||||
return fmt.Errorf("oauth: invalid token key %q (want \"provider:<name>\" or \"mcp:<name>\")", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProviderKey builds the store key for a provider login.
|
||||
func ProviderKey(name string) string { return KeyPrefixProvider + name }
|
||||
|
||||
// Status is a redaction-safe summary of a stored token (no secret material).
|
||||
type Status struct {
|
||||
Key string `json:"key"`
|
||||
HasToken bool `json:"hasToken"`
|
||||
HasRefreshToken bool `json:"hasRefreshToken"`
|
||||
TokenType string `json:"tokenType,omitempty"`
|
||||
Account string `json:"account,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
ExpiresAt time.Time `json:"expiresAt,omitempty"`
|
||||
Expired bool `json:"expired"`
|
||||
}
|
||||
|
||||
// StoreOptions configures where provider OAuth tokens are persisted.
|
||||
type StoreOptions struct {
|
||||
FilePath string
|
||||
Env map[string]string
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Store persists OAuth tokens (provider + MCP namespaces) in a 0600 JSON file,
|
||||
// guarded by a cross-process lock and written atomically.
|
||||
type Store struct {
|
||||
filePath string
|
||||
now func() time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type storeFile struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Tokens map[string]Token `json:"tokens"`
|
||||
}
|
||||
|
||||
// ResolveStorePath determines the on-disk location for provider OAuth tokens,
|
||||
// honoring ZERO_OAUTH_TOKENS_PATH, then XDG_CONFIG_HOME, then the home dir.
|
||||
func ResolveStorePath(env map[string]string) (string, error) {
|
||||
if override := strings.TrimSpace(envValue(env, "ZERO_OAUTH_TOKENS_PATH")); override != "" {
|
||||
if filepath.IsAbs(override) {
|
||||
return filepath.Clean(override), nil
|
||||
}
|
||||
return filepath.Abs(override)
|
||||
}
|
||||
configHome := strings.TrimSpace(envValue(env, "XDG_CONFIG_HOME"))
|
||||
if configHome == "" {
|
||||
home := strings.TrimSpace(firstNonEmpty(envValue(env, "HOME"), envValue(env, "USERPROFILE")))
|
||||
if home == "" {
|
||||
var err error
|
||||
home, err = os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("oauth: resolve user home: %w", err)
|
||||
}
|
||||
}
|
||||
configHome = filepath.Join(home, ".config")
|
||||
} else if !filepath.IsAbs(configHome) {
|
||||
resolved, err := filepath.Abs(configHome)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
configHome = resolved
|
||||
}
|
||||
return filepath.Join(configHome, "zero", "oauth-tokens.json"), nil
|
||||
}
|
||||
|
||||
// NewStore builds a file-backed token store.
|
||||
func NewStore(options StoreOptions) (*Store, error) {
|
||||
filePath := options.FilePath
|
||||
var err error
|
||||
if strings.TrimSpace(filePath) == "" {
|
||||
filePath, err = ResolveStorePath(options.Env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if !filepath.IsAbs(filePath) {
|
||||
filePath, err = filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Store{filePath: filepath.Clean(filePath), now: now}, nil
|
||||
}
|
||||
|
||||
// Save persists a token under key, replacing any existing entry.
|
||||
func (s *Store) Save(key string, token Token) error {
|
||||
if err := ValidateKey(key); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
unlock, err := acquireFileLock(s.filePath+".lockfile", s.now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
state, err := s.readState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.Tokens[key] = token
|
||||
return s.writeState(state)
|
||||
}
|
||||
|
||||
// Load returns the token for key; the bool is false when none is stored.
|
||||
func (s *Store) Load(key string) (Token, bool, error) {
|
||||
if err := ValidateKey(key); err != nil {
|
||||
return Token{}, false, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
state, err := s.readState()
|
||||
if err != nil {
|
||||
return Token{}, false, err
|
||||
}
|
||||
token, ok := state.Tokens[key]
|
||||
return token, ok, nil
|
||||
}
|
||||
|
||||
// Delete removes the token for key, reporting whether one was present.
|
||||
func (s *Store) Delete(key string) (bool, error) {
|
||||
if err := ValidateKey(key); err != nil {
|
||||
return false, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
unlock, err := acquireFileLock(s.filePath+".lockfile", s.now)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer unlock()
|
||||
state, err := s.readState()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, ok := state.Tokens[key]; !ok {
|
||||
return false, nil
|
||||
}
|
||||
delete(state.Tokens, key)
|
||||
if err := s.writeState(state); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Status returns redaction-safe summaries of every stored token, sorted by key.
|
||||
// An optional prefix filters to one namespace (e.g. KeyPrefixProvider).
|
||||
func (s *Store) Status(prefix string) ([]Status, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
state, err := s.readState()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := make([]string, 0, len(state.Tokens))
|
||||
for k := range state.Tokens {
|
||||
if prefix == "" || strings.HasPrefix(k, prefix) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
now := s.now()
|
||||
out := make([]Status, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
token := state.Tokens[k]
|
||||
out = append(out, Status{
|
||||
Key: k,
|
||||
HasToken: strings.TrimSpace(token.AccessToken) != "",
|
||||
HasRefreshToken: strings.TrimSpace(token.RefreshToken) != "",
|
||||
TokenType: token.TokenType,
|
||||
Account: token.Account,
|
||||
Scopes: token.Scopes,
|
||||
ExpiresAt: token.ExpiresAt,
|
||||
Expired: token.Expired(now),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) readState() (storeFile, error) {
|
||||
data, err := os.ReadFile(s.filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return emptyStoreFile(), nil
|
||||
}
|
||||
return storeFile{}, err
|
||||
}
|
||||
var state storeFile
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return storeFile{}, fmt.Errorf("oauth: invalid token file at %s: %w", s.filePath, err)
|
||||
}
|
||||
if state.SchemaVersion != storeSchemaVersion {
|
||||
return storeFile{}, fmt.Errorf("oauth: invalid token file at %s: unsupported schemaVersion", s.filePath)
|
||||
}
|
||||
if state.Tokens == nil {
|
||||
state.Tokens = map[string]Token{}
|
||||
}
|
||||
for key := range state.Tokens {
|
||||
if err := ValidateKey(key); err != nil {
|
||||
return storeFile{}, fmt.Errorf("oauth: invalid token file at %s: %w", s.filePath, err)
|
||||
}
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *Store) writeState(state storeFile) error {
|
||||
if err := os.MkdirAll(filepath.Dir(s.filePath), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempPath := fmt.Sprintf("%s.tmp-%d-%d", s.filePath, os.Getpid(), s.now().UnixNano())
|
||||
if err := os.WriteFile(tempPath, append(data, '\n'), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tempPath, s.filePath); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func emptyStoreFile() storeFile {
|
||||
return storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{}}
|
||||
}
|
||||
|
||||
// FormatStatuses renders a human-readable status table without leaking token
|
||||
// material.
|
||||
func FormatStatuses(statuses []Status) string {
|
||||
if len(statuses) == 0 {
|
||||
return "No OAuth provider logins are stored."
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, st := range statuses {
|
||||
if i > 0 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
name := strings.TrimPrefix(st.Key, KeyPrefixProvider)
|
||||
b.WriteString(name)
|
||||
b.WriteString(": ")
|
||||
if !st.HasToken {
|
||||
b.WriteString("no token")
|
||||
continue
|
||||
}
|
||||
b.WriteString("logged in")
|
||||
if st.Account != "" {
|
||||
b.WriteString(" as " + st.Account)
|
||||
}
|
||||
if st.HasRefreshToken {
|
||||
b.WriteString(" (refreshable)")
|
||||
}
|
||||
if !st.ExpiresAt.IsZero() {
|
||||
if st.Expired {
|
||||
b.WriteString(", expired at ")
|
||||
} else {
|
||||
b.WriteString(", expires ")
|
||||
}
|
||||
b.WriteString(st.ExpiresAt.UTC().Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// envValue reads a variable. A non-nil env map is authoritative (hermetic): a
|
||||
// missing key returns "" rather than falling back to the process environment, so
|
||||
// a caller/test that passes a controlled map can never pick up ambient
|
||||
// ZERO_OAUTH_* / HOME / XDG_CONFIG_HOME values. Only a nil map uses os.Getenv.
|
||||
func envValue(env map[string]string, key string) string {
|
||||
if env != nil {
|
||||
return env[key]
|
||||
}
|
||||
return os.Getenv(key)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) (*Store, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
s, err := NewStore(StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
return s, path
|
||||
}
|
||||
|
||||
func TestStoreSaveLoadDelete(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
tok := Token{AccessToken: "at", RefreshToken: "rt", Account: "me@x"}
|
||||
if err := s.Save(ProviderKey("demo"), tok); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
if err := s.Save(KeyPrefixMCP+"server1", Token{AccessToken: "mcp-at"}); err != nil {
|
||||
t.Fatalf("Save mcp: %v", err)
|
||||
}
|
||||
got, ok, err := s.Load(ProviderKey("demo"))
|
||||
if err != nil || !ok || got.AccessToken != "at" || got.Account != "me@x" {
|
||||
t.Fatalf("Load = %+v ok=%v err=%v", got, ok, err)
|
||||
}
|
||||
removed, err := s.Delete(ProviderKey("demo"))
|
||||
if err != nil || !removed {
|
||||
t.Fatalf("Delete = %v %v", removed, err)
|
||||
}
|
||||
if _, ok, _ := s.Load(ProviderKey("demo")); ok {
|
||||
t.Fatal("token should be gone after delete")
|
||||
}
|
||||
// The mcp-namespaced token is untouched.
|
||||
if _, ok, _ := s.Load(KeyPrefixMCP + "server1"); !ok {
|
||||
t.Fatal("mcp token should survive provider delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRejectsInvalidKeys(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
for _, bad := range []string{"demo", "provider:", "provider:../escape", "mcp:bad/key", "other:x", ""} {
|
||||
if err := s.Save(bad, Token{AccessToken: "x"}); err == nil {
|
||||
t.Errorf("Save(%q) should be rejected", bad)
|
||||
}
|
||||
}
|
||||
for _, ok := range []string{"provider:demo", "mcp:server-1", "provider:two-svc"} {
|
||||
if err := ValidateKey(ok); err != nil {
|
||||
t.Errorf("ValidateKey(%q) = %v, want nil", ok, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreStatusFiltersByPrefix(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
_ = s.Save(ProviderKey("demo"), Token{AccessToken: "a", RefreshToken: "r", ExpiresAt: time.Now().Add(time.Hour)})
|
||||
_ = s.Save(KeyPrefixMCP+"srv", Token{AccessToken: "m"})
|
||||
statuses, err := s.Status(KeyPrefixProvider)
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
if len(statuses) != 1 || statuses[0].Key != ProviderKey("demo") {
|
||||
t.Fatalf("provider status = %+v", statuses)
|
||||
}
|
||||
if !statuses[0].HasRefreshToken {
|
||||
t.Fatal("status should report a refresh token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreFileMode0600(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("unix file modes")
|
||||
}
|
||||
s, path := newTestStore(t)
|
||||
if err := s.Save(ProviderKey("x"), Token{AccessToken: "a"}); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Fatalf("token file mode = %o, want 600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreMalformedFailsClosed(t *testing.T) {
|
||||
s, path := newTestStore(t)
|
||||
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
|
||||
t.Fatalf("corrupt: %v", err)
|
||||
}
|
||||
if _, _, err := s.Load(ProviderKey("x")); err == nil {
|
||||
t.Fatal("malformed store must fail closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvValueHermetic(t *testing.T) {
|
||||
t.Setenv("ZERO_OAUTH_DEMO_CLIENT_ID", "ambient")
|
||||
// A non-nil map omitting the key must NOT leak the ambient process value.
|
||||
if got := envValue(map[string]string{}, "ZERO_OAUTH_DEMO_CLIENT_ID"); got != "" {
|
||||
t.Fatalf("non-nil env map must be hermetic, got %q", got)
|
||||
}
|
||||
// A nil map reads the process environment.
|
||||
if got := envValue(nil, "ZERO_OAUTH_DEMO_CLIENT_ID"); got != "ambient" {
|
||||
t.Fatalf("nil env map should read os env, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStorePathHonorsOverride(t *testing.T) {
|
||||
// Use an OS-appropriate absolute path: a unix-style "/tmp/..." literal is not
|
||||
// absolute on Windows (no drive), so it would be resolved against the current
|
||||
// drive and a verbatim comparison would fail there.
|
||||
override := filepath.Join(t.TempDir(), "custom", "tok.json")
|
||||
path, err := ResolveStorePath(map[string]string{"ZERO_OAUTH_TOKENS_PATH": override})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveStorePath: %v", err)
|
||||
}
|
||||
if path != override {
|
||||
t.Fatalf("path = %q, want %q", path, override)
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
|
||||
MaxTokens: resolved.maxOutputTokens,
|
||||
HTTPClient: options.HTTPClient,
|
||||
UserAgent: options.UserAgent,
|
||||
ParseThinkTags: parseThinkTagsForProfile(profile, resolved),
|
||||
})
|
||||
case config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat:
|
||||
return anthropic.New(anthropic.Options{
|
||||
@@ -72,6 +73,40 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
|
||||
}
|
||||
}
|
||||
|
||||
func parseThinkTagsForProfile(profile config.ProviderProfile, resolved resolvedProfile) bool {
|
||||
if profile.ParseThinkTags != nil {
|
||||
return *profile.ParseThinkTags
|
||||
}
|
||||
if resolved.providerKind != config.ProviderKindOpenAICompatible {
|
||||
return false
|
||||
}
|
||||
return modelMayEmitThinkTags(resolved.apiModel)
|
||||
}
|
||||
|
||||
func modelMayEmitThinkTags(model string) bool {
|
||||
model = strings.ToLower(strings.TrimSpace(model))
|
||||
for _, marker := range []string{
|
||||
"deepseek-r1",
|
||||
"deepseek-reasoner",
|
||||
"gpt-oss",
|
||||
"glm-z1",
|
||||
"kimi-k2-thinking",
|
||||
"magistral",
|
||||
"minimax-m3",
|
||||
"nemotron",
|
||||
"qwen3",
|
||||
"qwq",
|
||||
"reasoner",
|
||||
"reasoning",
|
||||
"thinking",
|
||||
} {
|
||||
if strings.Contains(model, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type resolvedProfile struct {
|
||||
providerKind config.ProviderKind
|
||||
apiModel string
|
||||
|
||||
@@ -108,6 +108,33 @@ func TestNewSupportsOpenAIProviderKind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseThinkTagsForProfileUsesConservativeDefaultsAndOverride(t *testing.T) {
|
||||
openAICompatible := resolvedProfile{providerKind: config.ProviderKindOpenAICompatible, apiModel: "qwen3-coder:480b"}
|
||||
if !parseThinkTagsForProfile(config.ProviderProfile{}, openAICompatible) {
|
||||
t.Fatal("qwen3 OpenAI-compatible model should parse inline think tags by default")
|
||||
}
|
||||
|
||||
generic := resolvedProfile{providerKind: config.ProviderKindOpenAICompatible, apiModel: "factory-model"}
|
||||
if parseThinkTagsForProfile(config.ProviderProfile{}, generic) {
|
||||
t.Fatal("generic OpenAI-compatible model should preserve literal think tags by default")
|
||||
}
|
||||
|
||||
official := resolvedProfile{providerKind: config.ProviderKindOpenAI, apiModel: "gpt-4.1"}
|
||||
if parseThinkTagsForProfile(config.ProviderProfile{}, official) {
|
||||
t.Fatal("official OpenAI model should preserve literal think tags by default")
|
||||
}
|
||||
|
||||
enabled := true
|
||||
if !parseThinkTagsForProfile(config.ProviderProfile{ParseThinkTags: &enabled}, generic) {
|
||||
t.Fatal("explicit parseThinkTags=true should enable inline think parsing")
|
||||
}
|
||||
|
||||
disabled := false
|
||||
if parseThinkTagsForProfile(config.ProviderProfile{ParseThinkTags: &disabled}, openAICompatible) {
|
||||
t.Fatal("explicit parseThinkTags=false should disable inline think parsing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewResolvesKnownModelToAPIModelAndProvider(t *testing.T) {
|
||||
transport := &captureTransport{
|
||||
responseBody: "data: {\"type\":\"message_stop\"}\n\n",
|
||||
|
||||
@@ -40,6 +40,9 @@ type Options struct {
|
||||
// StreamIdleTimeout aborts the stream if no data arrives for this long.
|
||||
// Zero uses defaultStreamIdleTimeout.
|
||||
StreamIdleTimeout time.Duration
|
||||
// ParseThinkTags converts streamed <think>...</think> content into reasoning
|
||||
// events for OpenAI-compatible models known to emit that legacy format.
|
||||
ParseThinkTags bool
|
||||
}
|
||||
|
||||
// Provider streams completions from an OpenAI-compatible chat completions API.
|
||||
@@ -55,6 +58,7 @@ type Provider struct {
|
||||
httpClient *http.Client
|
||||
userAgent string
|
||||
streamIdleTimeout time.Duration
|
||||
parseThinkTags bool
|
||||
}
|
||||
|
||||
// New creates an OpenAI-compatible provider.
|
||||
@@ -100,6 +104,7 @@ func New(options Options) (*Provider, error) {
|
||||
httpClient: httpClient,
|
||||
userAgent: options.UserAgent,
|
||||
streamIdleTimeout: idleTimeout,
|
||||
parseThinkTags: options.ParseThinkTags,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -162,7 +167,7 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
|
||||
return
|
||||
}
|
||||
|
||||
state := newToolState()
|
||||
state := newToolState(provider.parseThinkTags)
|
||||
// Use the shared SSE reader (also used by the Anthropic/Gemini providers) so
|
||||
// multi-line "data:" continuation fields are joined into one payload, and the
|
||||
// idle watchdog / context cancellation are handled uniformly.
|
||||
@@ -170,7 +175,8 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
|
||||
return provider.emitPayload(ctx, data, state, events)
|
||||
})
|
||||
if errors.Is(err, providerio.ErrStreamIdle) {
|
||||
state.closeOpen(ctx, events)
|
||||
state.flushBufferedContent(events)
|
||||
state.closeBufferedOpen(events)
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: provider.redact(fmt.Sprintf("provider stream error: idle timeout after %s (upstream stopped sending data)", provider.streamIdleTimeout)),
|
||||
@@ -178,16 +184,19 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
state.closeOpen(ctx, events)
|
||||
state.flushBufferedContent(events)
|
||||
state.closeBufferedOpen(events)
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
|
||||
return
|
||||
}
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
state.closeOpen(ctx, events)
|
||||
state.flushBufferedContent(events)
|
||||
state.closeBufferedOpen(events)
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + ctxErr.Error())})
|
||||
return
|
||||
}
|
||||
if !state.done {
|
||||
state.flushContent(ctx, events)
|
||||
state.closeOpen(ctx, events)
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone, FinishReason: state.finishReason})
|
||||
}
|
||||
@@ -199,6 +208,7 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
|
||||
func (provider *Provider) emitPayload(ctx context.Context, data string, state *toolState, events chan<- zeroruntime.StreamEvent) bool {
|
||||
var chunk streamChunk
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
state.flushContent(ctx, events)
|
||||
state.closeOpen(ctx, events)
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
@@ -208,6 +218,7 @@ func (provider *Provider) emitPayload(ctx context.Context, data string, state *t
|
||||
return false
|
||||
}
|
||||
if chunk.Error != nil {
|
||||
state.flushContent(ctx, events)
|
||||
state.closeOpen(ctx, events)
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
@@ -234,15 +245,13 @@ func (provider *Provider) emitChunk(
|
||||
})
|
||||
}
|
||||
if choice.Delta.Content != "" {
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventText,
|
||||
Content: choice.Delta.Content,
|
||||
})
|
||||
state.emitContent(ctx, events, choice.Delta.Content)
|
||||
}
|
||||
for _, toolCall := range choice.Delta.ToolCalls {
|
||||
state.applyDelta(ctx, toolCall, events)
|
||||
}
|
||||
if choice.FinishReason == "tool_calls" {
|
||||
state.flushContent(ctx, events)
|
||||
state.closeOpen(ctx, events)
|
||||
}
|
||||
if reason := mapFinishReason(choice.FinishReason); reason != "" {
|
||||
@@ -314,6 +323,13 @@ func sendEvent(ctx context.Context, events chan<- zeroruntime.StreamEvent, event
|
||||
}
|
||||
}
|
||||
|
||||
func sendBufferedEvent(events chan<- zeroruntime.StreamEvent, event zeroruntime.StreamEvent) {
|
||||
select {
|
||||
case events <- event:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) chatCompletionRequest {
|
||||
messages := make([]chatMessage, 0, len(request.Messages))
|
||||
for _, message := range request.Messages {
|
||||
|
||||
@@ -244,6 +244,53 @@ func TestStreamCompletionEmitsReasoningBeforeRegularContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionPreservesLiteralThinkTagsByDefault(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"choices":[{"delta":{"content":"show <think>literal</think> markup"}}]}`)
|
||||
writeSSE(w, `[DONE]`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
assertEvent(t, events[0], zeroruntime.StreamEventText, "show <think>literal</think> markup")
|
||||
if reasoning := eventsOfType(events, zeroruntime.StreamEventReasoning); len(reasoning) != 0 {
|
||||
t.Fatalf("literal think tags must not emit reasoning by default, got %#v", reasoning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionSplitsInlineThinkTagsFromContent(t *testing.T) {
|
||||
provider := newTestProviderWithThinkTags(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"choices":[{"delta":{"content":"<think>private reasoning</think>public answer"}}]}`)
|
||||
writeSSE(w, `[DONE]`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
assertEvent(t, events[0], zeroruntime.StreamEventReasoning, "private reasoning")
|
||||
assertEvent(t, events[1], zeroruntime.StreamEventText, "public answer")
|
||||
if events[2].Type != zeroruntime.StreamEventDone {
|
||||
t.Fatalf("third event = %#v, want done", events[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionSplitsInlineThinkTagsAcrossChunks(t *testing.T) {
|
||||
provider := newTestProviderWithThinkTags(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"choices":[{"delta":{"content":"<thi"}}]}`)
|
||||
writeSSE(w, `{"choices":[{"delta":{"content":"nk>reason"}}]}`)
|
||||
writeSSE(w, `{"choices":[{"delta":{"content":"ing</"}}]}`)
|
||||
writeSSE(w, `{"choices":[{"delta":{"content":"think> answer"}}]}`)
|
||||
writeSSE(w, `[DONE]`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
reasoning := eventsOfType(events, zeroruntime.StreamEventReasoning)
|
||||
if len(reasoning) != 2 || reasoning[0].Content != "reason" || reasoning[1].Content != "ing" {
|
||||
t.Fatalf("reasoning events = %#v, want split reasoning content", reasoning)
|
||||
}
|
||||
text := eventsOfType(events, zeroruntime.StreamEventText)
|
||||
if len(text) != 1 || text[0].Content != " answer" {
|
||||
t.Fatalf("text events = %#v, want answer-only content", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionBuffersToolArgsUntilIDAndNameArrive(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"path\":"}}]}}]}`)
|
||||
@@ -385,19 +432,83 @@ func TestStreamCompletionEmitsErrorWhenContextCancels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionFlushesBufferedContentWhenContextCancels(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
provider := newTestProviderWithThinkTags(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"choices":[{"delta":{"content":"visible <thi"}}]}`)
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
case <-release:
|
||||
}
|
||||
})
|
||||
defer close(release)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
stream, err := provider.StreamCompletion(ctx, zeroruntime.CompletionRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned setup error: %v", err)
|
||||
}
|
||||
|
||||
events := []zeroruntime.StreamEvent{}
|
||||
select {
|
||||
case event, ok := <-stream:
|
||||
if !ok {
|
||||
t.Fatal("stream closed before first text event")
|
||||
}
|
||||
events = append(events, event)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for first text event")
|
||||
}
|
||||
cancel()
|
||||
events = append(events, readAll(stream)...)
|
||||
|
||||
if len(events) != 3 ||
|
||||
events[0].Type != zeroruntime.StreamEventText || events[0].Content != "visible " ||
|
||||
events[1].Type != zeroruntime.StreamEventText || events[1].Content != "<thi" ||
|
||||
events[2].Type != zeroruntime.StreamEventError {
|
||||
t.Fatalf("events = %#v, want text, buffered text, then context error", events)
|
||||
}
|
||||
if done := eventsOfType(events, zeroruntime.StreamEventDone); len(done) != 0 {
|
||||
t.Fatalf("events = %#v, want no done after context cancel", events)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestProvider(t *testing.T, handler http.HandlerFunc) *Provider {
|
||||
t.Helper()
|
||||
return newTestProviderWithKey(t, "", handler)
|
||||
}
|
||||
|
||||
func newTestProviderWithKey(t *testing.T, apiKey string, handler http.HandlerFunc) *Provider {
|
||||
t.Helper()
|
||||
return newTestProviderWithOptions(t, Options{APIKey: apiKey}, handler)
|
||||
}
|
||||
|
||||
func newTestProviderWithThinkTags(t *testing.T, handler http.HandlerFunc) *Provider {
|
||||
t.Helper()
|
||||
return newTestProviderWithOptions(t, Options{ParseThinkTags: true}, handler)
|
||||
}
|
||||
|
||||
func newTestProviderWithOptions(t *testing.T, options Options, handler http.HandlerFunc) *Provider {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
options.BaseURL = server.URL
|
||||
if strings.TrimSpace(options.Model) == "" {
|
||||
options.Model = "gpt-test"
|
||||
}
|
||||
provider, err := New(Options{
|
||||
APIKey: apiKey,
|
||||
BaseURL: server.URL,
|
||||
Model: "gpt-test",
|
||||
APIKey: options.APIKey,
|
||||
BaseURL: options.BaseURL,
|
||||
Model: options.Model,
|
||||
AuthHeader: options.AuthHeader,
|
||||
AuthScheme: options.AuthScheme,
|
||||
AuthHeaderValue: options.AuthHeaderValue,
|
||||
CustomHeaders: options.CustomHeaders,
|
||||
HTTPClient: options.HTTPClient,
|
||||
UserAgent: options.UserAgent,
|
||||
MaxTokens: options.MaxTokens,
|
||||
StreamIdleTimeout: options.StreamIdleTimeout,
|
||||
ParseThinkTags: options.ParseThinkTags,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
|
||||
@@ -3,12 +3,20 @@ package openai
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
const (
|
||||
thinkOpenTag = "<think>"
|
||||
thinkCloseTag = "</think>"
|
||||
)
|
||||
|
||||
type toolState struct {
|
||||
calls map[int]*pendingToolCall
|
||||
calls map[int]*pendingToolCall
|
||||
think thinkTagSplitter
|
||||
parseThinkTags bool
|
||||
// finishReason holds the normalized terminal stop reason (zeroruntime
|
||||
// FinishReason*) when the response ended abnormally (length/content_filter),
|
||||
// so the provider can attach it to the done event. Empty for a normal finish.
|
||||
@@ -26,8 +34,128 @@ type pendingToolCall struct {
|
||||
ended bool
|
||||
}
|
||||
|
||||
func newToolState() *toolState {
|
||||
return &toolState{calls: make(map[int]*pendingToolCall)}
|
||||
func newToolState(parseThinkTags bool) *toolState {
|
||||
return &toolState{calls: make(map[int]*pendingToolCall), parseThinkTags: parseThinkTags}
|
||||
}
|
||||
|
||||
type streamEventEmitter func(zeroruntime.StreamEvent)
|
||||
|
||||
type thinkTagSplitter struct {
|
||||
pending string
|
||||
inThinking bool
|
||||
}
|
||||
|
||||
func (state *toolState) emitContent(ctx context.Context, events chan<- zeroruntime.StreamEvent, content string) {
|
||||
state.emitContentWith(content, func(event zeroruntime.StreamEvent) {
|
||||
sendEvent(ctx, events, event)
|
||||
})
|
||||
}
|
||||
|
||||
func (state *toolState) flushContent(ctx context.Context, events chan<- zeroruntime.StreamEvent) {
|
||||
state.flushContentWith(func(event zeroruntime.StreamEvent) {
|
||||
sendEvent(ctx, events, event)
|
||||
})
|
||||
}
|
||||
|
||||
func (state *toolState) flushBufferedContent(events chan<- zeroruntime.StreamEvent) {
|
||||
state.flushContentWith(func(event zeroruntime.StreamEvent) {
|
||||
sendBufferedEvent(events, event)
|
||||
})
|
||||
}
|
||||
|
||||
func (state *toolState) emitContentWith(content string, emit streamEventEmitter) {
|
||||
if !state.parseThinkTags {
|
||||
emit(zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: content})
|
||||
return
|
||||
}
|
||||
state.think.push(content, func(eventType zeroruntime.StreamEventType, text string) {
|
||||
emit(zeroruntime.StreamEvent{Type: eventType, Content: text})
|
||||
})
|
||||
}
|
||||
|
||||
func (state *toolState) flushContentWith(emit streamEventEmitter) {
|
||||
if !state.parseThinkTags {
|
||||
return
|
||||
}
|
||||
state.think.flush(func(eventType zeroruntime.StreamEventType, text string) {
|
||||
emit(zeroruntime.StreamEvent{Type: eventType, Content: text})
|
||||
})
|
||||
}
|
||||
|
||||
func (splitter *thinkTagSplitter) push(content string, emit func(zeroruntime.StreamEventType, string)) {
|
||||
if content == "" {
|
||||
return
|
||||
}
|
||||
splitter.pending += content
|
||||
splitter.drain(false, emit)
|
||||
}
|
||||
|
||||
func (splitter *thinkTagSplitter) flush(emit func(zeroruntime.StreamEventType, string)) {
|
||||
splitter.drain(true, emit)
|
||||
}
|
||||
|
||||
func (splitter *thinkTagSplitter) drain(final bool, emit func(zeroruntime.StreamEventType, string)) {
|
||||
for {
|
||||
tag := thinkOpenTag
|
||||
if splitter.inThinking {
|
||||
tag = thinkCloseTag
|
||||
}
|
||||
if index := indexFold(splitter.pending, tag); index >= 0 {
|
||||
splitter.emitCurrent(emit, splitter.pending[:index])
|
||||
splitter.pending = splitter.pending[index+len(tag):]
|
||||
splitter.inThinking = !splitter.inThinking
|
||||
continue
|
||||
}
|
||||
if final {
|
||||
splitter.emitCurrent(emit, splitter.pending)
|
||||
splitter.pending = ""
|
||||
return
|
||||
}
|
||||
keep := trailingTagPrefixLen(splitter.pending, tag)
|
||||
if keep == len(splitter.pending) {
|
||||
return
|
||||
}
|
||||
emitText := splitter.pending[:len(splitter.pending)-keep]
|
||||
splitter.emitCurrent(emit, emitText)
|
||||
splitter.pending = splitter.pending[len(splitter.pending)-keep:]
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (splitter *thinkTagSplitter) emitCurrent(emit func(zeroruntime.StreamEventType, string), text string) {
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
eventType := zeroruntime.StreamEventText
|
||||
if splitter.inThinking {
|
||||
eventType = zeroruntime.StreamEventReasoning
|
||||
}
|
||||
emit(eventType, text)
|
||||
}
|
||||
|
||||
func trailingTagPrefixLen(text string, tag string) int {
|
||||
maxLen := min(len(text), len(tag)-1)
|
||||
for length := maxLen; length > 0; length-- {
|
||||
if strings.EqualFold(text[len(text)-length:], tag[:length]) {
|
||||
return length
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func indexFold(text string, needle string) int {
|
||||
if needle == "" {
|
||||
return 0
|
||||
}
|
||||
if len(text) < len(needle) {
|
||||
return -1
|
||||
}
|
||||
for index := 0; index <= len(text)-len(needle); index++ {
|
||||
if strings.EqualFold(text[index:index+len(needle)], needle) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (state *toolState) applyDelta(
|
||||
@@ -78,6 +206,18 @@ func (state *toolState) applyDelta(
|
||||
}
|
||||
|
||||
func (state *toolState) closeOpen(ctx context.Context, events chan<- zeroruntime.StreamEvent) {
|
||||
state.closeOpenWith(func(event zeroruntime.StreamEvent) {
|
||||
sendEvent(ctx, events, event)
|
||||
})
|
||||
}
|
||||
|
||||
func (state *toolState) closeBufferedOpen(events chan<- zeroruntime.StreamEvent) {
|
||||
state.closeOpenWith(func(event zeroruntime.StreamEvent) {
|
||||
sendBufferedEvent(events, event)
|
||||
})
|
||||
}
|
||||
|
||||
func (state *toolState) closeOpenWith(emit streamEventEmitter) {
|
||||
indexes := make([]int, 0, len(state.calls))
|
||||
for index := range state.calls {
|
||||
indexes = append(indexes, index)
|
||||
@@ -95,20 +235,20 @@ func (state *toolState) closeOpen(ctx context.Context, events chan<- zeroruntime
|
||||
if call.id == "" || call.name == "" {
|
||||
if call.id != "" || call.name != "" || call.arguments != "" {
|
||||
call.ended = true
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDropped})
|
||||
emit(zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDropped})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !call.started {
|
||||
call.started = true
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
emit(zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventToolCallStart,
|
||||
ToolCallID: call.id,
|
||||
ToolName: call.name,
|
||||
})
|
||||
}
|
||||
if call.arguments != "" {
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
emit(zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventToolCallDelta,
|
||||
ToolCallID: call.id,
|
||||
ArgumentsFragment: call.arguments,
|
||||
@@ -116,7 +256,7 @@ func (state *toolState) closeOpen(ctx context.Context, events chan<- zeroruntime
|
||||
call.arguments = ""
|
||||
}
|
||||
call.ended = true
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
emit(zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventToolCallEnd,
|
||||
ToolCallID: call.id,
|
||||
})
|
||||
|
||||
@@ -23,15 +23,26 @@ type Backend struct {
|
||||
// under a seatbelt profile that restricts outbound to the proxy port);
|
||||
// bubblewrap isolates the network namespace with no bridge to the host proxy,
|
||||
// so scoped egress there collapses to deny until a real relay exists.
|
||||
ScopedEgress bool `json:"scopedEgress,omitempty"`
|
||||
Executable string `json:"executable,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
ScopedEgress bool `json:"scopedEgress,omitempty"`
|
||||
// ProxyEgress reports a backend that routes scoped egress through the local
|
||||
// filtering proxy on a BEST-EFFORT basis (the child must honor the proxy env)
|
||||
// rather than via OS-level network isolation. The WSL policy-only fallback uses
|
||||
// this: there is no netns/seatbelt to enforce egress, but the proxy still
|
||||
// applies the allow/deny gate to well-behaved clients.
|
||||
ProxyEgress bool `json:"proxyEgress,omitempty"`
|
||||
Executable string `json:"executable,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// EnforcesScopedEgress reports whether a populated NetworkScoped allowlist can be
|
||||
// enforced by this backend. When false, scoped egress must fail closed (collapse
|
||||
// to deny) rather than silently run with unrestricted networking.
|
||||
// routed through the filtering proxy by this backend. When false, scoped egress
|
||||
// must fail closed (collapse to deny) rather than silently run with unrestricted
|
||||
// networking. A native backend needs an executable; the WSL fallback uses
|
||||
// best-effort proxy egress (ProxyEgress) with no native isolation.
|
||||
func (backend Backend) EnforcesScopedEgress() bool {
|
||||
if backend.ProxyEgress {
|
||||
return true
|
||||
}
|
||||
return backend.Available && backend.Executable != "" && backend.ScopedEgress
|
||||
}
|
||||
|
||||
@@ -65,6 +76,12 @@ func SelectBackend(options BackendOptions) Backend {
|
||||
if path, err := lookup("bwrap"); err == nil && path != "" {
|
||||
return nativeBackend(goos, BackendBubblewrap, path, "bubblewrap sandbox available")
|
||||
}
|
||||
// Under WSL (no bubblewrap), do NOT silently degrade to a plain policy-only
|
||||
// runner: select the WSL backend, which still routes egress through the
|
||||
// filtering proxy and is gated on AllowPolicyOnlyRunner in the runner.
|
||||
if info := detectWSL(); info.IsWSL {
|
||||
return wslBackend(goos, info)
|
||||
}
|
||||
return policyOnlyBackend(goos, "policy-only fallback: bubblewrap is not installed")
|
||||
case "darwin":
|
||||
if path, err := lookup("sandbox-exec"); err == nil && path != "" {
|
||||
@@ -94,6 +111,26 @@ func nativeBackend(goos string, name BackendName, executable string, message str
|
||||
}
|
||||
}
|
||||
|
||||
// wslBackend is the policy-only WSL fallback: no native isolation, but scoped
|
||||
// egress is routed best-effort through the filtering proxy (ProxyEgress). The
|
||||
// runner gates it on Policy.AllowPolicyOnlyRunner and records a downgrade note.
|
||||
func wslBackend(goos string, info WSLInfo) Backend {
|
||||
msg := "policy-only WSL fallback: bubblewrap unavailable under WSL; egress routed through the filtering proxy"
|
||||
if info.IsWSL2 {
|
||||
msg = "policy-only WSL2 fallback: bubblewrap unavailable/unreliable under WSL2; egress routed through the filtering proxy"
|
||||
}
|
||||
return Backend{
|
||||
Name: BackendWSL,
|
||||
Available: false,
|
||||
Platform: goos,
|
||||
Fallback: true,
|
||||
CommandWrapping: false,
|
||||
NativeIsolation: false,
|
||||
ProxyEgress: true,
|
||||
Message: msg,
|
||||
}
|
||||
}
|
||||
|
||||
func policyOnlyBackend(goos string, message string) Backend {
|
||||
return Backend{
|
||||
Name: BackendPolicyOnly,
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
// RequiredAutonomyForBatch returns the highest autonomy tier required to run a
|
||||
// batch of operations without further prompting, given each operation's
|
||||
// classified risk. RiskCritical and RiskHigh both map to AutonomyHigh because
|
||||
// the autonomy ladder has three tiers and the risk ladder has four. Any risk
|
||||
// level that is empty or unrecognised fails closed to AutonomyHigh so an
|
||||
// unclassified operation can never silently lower the batch ceiling. An empty
|
||||
// batch requires only AutonomyLow.
|
||||
func RequiredAutonomyForBatch(risks []Risk) Autonomy {
|
||||
required := AutonomyLow
|
||||
for _, risk := range risks {
|
||||
tier := riskToAutonomy(risk.Level)
|
||||
if autonomyRank[tier] > autonomyRank[required] {
|
||||
required = tier
|
||||
}
|
||||
}
|
||||
return required
|
||||
}
|
||||
|
||||
func riskToAutonomy(level RiskLevel) Autonomy {
|
||||
switch level {
|
||||
case RiskLow:
|
||||
return AutonomyLow
|
||||
case RiskMedium:
|
||||
return AutonomyMedium
|
||||
case RiskHigh, RiskCritical:
|
||||
return AutonomyHigh
|
||||
default:
|
||||
// Unknown / empty risk is treated as the highest tier (fail-closed).
|
||||
return AutonomyHigh
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRequiredAutonomyForBatch(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
risks []Risk
|
||||
want Autonomy
|
||||
}{
|
||||
{"empty slice is low", nil, AutonomyLow},
|
||||
{"single low", []Risk{{Level: RiskLow}}, AutonomyLow},
|
||||
{"mixed low and medium is medium", []Risk{{Level: RiskLow}, {Level: RiskMedium}}, AutonomyMedium},
|
||||
{"any high is high", []Risk{{Level: RiskLow}, {Level: RiskHigh}}, AutonomyHigh},
|
||||
{"any critical is high", []Risk{{Level: RiskMedium}, {Level: RiskCritical}}, AutonomyHigh},
|
||||
{"unknown risk fails closed to high", []Risk{{Level: RiskLevel("weird")}}, AutonomyHigh},
|
||||
{"empty-level risk fails closed to high", []Risk{{Level: RiskLevel("")}}, AutonomyHigh},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := RequiredAutonomyForBatch(tc.risks); got != tc.want {
|
||||
t.Fatalf("RequiredAutonomyForBatch(%v) = %q, want %q", tc.risks, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -32,9 +34,21 @@ import (
|
||||
type egressProxy struct {
|
||||
listener net.Listener
|
||||
server *http.Server
|
||||
allowed []string
|
||||
denied []string
|
||||
log func(string)
|
||||
// socksListener serves the SOCKS5 CONNECT front-end on a SEPARATE loopback
|
||||
// port. It runs the same authorize()/domainAllowed() gate as the HTTP proxy, so
|
||||
// a client configured with ALL_PROXY=socks5://<socksAddr> is filtered
|
||||
// identically. Bound to 127.0.0.1:0 like the HTTP listener.
|
||||
socksListener net.Listener
|
||||
allowed []string
|
||||
denied []string
|
||||
log func(string)
|
||||
|
||||
// mitm, when non-nil (Policy.InspectTLS), makes handleConnect TERMINATE TLS
|
||||
// using a locally generated CA so the decrypted Host can be re-checked against
|
||||
// the allow/deny gate. mitmTransport dials upstreams with full validation
|
||||
// (system roots unless upstreamRoots is set for tests) — never InsecureSkipVerify.
|
||||
mitm *mitmCA
|
||||
mitmTransport *http.Transport
|
||||
|
||||
// domainPrompt, when set, is asked to authorize an UNKNOWN host (one neither
|
||||
// allowed nor explicitly denied) instead of failing closed. It must return
|
||||
@@ -71,6 +85,14 @@ type egressOptions struct {
|
||||
// fail-closed behavior.
|
||||
DomainPrompt func(ctx context.Context, host string, port int) (bool, error)
|
||||
PromptTimeout time.Duration
|
||||
// InspectTLS enables TLS termination (MITM) so the proxy can enforce the
|
||||
// allow/deny gate on the decrypted request Host. Off => pure CONNECT
|
||||
// passthrough (the default, byte-for-byte unchanged).
|
||||
InspectTLS bool
|
||||
// upstreamRoots overrides the root CAs used to validate UPSTREAM TLS. nil =
|
||||
// system roots (production). Tests set it to an httptest server's pool so
|
||||
// validation is exercised WITHOUT InsecureSkipVerify.
|
||||
upstreamRoots *x509.CertPool
|
||||
}
|
||||
|
||||
// errEmptyAllowlist is returned when a scoped-egress proxy is requested with no
|
||||
@@ -89,9 +111,18 @@ func newEgressProxy(options egressOptions) (*egressProxy, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bind scoped-egress proxy: %w", err)
|
||||
}
|
||||
// Bind the SOCKS5 front-end on its own loopback port. Fail closed: if it
|
||||
// cannot bind, tear down the HTTP listener and return an error so the caller
|
||||
// treats scoped egress as a full deny rather than starting a partial proxy.
|
||||
socksListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
_ = listener.Close()
|
||||
return nil, fmt.Errorf("bind scoped-egress socks proxy: %w", err)
|
||||
}
|
||||
|
||||
proxy := &egressProxy{
|
||||
listener: listener,
|
||||
socksListener: socksListener,
|
||||
allowed: allowed,
|
||||
denied: denied,
|
||||
log: options.Log,
|
||||
@@ -100,6 +131,31 @@ func newEgressProxy(options egressOptions) (*egressProxy, error) {
|
||||
decisionCache: map[string]bool{},
|
||||
inflight: map[string]chan struct{}{},
|
||||
}
|
||||
if options.InspectTLS {
|
||||
ca, err := newMITMCA()
|
||||
if err != nil {
|
||||
_ = listener.Close()
|
||||
_ = socksListener.Close()
|
||||
return nil, err
|
||||
}
|
||||
proxy.mitm = ca
|
||||
dialer := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
|
||||
proxy.mitmTransport = &http.Transport{
|
||||
// Bound the TCP connect phase so a blackholed upstream cannot park a
|
||||
// handler+goroutine for the OS default (minutes) — mirrors the
|
||||
// net.DialTimeout the passthrough CONNECT path uses.
|
||||
DialContext: dialer.DialContext,
|
||||
// Upstream TLS is validated normally; upstreamRoots is nil in production
|
||||
// (system roots) and set only by tests. NEVER InsecureSkipVerify.
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: options.upstreamRoots},
|
||||
// Keep upstream on HTTP/1.1: the decrypted client side is HTTP/1.1, so an
|
||||
// HTTP/2 upstream response would serialize an invalid status line back to
|
||||
// the client. (handleConnectMITM also normalizes the response proto.)
|
||||
ForceAttemptHTTP2: false,
|
||||
TLSHandshakeTimeout: 30 * time.Second,
|
||||
ResponseHeaderTimeout: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
proxy.server = &http.Server{
|
||||
Handler: http.HandlerFunc(proxy.handle),
|
||||
ReadHeaderTimeout: 30 * time.Second,
|
||||
@@ -108,10 +164,11 @@ func newEgressProxy(options egressOptions) (*egressProxy, error) {
|
||||
// Serve returns ErrServerClosed on Close; nothing else to do.
|
||||
_ = proxy.server.Serve(listener)
|
||||
}()
|
||||
go proxy.serveSocks(socksListener)
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
// Addr returns the loopback host:port the proxy is listening on.
|
||||
// Addr returns the loopback host:port the HTTP proxy is listening on.
|
||||
func (proxy *egressProxy) Addr() string {
|
||||
if proxy == nil || proxy.listener == nil {
|
||||
return ""
|
||||
@@ -119,6 +176,25 @@ func (proxy *egressProxy) Addr() string {
|
||||
return proxy.listener.Addr().String()
|
||||
}
|
||||
|
||||
// SocksAddr returns the loopback host:port the SOCKS5 front-end is listening on,
|
||||
// or "" when no SOCKS listener is active.
|
||||
func (proxy *egressProxy) SocksAddr() string {
|
||||
if proxy == nil || proxy.socksListener == nil {
|
||||
return ""
|
||||
}
|
||||
return proxy.socksListener.Addr().String()
|
||||
}
|
||||
|
||||
// CACertPEM returns the MITM CA's public certificate (PEM) when TLS inspection
|
||||
// is active, or nil for a plain passthrough proxy. The runner writes this to a
|
||||
// file and surfaces it via ZERO_SANDBOX_CA_CERT.
|
||||
func (proxy *egressProxy) CACertPEM() []byte {
|
||||
if proxy == nil || proxy.mitm == nil {
|
||||
return nil
|
||||
}
|
||||
return proxy.mitm.caPEM()
|
||||
}
|
||||
|
||||
// Close stops the proxy and releases its listener. It is safe to call multiple
|
||||
// times.
|
||||
func (proxy *egressProxy) Close() error {
|
||||
@@ -130,12 +206,26 @@ func (proxy *egressProxy) Close() error {
|
||||
if proxy.server != nil {
|
||||
err = proxy.server.Close()
|
||||
}
|
||||
// Closing the SOCKS listener unblocks serveSocks's Accept loop so the
|
||||
// goroutine returns. Report its error only if the HTTP close succeeded.
|
||||
if proxy.socksListener != nil {
|
||||
if cerr := proxy.socksListener.Close(); cerr != nil && err == nil {
|
||||
err = cerr
|
||||
}
|
||||
}
|
||||
if proxy.mitmTransport != nil {
|
||||
proxy.mitmTransport.CloseIdleConnections()
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (proxy *egressProxy) handle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodConnect {
|
||||
if proxy.mitm != nil {
|
||||
proxy.handleConnectMITM(w, r)
|
||||
return
|
||||
}
|
||||
proxy.handleConnect(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SOCKS5 wire constants (RFC 1928). The proxy speaks only the no-auth CONNECT
|
||||
// subset: enough for a sandboxed process configured with ALL_PROXY=socks5://...
|
||||
// to tunnel TCP through the same allow/deny gate the HTTP proxy enforces.
|
||||
const (
|
||||
socksVersion5 = 0x05
|
||||
socksNoAuth = 0x00
|
||||
socksNoAcceptable = 0xFF
|
||||
socksCmdConnect = 0x01
|
||||
socksAtypIPv4 = 0x01
|
||||
socksAtypDomain = 0x03
|
||||
socksAtypIPv6 = 0x04
|
||||
socksRepSuccess = 0x00
|
||||
socksRepGeneralFailure = 0x01
|
||||
socksRepNotAllowed = 0x02
|
||||
socksRepCmdUnsupported = 0x07
|
||||
)
|
||||
|
||||
// socksHandshakeTimeout bounds the greeting+request exchange so a stalled or
|
||||
// malformed client cannot pin a goroutine; it is cleared before the relay so an
|
||||
// established tunnel is not torn down.
|
||||
const socksHandshakeTimeout = 30 * time.Second
|
||||
|
||||
// serveSocks accepts SOCKS5 connections until the listener is closed.
|
||||
func (proxy *egressProxy) serveSocks(listener net.Listener) {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return // listener closed
|
||||
}
|
||||
go proxy.handleSocksConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSocksConn runs one SOCKS5 CONNECT through the SAME authorize() gate as
|
||||
// the HTTP proxy: an allowed (host, port) is dialed and relayed; a denied target
|
||||
// gets a SOCKS "connection not allowed" reply and is never dialed. Fail-closed:
|
||||
// any protocol/read error just closes the connection.
|
||||
func (proxy *egressProxy) handleSocksConn(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(socksHandshakeTimeout))
|
||||
|
||||
host, port, ok := socksNegotiate(conn)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
targetLabel := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
if !proxy.authorize(host, port) {
|
||||
proxy.logDecision("deny", "SOCKS", targetLabel)
|
||||
_ = socksReply(conn, socksRepNotAllowed)
|
||||
return
|
||||
}
|
||||
upstream, err := net.DialTimeout("tcp", targetLabel, 30*time.Second)
|
||||
if err != nil {
|
||||
proxy.logDecision("deny", "SOCKS", targetLabel)
|
||||
_ = socksReply(conn, socksRepGeneralFailure)
|
||||
return
|
||||
}
|
||||
defer upstream.Close()
|
||||
if err := socksReply(conn, socksRepSuccess); err != nil {
|
||||
return
|
||||
}
|
||||
// Clear the handshake deadline; the tunnel is long-lived.
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
proxy.logDecision("allow", "SOCKS", targetLabel)
|
||||
relay(conn, upstream)
|
||||
}
|
||||
|
||||
// socksNegotiate performs the no-auth greeting and a CONNECT request, returning
|
||||
// the requested host and port. ok=false means negotiation failed (an error reply
|
||||
// has already been written where the protocol calls for one) and the caller
|
||||
// should close the connection.
|
||||
func socksNegotiate(conn net.Conn) (host string, port int, ok bool) {
|
||||
greeting := make([]byte, 2) // VER, NMETHODS
|
||||
if _, err := io.ReadFull(conn, greeting); err != nil || greeting[0] != socksVersion5 {
|
||||
return "", 0, false
|
||||
}
|
||||
methods := make([]byte, int(greeting[1]))
|
||||
if _, err := io.ReadFull(conn, methods); err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
// RFC 1928: select a method the client actually offered. We only support
|
||||
// no-auth; if the client did not offer it, reply "no acceptable methods"
|
||||
// (0xFF) and abort rather than forcing a method it never advertised.
|
||||
supportsNoAuth := false
|
||||
for _, method := range methods {
|
||||
if method == socksNoAuth {
|
||||
supportsNoAuth = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !supportsNoAuth {
|
||||
_, _ = conn.Write([]byte{socksVersion5, socksNoAcceptable})
|
||||
return "", 0, false
|
||||
}
|
||||
if _, err := conn.Write([]byte{socksVersion5, socksNoAuth}); err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
header := make([]byte, 4) // VER, CMD, RSV, ATYP
|
||||
if _, err := io.ReadFull(conn, header); err != nil || header[0] != socksVersion5 {
|
||||
return "", 0, false
|
||||
}
|
||||
if header[1] != socksCmdConnect {
|
||||
_ = socksReply(conn, socksRepCmdUnsupported)
|
||||
return "", 0, false
|
||||
}
|
||||
switch header[3] {
|
||||
case socksAtypIPv4:
|
||||
addr := make([]byte, net.IPv4len)
|
||||
if _, err := io.ReadFull(conn, addr); err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
host = net.IP(addr).String()
|
||||
case socksAtypIPv6:
|
||||
addr := make([]byte, net.IPv6len)
|
||||
if _, err := io.ReadFull(conn, addr); err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
host = net.IP(addr).String()
|
||||
case socksAtypDomain:
|
||||
length := make([]byte, 1)
|
||||
if _, err := io.ReadFull(conn, length); err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
name := make([]byte, int(length[0]))
|
||||
if _, err := io.ReadFull(conn, name); err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
host = string(name)
|
||||
default:
|
||||
_ = socksReply(conn, socksRepGeneralFailure)
|
||||
return "", 0, false
|
||||
}
|
||||
portBytes := make([]byte, 2)
|
||||
if _, err := io.ReadFull(conn, portBytes); err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
return host, int(binary.BigEndian.Uint16(portBytes)), true
|
||||
}
|
||||
|
||||
// socksReply writes a minimal SOCKS5 reply with the given status and a zero IPv4
|
||||
// bound address (clients ignore BND.ADDR/BND.PORT for CONNECT).
|
||||
func socksReply(conn net.Conn, status byte) error {
|
||||
_, err := conn.Write([]byte{socksVersion5, status, 0x00, socksAtypIPv4, 0, 0, 0, 0, 0, 0})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// socks5Connect performs a SOCKS5 no-auth greeting and a CONNECT request for the
|
||||
// given ATYP/address/port through proxyAddr, returning the open connection and
|
||||
// the server's REP byte. The caller closes the connection.
|
||||
func socks5Connect(t *testing.T, proxyAddr string, atyp byte, addr []byte, port int) (net.Conn, byte) {
|
||||
t.Helper()
|
||||
conn, err := net.DialTimeout("tcp", proxyAddr, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial socks proxy: %v", err)
|
||||
}
|
||||
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
|
||||
// Greeting: VER=5, NMETHODS=1, METHOD=0 (no auth).
|
||||
if _, err := conn.Write([]byte{socksVersion5, 0x01, socksNoAuth}); err != nil {
|
||||
t.Fatalf("write greeting: %v", err)
|
||||
}
|
||||
method := make([]byte, 2)
|
||||
if _, err := io.ReadFull(conn, method); err != nil {
|
||||
t.Fatalf("read method selection: %v", err)
|
||||
}
|
||||
if method[0] != socksVersion5 || method[1] != socksNoAuth {
|
||||
t.Fatalf("method selection = %v, want [5 0]", method)
|
||||
}
|
||||
|
||||
// Request: VER=5, CMD=CONNECT, RSV=0, ATYP, ADDR, PORT(2, big-endian).
|
||||
request := append([]byte{socksVersion5, socksCmdConnect, 0x00, atyp}, addr...)
|
||||
request = append(request, byte(port>>8), byte(port&0xff))
|
||||
if _, err := conn.Write(request); err != nil {
|
||||
t.Fatalf("write request: %v", err)
|
||||
}
|
||||
reply := make([]byte, 10) // VER, REP, RSV, ATYP(IPv4), BND.ADDR(4), BND.PORT(2)
|
||||
if _, err := io.ReadFull(conn, reply); err != nil {
|
||||
t.Fatalf("read reply: %v", err)
|
||||
}
|
||||
return conn, reply[1]
|
||||
}
|
||||
|
||||
func TestEgressProxySocksAddrBindsLoopback(t *testing.T) {
|
||||
proxy, err := newEgressProxy(egressOptions{Allowed: []string{"github.com"}})
|
||||
if err != nil {
|
||||
t.Fatalf("newEgressProxy: %v", err)
|
||||
}
|
||||
defer proxy.Close()
|
||||
|
||||
if proxy.SocksAddr() == "" {
|
||||
t.Fatal("SocksAddr must be non-empty while the proxy is running")
|
||||
}
|
||||
if proxy.SocksAddr() == proxy.Addr() {
|
||||
t.Fatalf("SOCKS and HTTP listeners must be distinct ports, both %q", proxy.Addr())
|
||||
}
|
||||
host, _, err := net.SplitHostPort(proxy.SocksAddr())
|
||||
if err != nil {
|
||||
t.Fatalf("SplitHostPort(%q): %v", proxy.SocksAddr(), err)
|
||||
}
|
||||
if host != "127.0.0.1" {
|
||||
t.Fatalf("SOCKS bound to %q, want loopback 127.0.0.1", host)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEgressProxySocksRejectsUnsupportedAuth verifies the proxy honors SOCKS5
|
||||
// method negotiation: a client offering only methods the proxy does not support
|
||||
// (here user/pass 0x02, no no-auth) gets the "no acceptable methods" reply (0xFF)
|
||||
// rather than the proxy forcing a method the client never advertised.
|
||||
func TestEgressProxySocksRejectsUnsupportedAuth(t *testing.T) {
|
||||
proxy, err := newEgressProxy(egressOptions{Allowed: []string{"github.com"}})
|
||||
if err != nil {
|
||||
t.Fatalf("newEgressProxy: %v", err)
|
||||
}
|
||||
defer proxy.Close()
|
||||
|
||||
conn, err := net.DialTimeout("tcp", proxy.SocksAddr(), 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial socks proxy: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
|
||||
// Greeting: VER=5, NMETHODS=1, METHOD=0x02 (user/pass) — no-auth NOT offered.
|
||||
if _, err := conn.Write([]byte{socksVersion5, 0x01, 0x02}); err != nil {
|
||||
t.Fatalf("write greeting: %v", err)
|
||||
}
|
||||
resp := make([]byte, 2)
|
||||
if _, err := io.ReadFull(conn, resp); err != nil {
|
||||
t.Fatalf("read method selection: %v", err)
|
||||
}
|
||||
if resp[0] != socksVersion5 || resp[1] != socksNoAcceptable {
|
||||
t.Fatalf("method selection = %v, want [5 0xFF] (no acceptable methods)", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEgressProxySocksConnectAllowed verifies an allowlisted SOCKS5 CONNECT is
|
||||
// authorized through the SAME gate, dialed, and relayed to a fake upstream.
|
||||
func TestEgressProxySocksConnectAllowed(t *testing.T) {
|
||||
upstream, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen upstream: %v", err)
|
||||
}
|
||||
defer upstream.Close()
|
||||
go func() {
|
||||
c, aerr := upstream.Accept()
|
||||
if aerr != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
buf := make([]byte, 4)
|
||||
if _, rerr := io.ReadFull(c, buf); rerr != nil {
|
||||
return
|
||||
}
|
||||
_, _ = c.Write([]byte("pong"))
|
||||
}()
|
||||
|
||||
_, portStr, _ := net.SplitHostPort(upstream.Addr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
|
||||
// Allow the loopback IP so the IPv4 CONNECT is authorized.
|
||||
proxy, err := newEgressProxy(egressOptions{Allowed: []string{"127.0.0.1"}})
|
||||
if err != nil {
|
||||
t.Fatalf("newEgressProxy: %v", err)
|
||||
}
|
||||
defer proxy.Close()
|
||||
|
||||
conn, rep := socks5Connect(t, proxy.SocksAddr(), socksAtypIPv4, net.IPv4(127, 0, 0, 1).To4(), port)
|
||||
defer conn.Close()
|
||||
if rep != socksRepSuccess {
|
||||
t.Fatalf("SOCKS CONNECT to allowed host REP = %#x, want success (0)", rep)
|
||||
}
|
||||
if _, err := conn.Write([]byte("ping")); err != nil {
|
||||
t.Fatalf("write through tunnel: %v", err)
|
||||
}
|
||||
got := make([]byte, 4)
|
||||
if _, err := io.ReadFull(conn, got); err != nil {
|
||||
t.Fatalf("read through tunnel: %v", err)
|
||||
}
|
||||
if string(got) != "pong" {
|
||||
t.Fatalf("tunneled response = %q, want pong", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEgressProxySocksConnectDenied verifies a non-allowlisted SOCKS5 CONNECT
|
||||
// (domain form) is refused with the "connection not allowed" reply.
|
||||
func TestEgressProxySocksConnectDenied(t *testing.T) {
|
||||
proxy, err := newEgressProxy(egressOptions{Allowed: []string{"allowed.example"}})
|
||||
if err != nil {
|
||||
t.Fatalf("newEgressProxy: %v", err)
|
||||
}
|
||||
defer proxy.Close()
|
||||
|
||||
domain := "denied.example"
|
||||
addr := append([]byte{byte(len(domain))}, []byte(domain)...)
|
||||
conn, rep := socks5Connect(t, proxy.SocksAddr(), socksAtypDomain, addr, 443)
|
||||
conn.Close()
|
||||
if rep != socksRepNotAllowed {
|
||||
t.Fatalf("SOCKS CONNECT to denied host REP = %#x, want not-allowed (2)", rep)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEgressProxySocksDenyWinsOverAllow verifies the SOCKS gate honors
|
||||
// deny-wins: a denied subdomain of an allowed domain is refused (REP 2), proving
|
||||
// the SOCKS path runs the same authorize()/domainAllowed() logic as the HTTP
|
||||
// proxy rather than a parallel, weaker check.
|
||||
func TestEgressProxySocksDenyWinsOverAllow(t *testing.T) {
|
||||
proxy, err := newEgressProxy(egressOptions{
|
||||
Allowed: []string{"allowed.example"},
|
||||
Denied: []string{"blocked.allowed.example"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newEgressProxy: %v", err)
|
||||
}
|
||||
defer proxy.Close()
|
||||
|
||||
denied := "blocked.allowed.example"
|
||||
addr := append([]byte{byte(len(denied))}, []byte(denied)...)
|
||||
conn, rep := socks5Connect(t, proxy.SocksAddr(), socksAtypDomain, addr, 443)
|
||||
conn.Close()
|
||||
if rep != socksRepNotAllowed {
|
||||
t.Fatalf("denied subdomain REP = %#x, want not-allowed (2) (deny must win)", rep)
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,35 @@ func (engine *Engine) Scope() *Scope {
|
||||
return engine.scope
|
||||
}
|
||||
|
||||
// ReadExclusions returns the resolved DenyRead/AllowRead exclusion matcher for
|
||||
// this engine's policy, resolving each policy entry ONCE. The search tools build
|
||||
// it a single time per run and reuse it across the whole walk so the predicates
|
||||
// don't re-run Abs/EvalSymlinks per visited path. Returns nil for a nil engine
|
||||
// (the matcher's methods treat nil as "exclude nothing").
|
||||
func (engine *Engine) ReadExclusions() *ReadExclusions {
|
||||
// A disabled policy enforces nothing, so it must not filter search results
|
||||
// either (Evaluate already allows every request under ModeDisabled).
|
||||
if engine == nil || engine.policy.Mode == ModeDisabled {
|
||||
return nil
|
||||
}
|
||||
return &ReadExclusions{
|
||||
workspaceRoot: engine.workspaceRoot,
|
||||
denyRoots: resolvePolicyPaths(engine.policy.DenyRead),
|
||||
allowRoots: resolvePolicyPaths(engine.policy.AllowRead),
|
||||
}
|
||||
}
|
||||
|
||||
// ReadExclusionGlobs returns the ripgrep-style --glob exclusion args for this
|
||||
// engine's policy + scope (see the package-level ReadExclusionGlobs). Empty when
|
||||
// DenyRead is unset or the engine has no scope.
|
||||
func (engine *Engine) ReadExclusionGlobs() []string {
|
||||
// A disabled policy filters nothing (parity with ReadExclusions / Evaluate).
|
||||
if engine == nil || engine.policy.Mode == ModeDisabled {
|
||||
return nil
|
||||
}
|
||||
return ReadExclusionGlobs(engine.policy, engine.scope)
|
||||
}
|
||||
|
||||
// effectiveNetworkMode is the single source of truth for the engine's active
|
||||
// network mode: it collapses an empty-allowlist scoped policy to deny, and ALSO
|
||||
// downgrades scoped to deny when the backend cannot actually route scoped egress
|
||||
@@ -90,6 +119,13 @@ func (engine *Engine) effectiveNetworkMode(policy Policy) NetworkMode {
|
||||
// their pre-sandbox behaviour); deny blocks everything; scoped allows only hosts
|
||||
// in AllowedDomains minus DeniedDomains (an empty allowlist, or a backend that
|
||||
// can't enforce scoped egress, collapses to deny).
|
||||
//
|
||||
// By default the first-party, in-process tools that consult this gate are NOT
|
||||
// subject to the network policy (EnforceToolNetwork is off): that policy exists
|
||||
// to confine the sandboxed SHELL's egress, which these tools don't use, and they
|
||||
// retain their own SSRF/port/redirect safeguards. Set EnforceToolNetwork to also
|
||||
// hold them to the allow/scoped/deny policy. The sandboxed-shell egress decision
|
||||
// lives in Evaluate via effectiveNetworkMode and is unaffected by this flag.
|
||||
func (engine *Engine) NetworkHostAllowed(host string) (bool, NetworkMode) {
|
||||
if engine == nil {
|
||||
return true, NetworkAllow
|
||||
@@ -98,6 +134,10 @@ func (engine *Engine) NetworkHostAllowed(host string) (bool, NetworkMode) {
|
||||
if policy.Mode == ModeDisabled {
|
||||
return true, NetworkAllow
|
||||
}
|
||||
if !policy.EnforceToolNetwork {
|
||||
// First-party in-process tools are exempt unless the operator opts in.
|
||||
return true, NetworkAllow
|
||||
}
|
||||
switch mode := engine.effectiveNetworkMode(policy); mode {
|
||||
case NetworkAllow:
|
||||
return true, mode
|
||||
@@ -109,6 +149,19 @@ func (engine *Engine) NetworkHostAllowed(host string) (bool, NetworkMode) {
|
||||
}
|
||||
}
|
||||
|
||||
// toolNetworkExempt reports whether a request is exempt from the engine-level
|
||||
// network deny because it is a first-party, in-process network TOOL — one that
|
||||
// declares SideEffectNetwork (web_search / web_fetch) — and the operator has not
|
||||
// opted into EnforceToolNetwork. Such tools do not use the sandboxed shell's
|
||||
// egress; they keep their own SSRF/host safeguards, and NetworkHostAllowed (also
|
||||
// gated by EnforceToolNetwork) governs them at run time. A SHELL command merely
|
||||
// classified as network (SideEffectShell) is NOT exempt, so shell egress stays
|
||||
// blocked under deny. Mirrors the NetworkHostAllowed exemption so the Evaluate
|
||||
// gate and the per-tool gate never diverge.
|
||||
func (engine *Engine) toolNetworkExempt(policy Policy, request Request) bool {
|
||||
return !policy.EnforceToolNetwork && request.SideEffect == SideEffectNetwork
|
||||
}
|
||||
|
||||
// scopeFor returns the scope to validate request paths against. The engine's
|
||||
// shared scope applies only when the request targets the engine's own
|
||||
// workspace root; a per-request override root gets an ad-hoc single-root scope
|
||||
@@ -221,11 +274,17 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
|
||||
if request.Permission == PermissionDeny {
|
||||
return deny(request, risk, ViolationDeniedPermission, "", permissionReason(request), false)
|
||||
}
|
||||
if policy.EnforceWorkspace && request.WorkspaceRoot != "" {
|
||||
for _, requested := range requestPaths(request) {
|
||||
if violation := scope.validate(requested); violation != nil {
|
||||
return deny(request, risk, violation.Code, violation.Path, violation.Reason, false)
|
||||
}
|
||||
// The fine-grained path lists (DenyRead/DenyWrite/AllowRead/AllowWrite) apply
|
||||
// whenever the sandbox is enforcing, independent of EnforceWorkspace and even
|
||||
// when there is no workspace root (absolute paths are still resolved and
|
||||
// matched), so they are honored consistently with the grep/glob exclusion path
|
||||
// and can't be bypassed by an engine built without a workspace root. The
|
||||
// workspace boundary itself needs a root, so it is gated on having one. Mode is
|
||||
// already known to be enforcing here (ModeDisabled returned above).
|
||||
enforceWorkspace := policy.EnforceWorkspace && request.WorkspaceRoot != ""
|
||||
for _, requested := range requestPaths(request) {
|
||||
if violation := validatePathWithPolicy(scope, policy, request.SideEffect, enforceWorkspace, request.WorkspaceRoot, requested); violation != nil {
|
||||
return deny(request, risk, violation.Code, violation.Path, violation.Reason, false)
|
||||
}
|
||||
}
|
||||
// effectiveNetworkMode collapses an empty-allowlist scoped policy to deny, and
|
||||
@@ -235,7 +294,7 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
|
||||
// than running with unrestricted networking. Shared with NetworkHostAllowed so
|
||||
// the per-tool gate can't diverge from this decision. Allow is unchanged.
|
||||
netMode := engine.effectiveNetworkMode(policy)
|
||||
if netMode == NetworkDeny && HasRiskCategory(risk, "network") {
|
||||
if netMode == NetworkDeny && HasRiskCategory(risk, "network") && !engine.toolNetworkExempt(policy, request) {
|
||||
return deny(request, risk, ViolationNetwork, "", "network access is blocked by sandbox policy", false)
|
||||
}
|
||||
if policy.DenyDestructiveShell && HasRiskCategory(risk, "destructive") {
|
||||
|
||||
@@ -9,6 +9,53 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestEvaluateExemptsNetworkToolsFromDenyByDefault reproduces the web_search
|
||||
// hard-deny: under NetworkDeny, the engine-level network gate must NOT deny a
|
||||
// first-party network TOOL (SideEffect=network) by default, so it follows the
|
||||
// normal permission flow instead of being blocked outright. EnforceToolNetwork
|
||||
// restores the strict deny, and a network SHELL command stays denied either way.
|
||||
func TestEvaluateExemptsNetworkToolsFromDenyByDefault(t *testing.T) {
|
||||
base := Policy{Mode: ModeEnforce, Network: NetworkDeny, MaxAutonomy: AutonomyHigh}
|
||||
|
||||
// Default (EnforceToolNetwork off): web_search is NOT network-denied.
|
||||
engine := NewEngine(EngineOptions{Policy: base})
|
||||
d := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionPrompt,
|
||||
})
|
||||
if d.Action == ActionDeny {
|
||||
t.Fatalf("web_search must not be network-denied by default, got deny: %s", d.ErrorString())
|
||||
}
|
||||
|
||||
// Granted permission → it actually runs (ActionAllow), not blocked.
|
||||
allowed := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionPrompt, PermissionGranted: true,
|
||||
})
|
||||
if allowed.Action != ActionAllow {
|
||||
t.Fatalf("granted web_search must be allowed under deny by default, got %q (%s)", allowed.Action, allowed.ErrorString())
|
||||
}
|
||||
|
||||
// EnforceToolNetwork on → strict: the network tool is denied at the gate.
|
||||
strict := base
|
||||
strict.EnforceToolNetwork = true
|
||||
strictEngine := NewEngine(EngineOptions{Policy: strict})
|
||||
ds := strictEngine.Evaluate(context.Background(), Request{
|
||||
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionPrompt, PermissionGranted: true,
|
||||
})
|
||||
if ds.Action != ActionDeny || ds.Violation == nil || ds.Violation.Code != ViolationNetwork {
|
||||
t.Fatalf("with EnforceToolNetwork, web_search must be network-denied, got %q (%+v)", ds.Action, ds.Violation)
|
||||
}
|
||||
|
||||
// A network SHELL command (SideEffect=shell) stays denied under deny — shell
|
||||
// egress is never exempt, even with EnforceToolNetwork off.
|
||||
shell := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "bash", SideEffect: SideEffectShell, PermissionGranted: true,
|
||||
Args: map[string]any{"command": "curl https://evil.test"},
|
||||
})
|
||||
if shell.Action != ActionDeny || shell.Violation == nil || shell.Violation.Code != ViolationNetwork {
|
||||
t.Fatalf("a network shell command must stay denied under deny, got %q (%+v)", shell.Action, shell.Violation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineEvaluatesReadPromptAndPersistentDecisions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store, err := NewGrantStore(StoreOptions{
|
||||
@@ -295,7 +342,11 @@ func TestEngineClassifiesNetworkAndDestructiveShellCommands(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEngineDeniesNetworkSideEffectWhenPolicyBlocksNetwork(t *testing.T) {
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: t.TempDir(), Policy: DefaultPolicy()})
|
||||
// EnforceToolNetwork opts the in-process network tools into the policy; by
|
||||
// default they are exempt (see TestEvaluateExemptsNetworkToolsFromDenyByDefault).
|
||||
policy := DefaultPolicy()
|
||||
policy.EnforceToolNetwork = true
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: t.TempDir(), Policy: policy})
|
||||
|
||||
decision := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "web_fetch",
|
||||
@@ -736,7 +787,11 @@ func TestEngineNetworkHostAllowed(t *testing.T) {
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
engine := NewEngine(EngineOptions{Policy: tc.policy, Backend: tc.backend})
|
||||
// This table exercises the ENFORCED tool-network gate; the default
|
||||
// (exempt) posture is covered by TestEngineNetworkHostAllowedToolNetworkDefaultExempt.
|
||||
policy := tc.policy
|
||||
policy.EnforceToolNetwork = true
|
||||
engine := NewEngine(EngineOptions{Policy: policy, Backend: tc.backend})
|
||||
allowed, mode := engine.NetworkHostAllowed(tc.host)
|
||||
if allowed != tc.allowed || mode != tc.mode {
|
||||
t.Fatalf("NetworkHostAllowed(%q) = (%v, %q), want (%v, %q)", tc.host, allowed, mode, tc.allowed, tc.mode)
|
||||
@@ -749,3 +804,21 @@ func TestEngineNetworkHostAllowed(t *testing.T) {
|
||||
t.Fatalf("nil engine NetworkHostAllowed = (%v, %q), want (true, allow)", allowed, mode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngineNetworkHostAllowedToolNetworkDefaultExempt verifies the default
|
||||
// posture: with EnforceToolNetwork off, the in-process tool gate is exempt from
|
||||
// the network policy, so even a deny / scoped-unlisted host is allowed (the
|
||||
// sandboxed-shell egress decision is separate and unaffected).
|
||||
func TestEngineNetworkHostAllowedToolNetworkDefaultExempt(t *testing.T) {
|
||||
egressBackend := Backend{Name: BackendSandboxExec, Available: true, Executable: "/usr/bin/sandbox-exec", ScopedEgress: true}
|
||||
policies := []Policy{
|
||||
{Mode: ModeEnforce, Network: NetworkDeny},
|
||||
{Mode: ModeEnforce, Network: NetworkScoped, AllowedDomains: []string{"allowed.test"}},
|
||||
}
|
||||
for _, policy := range policies {
|
||||
engine := NewEngine(EngineOptions{Policy: policy, Backend: egressBackend})
|
||||
if allowed, _ := engine.NetworkHostAllowed("evil.test"); !allowed {
|
||||
t.Fatalf("default (EnforceToolNetwork off) must allow the tool host under network=%q", policy.Network)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MITM TLS interception (opt-in via Policy.InspectTLS). Mirrors
|
||||
// reference-sandbox-wsl-mitm-code-agent-js/sandbox-manager.js mitmCA/getMitmCA
|
||||
// and network/proxy.js. The proxy terminates TLS toward the SANDBOXED CLIENT
|
||||
// using an ephemeral, locally generated CA so it can read the decrypted request
|
||||
// and apply the SAME allow/deny gate on the real Host — visibility, not new
|
||||
// authority. The UPSTREAM connection is always validated against real roots
|
||||
// (never InsecureSkipVerify).
|
||||
|
||||
// mitmCA is an ephemeral certificate authority that mints per-host leaf certs.
|
||||
// It lives only for the proxy's lifetime; only its PUBLIC cert is ever exposed
|
||||
// (so the sandboxed client can trust the minted leaves).
|
||||
type mitmCA struct {
|
||||
cert *x509.Certificate
|
||||
key *ecdsa.PrivateKey
|
||||
certDER []byte
|
||||
|
||||
mu sync.Mutex
|
||||
leaves map[string]*tls.Certificate
|
||||
}
|
||||
|
||||
// maxMITMLeafCacheEntries caps the per-host leaf cache so an attacker cannot grow
|
||||
// it without bound. Combined with minting for the AUTHORIZED host (not arbitrary
|
||||
// client SNI), this keeps the cache size to roughly the allowlist size.
|
||||
const maxMITMLeafCacheEntries = 512
|
||||
|
||||
func randomSerial() (*big.Int, error) {
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mitm: serial: %w", err)
|
||||
}
|
||||
return serial, nil
|
||||
}
|
||||
|
||||
// newMITMCA generates a fresh ECDSA CA. The key never leaves the process.
|
||||
func newMITMCA() (*mitmCA, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mitm: generate CA key: %w", err)
|
||||
}
|
||||
serial, err := randomSerial()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: "zero sandbox MITM CA", Organization: []string{"zero-sandbox"}},
|
||||
NotBefore: now.Add(-time.Hour),
|
||||
NotAfter: now.Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
MaxPathLenZero: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mitm: create CA cert: %w", err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mitm: parse CA cert: %w", err)
|
||||
}
|
||||
return &mitmCA{cert: cert, key: key, certDER: der, leaves: map[string]*tls.Certificate{}}, nil
|
||||
}
|
||||
|
||||
// caPEM returns the CA's PUBLIC certificate in PEM (safe to write/expose).
|
||||
func (ca *mitmCA) caPEM() []byte {
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.certDER})
|
||||
}
|
||||
|
||||
// leafFor mints (and caches) a leaf certificate for host, signed by the CA, with
|
||||
// the CA appended so the chain verifies against the CA alone.
|
||||
func (ca *mitmCA) leafFor(host string) (*tls.Certificate, error) {
|
||||
host = hostnameOnly(host)
|
||||
if host == "" {
|
||||
return nil, fmt.Errorf("mitm: empty host for leaf cert")
|
||||
}
|
||||
ca.mu.Lock()
|
||||
defer ca.mu.Unlock()
|
||||
if leaf, ok := ca.leaves[host]; ok {
|
||||
return leaf, nil
|
||||
}
|
||||
// Bound the cache: past the cap, mint without caching so memory stays bounded
|
||||
// (a small per-request cost rather than unbounded growth).
|
||||
cacheable := len(ca.leaves) < maxMITMLeafCacheEntries
|
||||
leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
serial, err := randomSerial()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: host},
|
||||
NotBefore: now.Add(-time.Hour),
|
||||
NotAfter: now.Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
tmpl.IPAddresses = []net.IP{ip}
|
||||
} else {
|
||||
tmpl.DNSNames = []string{host}
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.cert, &leafKey.PublicKey, ca.key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mitm: mint leaf for %s: %w", host, err)
|
||||
}
|
||||
leaf := &tls.Certificate{Certificate: [][]byte{der, ca.certDER}, PrivateKey: leafKey}
|
||||
if cacheable {
|
||||
ca.leaves[host] = leaf
|
||||
}
|
||||
return leaf, nil
|
||||
}
|
||||
|
||||
// writeMITMCAFile writes the CA's PUBLIC cert PEM to an owner-only temp file and
|
||||
// returns its path, so the runner can surface it via ZERO_SANDBOX_CA_CERT.
|
||||
func writeMITMCAFile(pemBytes []byte) (string, error) {
|
||||
f, err := os.CreateTemp("", "zero-sandbox-ca-*.pem")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("mitm: create CA file: %w", err)
|
||||
}
|
||||
if err := f.Chmod(0o600); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(f.Name())
|
||||
return "", err
|
||||
}
|
||||
if _, err := f.Write(pemBytes); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(f.Name())
|
||||
return "", err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.Remove(f.Name())
|
||||
return "", err
|
||||
}
|
||||
return f.Name(), nil
|
||||
}
|
||||
|
||||
// handleConnectMITM terminates TLS toward the client (minting a leaf for the
|
||||
// requested host), then for each decrypted request re-checks the real Host
|
||||
// through the SAME authorize gate and forwards it upstream with full system-root
|
||||
// validation. A denied host gets a 403 over the decrypted channel and is never
|
||||
// forwarded.
|
||||
func (proxy *egressProxy) handleConnectMITM(w http.ResponseWriter, r *http.Request) {
|
||||
target := r.Host
|
||||
if target == "" {
|
||||
target = r.URL.Host
|
||||
}
|
||||
if !proxy.authorizeTarget(target, 443) {
|
||||
proxy.logDecision("deny", "CONNECT", target)
|
||||
http.Error(w, "scoped egress: host not allowed", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "scoped egress: hijack unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
clientConn, _, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer clientConn.Close()
|
||||
if _, err := io.WriteString(clientConn, "HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
host := hostnameOnly(target)
|
||||
tlsConn := tls.Server(clientConn, &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
GetCertificate: func(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
// Mint for the AUTHORIZED CONNECT host, never the arbitrary client SNI:
|
||||
// honoring SNI would let an attacker churn distinct names to force
|
||||
// unbounded key generation and cache growth.
|
||||
return proxy.mitm.leafFor(host)
|
||||
},
|
||||
})
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
return
|
||||
}
|
||||
defer tlsConn.Close()
|
||||
|
||||
reader := bufio.NewReader(tlsConn)
|
||||
for {
|
||||
req, err := http.ReadRequest(reader)
|
||||
if err != nil {
|
||||
return // client closed or EOF
|
||||
}
|
||||
reqHost := req.Host
|
||||
if reqHost == "" {
|
||||
reqHost = host
|
||||
}
|
||||
// MITM widens VISIBILITY, never authority: the decrypted Host passes the
|
||||
// SAME full authorize() gate as the CONNECT check (deny-wins, empty allowlist
|
||||
// => deny, and an interactive DomainPrompt is honored consistently).
|
||||
if !proxy.authorizeTarget(reqHost, 443) {
|
||||
proxy.logDecision("deny", req.Method, reqHost)
|
||||
writeMITMResponse(tlsConn, http.StatusForbidden, "scoped egress: host not allowed")
|
||||
return
|
||||
}
|
||||
req.URL.Scheme = "https"
|
||||
req.URL.Host = normalizeConnectTarget(target)
|
||||
req.RequestURI = ""
|
||||
resp, err := proxy.mitmTransport.RoundTrip(req)
|
||||
if err != nil {
|
||||
proxy.logDecision("deny", req.Method, reqHost)
|
||||
writeMITMResponse(tlsConn, http.StatusBadGateway, "scoped egress: upstream request failed")
|
||||
return
|
||||
}
|
||||
proxy.logDecision("allow", req.Method, reqHost)
|
||||
// Re-frame the response as HTTP/1.1 for the decrypted (HTTP/1.1) client
|
||||
// connection, regardless of how the upstream replied.
|
||||
resp.Proto, resp.ProtoMajor, resp.ProtoMinor = "HTTP/1.1", 1, 1
|
||||
writeErr := resp.Write(tlsConn)
|
||||
_ = resp.Body.Close()
|
||||
if writeErr != nil || req.Close || resp.Close {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeMITMResponse writes a minimal plaintext HTTP/1.1 response over the
|
||||
// decrypted client connection.
|
||||
func writeMITMResponse(conn net.Conn, code int, msg string) {
|
||||
body := msg + "\n"
|
||||
resp := &http.Response{
|
||||
StatusCode: code,
|
||||
ProtoMajor: 1,
|
||||
ProtoMinor: 1,
|
||||
Header: http.Header{"Content-Type": []string{"text/plain; charset=utf-8"}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
ContentLength: int64(len(body)),
|
||||
}
|
||||
_ = resp.Write(conn)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEgressProxyPassthroughNoCAByDefault(t *testing.T) {
|
||||
proxy, err := newEgressProxy(egressOptions{Allowed: []string{"github.com"}})
|
||||
if err != nil {
|
||||
t.Fatalf("newEgressProxy: %v", err)
|
||||
}
|
||||
defer proxy.Close()
|
||||
if proxy.mitm != nil {
|
||||
t.Fatal("InspectTLS off must not create a MITM CA (pure passthrough)")
|
||||
}
|
||||
if proxy.CACertPEM() != nil {
|
||||
t.Fatal("passthrough proxy must expose no CA cert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMITMCAMintsChainingLeaf(t *testing.T) {
|
||||
ca, err := newMITMCA()
|
||||
if err != nil {
|
||||
t.Fatalf("newMITMCA: %v", err)
|
||||
}
|
||||
leaf, err := ca.leafFor("api.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("leafFor: %v", err)
|
||||
}
|
||||
leafCert, err := x509.ParseCertificate(leaf.Certificate[0])
|
||||
if err != nil {
|
||||
t.Fatalf("parse leaf: %v", err)
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
if !roots.AppendCertsFromPEM(ca.caPEM()) {
|
||||
t.Fatal("append CA PEM")
|
||||
}
|
||||
if _, err := leafCert.Verify(x509.VerifyOptions{
|
||||
Roots: roots,
|
||||
DNSName: "api.example.com",
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}); err != nil {
|
||||
t.Fatalf("minted leaf must chain to the CA: %v", err)
|
||||
}
|
||||
// Per-host caching.
|
||||
if leaf2, _ := ca.leafFor("api.example.com"); leaf2 != leaf {
|
||||
t.Fatal("leafFor must cache one leaf per host")
|
||||
}
|
||||
}
|
||||
|
||||
// mitmConnect performs a CONNECT through the proxy then opens a TLS client that
|
||||
// trusts ONLY the MITM CA, returning the decrypted client connection.
|
||||
func mitmConnect(t *testing.T, proxyAddr, target string, caPEM []byte, serverName string) *tls.Conn {
|
||||
t.Helper()
|
||||
conn, err := net.DialTimeout("tcp", proxyAddr, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial proxy: %v", err)
|
||||
}
|
||||
// Bound the whole exchange (CONNECT read + TLS handshake + request/response) so
|
||||
// a regression cannot hang the test indefinitely.
|
||||
if err := conn.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
|
||||
t.Fatalf("set deadline: %v", err)
|
||||
}
|
||||
fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", target, target)
|
||||
br := bufio.NewReader(conn)
|
||||
resp, err := http.ReadResponse(br, &http.Request{Method: "CONNECT"})
|
||||
if err != nil {
|
||||
t.Fatalf("read CONNECT response: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("CONNECT status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if br.Buffered() != 0 {
|
||||
t.Fatalf("proxy over-sent %d bytes after CONNECT 200", br.Buffered())
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caPEM) {
|
||||
t.Fatal("append MITM CA PEM")
|
||||
}
|
||||
tlsConn := tls.Client(conn, &tls.Config{RootCAs: pool, ServerName: serverName})
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
t.Fatalf("client TLS handshake trusting the MITM CA: %v", err)
|
||||
}
|
||||
return tlsConn
|
||||
}
|
||||
|
||||
func TestEgressProxyMITMForwardsAllowlistedHost(t *testing.T) {
|
||||
upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
fmt.Fprint(w, "upstream-ok")
|
||||
}))
|
||||
defer upstream.Close()
|
||||
host := mustHost(t, upstream.URL)
|
||||
target := mustHostPort(t, upstream.URL)
|
||||
|
||||
// Trust the upstream's real cert explicitly (NOT InsecureSkipVerify): proves
|
||||
// the MITM still validates the upstream chain.
|
||||
upstreamRoots := x509.NewCertPool()
|
||||
upstreamRoots.AddCert(upstream.Certificate())
|
||||
|
||||
proxy, err := newEgressProxy(egressOptions{Allowed: []string{host}, InspectTLS: true, upstreamRoots: upstreamRoots})
|
||||
if err != nil {
|
||||
t.Fatalf("newEgressProxy: %v", err)
|
||||
}
|
||||
defer proxy.Close()
|
||||
if proxy.CACertPEM() == nil {
|
||||
t.Fatal("InspectTLS must generate a CA")
|
||||
}
|
||||
|
||||
tlsConn := mitmConnect(t, proxy.Addr(), target, proxy.CACertPEM(), host)
|
||||
defer tlsConn.Close()
|
||||
fmt.Fprintf(tlsConn, "GET / HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n", host)
|
||||
resp, err := http.ReadResponse(bufio.NewReader(tlsConn), &http.Request{Method: http.MethodGet})
|
||||
if err != nil {
|
||||
t.Fatalf("read decrypted response: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("allowlisted forwarded request status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read decrypted body: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(body), "upstream-ok") {
|
||||
t.Fatalf("MITM did not forward the allowlisted request, got %q", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEgressProxyMITMBlocksDeniedDecryptedHost(t *testing.T) {
|
||||
// CONNECT to an allowed host, but send a Host header that is NOT allowlisted —
|
||||
// the MITM re-checks the DECRYPTED Host and blocks it before forwarding.
|
||||
proxy, err := newEgressProxy(egressOptions{Allowed: []string{"127.0.0.1"}, InspectTLS: true})
|
||||
if err != nil {
|
||||
t.Fatalf("newEgressProxy: %v", err)
|
||||
}
|
||||
defer proxy.Close()
|
||||
|
||||
tlsConn := mitmConnect(t, proxy.Addr(), "127.0.0.1:443", proxy.CACertPEM(), "127.0.0.1")
|
||||
defer tlsConn.Close()
|
||||
fmt.Fprint(tlsConn, "GET / HTTP/1.1\r\nHost: blocked.example\r\nConnection: close\r\n\r\n")
|
||||
resp, err := http.ReadResponse(bufio.NewReader(tlsConn), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("read decrypted response: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("denied decrypted Host status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// This file implements the fine-grained AllowRead/DenyRead/AllowWrite/DenyWrite
|
||||
// path lists (Policy fields). They layer ON TOP of the workspace + Scope guards
|
||||
// and never bypass them: AllowRead only re-includes inside a DenyRead carve-out,
|
||||
// AllowWrite is consulted only after the workspace/Scope guard already denied a
|
||||
// write, and every match is symlink-resolved so a symlink prefix cannot evade a
|
||||
// deny or sneak past an allow. All lists default empty, so an unconfigured policy
|
||||
// behaves exactly as before.
|
||||
|
||||
// resolvePolicyPath home-expands, makes absolute, and symlink-resolves a single
|
||||
// policy path entry. ok is false for a blank entry or one that does not exist
|
||||
// (EvalSymlinks requires existence) so a bogus entry is dropped — a non-existent
|
||||
// deny protects nothing and a non-existent allow grants nothing.
|
||||
func resolvePolicyPath(entry string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(entry)
|
||||
if trimmed == "" {
|
||||
return "", false
|
||||
}
|
||||
if trimmed == "~" || strings.HasPrefix(trimmed, "~/") || strings.HasPrefix(trimmed, "~"+string(filepath.Separator)) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
trimmed = filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(trimmed[1:], "/"), string(filepath.Separator)))
|
||||
}
|
||||
absolute, err := filepath.Abs(trimmed)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(absolute)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return resolved, true
|
||||
}
|
||||
|
||||
// resolvePolicyPaths resolves and de-duplicates a list of policy path entries,
|
||||
// dropping blanks and non-existent entries. Files and directories are both kept
|
||||
// (a DenyRead/DenyWrite entry may target a single sensitive file).
|
||||
func resolvePolicyPaths(entries []string) []string {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(entries))
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
resolved, ok := resolvePolicyPath(entry)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[resolved]; dup {
|
||||
continue
|
||||
}
|
||||
seen[resolved] = struct{}{}
|
||||
out = append(out, resolved)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveWriteRootPaths is resolvePolicyPaths restricted to existing directories
|
||||
// that are not the filesystem root — the only valid targets for an OS write bind
|
||||
// and for an AllowWrite grant root.
|
||||
func resolveWriteRootPaths(entries []string) []string {
|
||||
resolved := resolvePolicyPaths(entries)
|
||||
if len(resolved) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(resolved))
|
||||
for _, path := range resolved {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
if filepath.Dir(path) == path {
|
||||
continue // refuse the filesystem root as a write root
|
||||
}
|
||||
out = append(out, path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pathUnderPolicyRoot reports whether requestedPath lies within root. A relative
|
||||
// requestedPath is resolved against workspaceRoot; the portion of an absolute
|
||||
// path outside root is symlink-resolved (via NormalizePrefixForRoot) so a
|
||||
// symlink prefix cannot evade the match. root must be an already-resolved
|
||||
// absolute path.
|
||||
func pathUnderPolicyRoot(requestedPath, root, workspaceRoot string) bool {
|
||||
if root == "" {
|
||||
return false
|
||||
}
|
||||
abs := requestedPath
|
||||
if !filepath.IsAbs(abs) {
|
||||
if workspaceRoot == "" {
|
||||
return false
|
||||
}
|
||||
abs = filepath.Join(workspaceRoot, abs)
|
||||
}
|
||||
normalized := NormalizePrefixForRoot(abs, root)
|
||||
return pathWithinRoot(root, normalized)
|
||||
}
|
||||
|
||||
// readDenied reports whether path is excluded by the DenyRead list with no
|
||||
// more-specific AllowRead re-inclusion. "More specific" means an AllowRead entry
|
||||
// nested inside the matched DenyRead entry — that subtree is read back in while
|
||||
// the rest of the denied tree stays blocked. It resolves the policy entries on
|
||||
// each call, so it suits a one-shot check (the Evaluate gate). A search walk that
|
||||
// checks many paths should use ReadExclusions, which resolves the entries once.
|
||||
func readDenied(policy Policy, workspaceRoot, path string) bool {
|
||||
return readDeniedResolved(workspaceRoot, resolvePolicyPaths(policy.DenyRead), resolvePolicyPaths(policy.AllowRead), path)
|
||||
}
|
||||
|
||||
// readDeniedResolved is readDenied operating on ALREADY-resolved deny/allow roots,
|
||||
// so a caller that resolved the policy entries once can reuse them across many
|
||||
// path checks instead of re-running Abs/EvalSymlinks per path.
|
||||
func readDeniedResolved(workspaceRoot string, denyRoots, allowRoots []string, path string) bool {
|
||||
if len(denyRoots) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, deny := range denyRoots {
|
||||
if !pathUnderPolicyRoot(path, deny, workspaceRoot) {
|
||||
continue
|
||||
}
|
||||
reincluded := false
|
||||
for _, allow := range allowRoots {
|
||||
// The allow entry must sit inside the deny entry to be "more specific",
|
||||
// and the path must fall under that allow entry.
|
||||
if pathWithinRoot(deny, allow) && pathUnderPolicyRoot(path, allow, workspaceRoot) {
|
||||
reincluded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !reincluded {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ReadExclusions holds the resolved DenyRead/AllowRead roots for a policy so a
|
||||
// search walk resolves each policy entry ONCE (Abs/EvalSymlinks) and reuses the
|
||||
// result across every visited path, rather than re-resolving per path. Build it
|
||||
// with Engine.ReadExclusions and reuse it for the whole grep/glob walk.
|
||||
type ReadExclusions struct {
|
||||
workspaceRoot string
|
||||
denyRoots []string
|
||||
allowRoots []string
|
||||
}
|
||||
|
||||
// Active reports whether any DenyRead root is configured. When false the
|
||||
// exclusions are a no-op and the search behaves exactly as before.
|
||||
func (rx *ReadExclusions) Active() bool {
|
||||
return rx != nil && len(rx.denyRoots) > 0
|
||||
}
|
||||
|
||||
// PathExcluded reports whether reading path is excluded by DenyRead, honoring a
|
||||
// more-specific AllowRead re-inclusion. It is the per-file predicate for a walk.
|
||||
func (rx *ReadExclusions) PathExcluded(path string) bool {
|
||||
if !rx.Active() {
|
||||
return false
|
||||
}
|
||||
return readDeniedResolved(rx.workspaceRoot, rx.denyRoots, rx.allowRoots, path)
|
||||
}
|
||||
|
||||
// DirExcluded reports whether a directory subtree can be skipped wholesale during
|
||||
// a walk: it is read-denied AND contains no nested AllowRead root (descending is
|
||||
// required to reach a re-included subtree). When it returns false on a denied dir
|
||||
// because of a nested allow, PathExcluded still filters the denied siblings.
|
||||
func (rx *ReadExclusions) DirExcluded(path string) bool {
|
||||
if !rx.Active() {
|
||||
return false
|
||||
}
|
||||
if !readDeniedResolved(rx.workspaceRoot, rx.denyRoots, rx.allowRoots, path) {
|
||||
return false
|
||||
}
|
||||
abs := path
|
||||
if !filepath.IsAbs(abs) && rx.workspaceRoot != "" {
|
||||
abs = filepath.Join(rx.workspaceRoot, path)
|
||||
}
|
||||
// allowRoots are symlink-resolved (resolvePolicyPaths), so resolve abs the
|
||||
// same way before the prefix comparison — otherwise a dir reached THROUGH a
|
||||
// symlink would fail to match a nested AllowRead root and be wrongly skipped,
|
||||
// dropping a re-included subtree. Best-effort: keep abs if it can't resolve
|
||||
// (e.g. a non-existent path), matching the deny check's tolerant behavior.
|
||||
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
abs = resolved
|
||||
}
|
||||
return !hasNestedAllowReadResolved(rx.allowRoots, abs)
|
||||
}
|
||||
|
||||
// allowWriteScope builds an ad-hoc Scope from the resolved AllowWrite roots so a
|
||||
// write to an AllowWrite path is validated with the SAME symlink-traversal logic
|
||||
// the workspace Scope uses. Returns nil when there are no usable AllowWrite roots.
|
||||
func allowWriteScope(policy Policy) *Scope {
|
||||
roots := resolveWriteRootPaths(policy.AllowWrite)
|
||||
if len(roots) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &Scope{workspaceRoot: roots[0], extraRoots: roots[1:]}
|
||||
}
|
||||
|
||||
// validateWritePath enforces the write precedence: DenyWrite wins, then (when
|
||||
// workspace enforcement is on) a workspace/Scope-writable path is allowed, then
|
||||
// an absolute path under an AllowWrite root is allowed, otherwise the base
|
||||
// workspace/Scope violation stands. The DenyWrite list applies regardless of
|
||||
// enforceWorkspace; the workspace boundary itself applies only when
|
||||
// enforceWorkspace. It never bypasses the symlink/out-of-workspace guards.
|
||||
func validateWritePath(scope *Scope, policy Policy, enforceWorkspace bool, workspaceRoot, path string) *pathViolation {
|
||||
// DenyWrite wins regardless of workspace enforcement.
|
||||
for _, deny := range resolvePolicyPaths(policy.DenyWrite) {
|
||||
if pathUnderPolicyRoot(path, deny, workspaceRoot) {
|
||||
return &pathViolation{
|
||||
Code: ViolationPolicyDenied,
|
||||
Path: path,
|
||||
Reason: path + " is excluded by the sandbox DenyWrite policy",
|
||||
}
|
||||
}
|
||||
}
|
||||
if !enforceWorkspace {
|
||||
// Workspace confinement is off: only the explicit DenyWrite list restricts.
|
||||
return nil
|
||||
}
|
||||
base := scope.validate(path)
|
||||
if base == nil {
|
||||
return nil // writable under the workspace / Scope guard
|
||||
}
|
||||
// AllowWrite only extends ABSOLUTE paths: a relative path is inherently
|
||||
// workspace-relative and already resolved by the base guard above.
|
||||
if filepath.IsAbs(path) {
|
||||
if allow := allowWriteScope(policy); allow != nil && allow.validate(path) == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// validatePathWithPolicy is the single entry point the engine uses to validate a
|
||||
// request path. The fine-grained read/write lists (DenyRead/DenyWrite, with
|
||||
// AllowRead/AllowWrite) apply whenever the sandbox is enforcing, INDEPENDENT of
|
||||
// enforceWorkspace, so they match the grep/glob path that also honors DenyRead
|
||||
// directly. The workspace boundary (scope.validate) applies only when
|
||||
// enforceWorkspace. Behavior is unchanged when the lists are empty.
|
||||
func validatePathWithPolicy(scope *Scope, policy Policy, sideEffect SideEffect, enforceWorkspace bool, workspaceRoot, path string) *pathViolation {
|
||||
// A relative path cannot be anchored without a workspace root, so it cannot be
|
||||
// checked against the (absolute) path lists or workspace boundary. Fail closed
|
||||
// when there is anything to enforce; otherwise it is a no-op (unchanged from the
|
||||
// pre-path-list behavior, where an empty workspace root skipped validation).
|
||||
if workspaceRoot == "" && !filepath.IsAbs(path) {
|
||||
if enforceWorkspace || policyHasPathLists(policy) {
|
||||
return &pathViolation{
|
||||
Code: ViolationOutsideWorkspace,
|
||||
Path: path,
|
||||
Reason: path + " cannot be validated without a workspace root",
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch sideEffect {
|
||||
case SideEffectRead:
|
||||
if readDenied(policy, workspaceRoot, path) {
|
||||
return &pathViolation{
|
||||
Code: ViolationPolicyDenied,
|
||||
Path: path,
|
||||
Reason: path + " is excluded by the sandbox DenyRead policy",
|
||||
}
|
||||
}
|
||||
if enforceWorkspace {
|
||||
return scope.validate(path)
|
||||
}
|
||||
return nil
|
||||
case SideEffectWrite, SideEffectOutOfWorkspace:
|
||||
return validateWritePath(scope, policy, enforceWorkspace, workspaceRoot, path)
|
||||
default:
|
||||
if enforceWorkspace {
|
||||
return scope.validate(path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// hasNestedAllowReadResolved reports whether any already-resolved AllowRead root
|
||||
// sits strictly inside dir (an already-resolved absolute path). When true, a
|
||||
// read-denied dir must still be descended during a walk so the re-included
|
||||
// subtree is reachable.
|
||||
func hasNestedAllowReadResolved(allowRoots []string, dir string) bool {
|
||||
for _, allow := range allowRoots {
|
||||
if allow != dir && pathWithinRoot(dir, allow) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// workspaceRelGlob returns target as a clean, slash-separated path relative to
|
||||
// workspaceRoot, or ok=false when target is the root itself or lies outside it
|
||||
// (a workspace-rooted search never reaches such a path, so no glob is needed).
|
||||
func workspaceRelGlob(workspaceRoot, target string) (string, bool) {
|
||||
rel, err := filepath.Rel(workspaceRoot, target)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||
return "", false
|
||||
}
|
||||
return filepath.ToSlash(rel), true
|
||||
}
|
||||
|
||||
// ReadExclusionGlobs returns ripgrep-style --glob exclusion args for the policy's
|
||||
// DenyRead subtrees that fall inside the scope's workspace root, so a
|
||||
// ripgrep-based search never descends into a read-denied subtree. For each such
|
||||
// entry it emits `--glob`, `!<rel>` and `--glob`, `!<rel>/**`. Mirrors the
|
||||
// read-subtree exclusion globs used by comparable executor sandboxes.
|
||||
//
|
||||
// The projection is exclusions-only: a positive ripgrep glob would switch the
|
||||
// search into whitelist mode and restrict it to only matching files, so AllowRead
|
||||
// re-inclusion is NOT expressed here. The Go-native grep/glob tools honor
|
||||
// AllowRead precisely via the cached predicate (Engine.ReadExclusions); this
|
||||
// function is the coarser ripgrep-format export for an external rg-based
|
||||
// consumer. Empty when DenyRead is unset (the default), so search behavior is
|
||||
// unchanged.
|
||||
func ReadExclusionGlobs(policy Policy, scope *Scope) []string {
|
||||
denyRoots := resolvePolicyPaths(policy.DenyRead)
|
||||
if len(denyRoots) == 0 || scope == nil {
|
||||
return nil
|
||||
}
|
||||
workspaceRoot := scope.WorkspaceRoot()
|
||||
if workspaceRoot == "" {
|
||||
return nil
|
||||
}
|
||||
var globs []string
|
||||
for _, deny := range denyRoots {
|
||||
rel, ok := workspaceRelGlob(workspaceRoot, deny)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
globs = append(globs, "--glob", "!"+rel, "--glob", "!"+rel+"/**")
|
||||
}
|
||||
return globs
|
||||
}
|
||||
|
||||
// policyHasPathLists reports whether any fine-grained path list has an
|
||||
// ENFORCEABLE entry. It resolves the lists (matching how the rest of this file
|
||||
// normalizes them) rather than counting raw config, so a typo or non-existent
|
||||
// entry — which resolution drops — doesn't spuriously fail-close relative
|
||||
// requests when there is no workspace root.
|
||||
func policyHasPathLists(policy Policy) bool {
|
||||
return len(resolvePolicyPaths(policy.DenyRead)) > 0 ||
|
||||
len(resolvePolicyPaths(policy.AllowRead)) > 0 ||
|
||||
len(resolvePolicyPaths(policy.DenyWrite)) > 0 ||
|
||||
len(resolveWriteRootPaths(policy.AllowWrite)) > 0
|
||||
}
|
||||
|
||||
// dedupeStrings returns xs with duplicates removed, preserving first-seen order.
|
||||
func dedupeStrings(xs []string) []string {
|
||||
if len(xs) <= 1 {
|
||||
return xs
|
||||
}
|
||||
seen := make(map[string]struct{}, len(xs))
|
||||
out := make([]string, 0, len(xs))
|
||||
for _, x := range xs {
|
||||
if _, dup := seen[x]; dup {
|
||||
continue
|
||||
}
|
||||
seen[x] = struct{}{}
|
||||
out = append(out, x)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// resolvedTempDir returns a symlink-resolved temp dir so paths built under it
|
||||
// compare equal to the EvalSymlinks-normalized forms the path lists produce
|
||||
// (macOS /var -> /private/var would otherwise diverge).
|
||||
func resolvedTempDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
resolved, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("EvalSymlinks(tempdir): %v", err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func mkdir(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(%q): %v", path, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestReadDenyAllowPrecedence(t *testing.T) {
|
||||
ws := resolvedTempDir(t)
|
||||
mkdir(t, filepath.Join(ws, "src"))
|
||||
secret := mkdir(t, filepath.Join(ws, "secret"))
|
||||
public := mkdir(t, filepath.Join(ws, "secret", "public"))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
policy Policy
|
||||
path string
|
||||
wantDenied bool
|
||||
}{
|
||||
{
|
||||
name: "no lists: nothing denied",
|
||||
policy: Policy{},
|
||||
path: filepath.Join(secret, "data"),
|
||||
wantDenied: false,
|
||||
},
|
||||
{
|
||||
name: "denyread blocks subtree",
|
||||
policy: Policy{DenyRead: []string{secret}},
|
||||
path: filepath.Join(secret, "data"),
|
||||
wantDenied: true,
|
||||
},
|
||||
{
|
||||
name: "denyread does not touch siblings",
|
||||
policy: Policy{DenyRead: []string{secret}},
|
||||
path: filepath.Join(ws, "src", "main.go"),
|
||||
wantDenied: false,
|
||||
},
|
||||
{
|
||||
name: "more-specific allowread re-includes",
|
||||
policy: Policy{DenyRead: []string{secret}, AllowRead: []string{public}},
|
||||
path: filepath.Join(public, "ok.txt"),
|
||||
wantDenied: false,
|
||||
},
|
||||
{
|
||||
name: "allowread only re-includes its own subtree",
|
||||
policy: Policy{DenyRead: []string{secret}, AllowRead: []string{public}},
|
||||
path: filepath.Join(secret, "other.txt"),
|
||||
wantDenied: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := readDenied(tc.policy, ws, tc.path); got != tc.wantDenied {
|
||||
t.Fatalf("readDenied(%q) = %v, want %v", tc.path, got, tc.wantDenied)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePrecedenceMatrix(t *testing.T) {
|
||||
ws := resolvedTempDir(t)
|
||||
mkdir(t, filepath.Join(ws, "src"))
|
||||
wsSecret := mkdir(t, filepath.Join(ws, "secret"))
|
||||
ext := resolvedTempDir(t)
|
||||
mkdir(t, filepath.Join(ext, "build"))
|
||||
extProtected := mkdir(t, filepath.Join(ext, "build", "protected"))
|
||||
outside := resolvedTempDir(t) // never allowed
|
||||
|
||||
scope, err := NewScope(ws, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
policy Policy
|
||||
path string
|
||||
wantAllow bool
|
||||
}{
|
||||
{
|
||||
name: "workspace path writable by default",
|
||||
policy: Policy{},
|
||||
path: filepath.Join(ws, "src", "main.go"),
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "outside path denied by default",
|
||||
policy: Policy{},
|
||||
path: filepath.Join(outside, "x"),
|
||||
wantAllow: false,
|
||||
},
|
||||
{
|
||||
name: "allowwrite extends to external root",
|
||||
policy: Policy{AllowWrite: []string{filepath.Join(ext, "build")}},
|
||||
path: filepath.Join(ext, "build", "out.o"),
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "denywrite wins over workspace",
|
||||
policy: Policy{DenyWrite: []string{wsSecret}},
|
||||
path: filepath.Join(wsSecret, "creds"),
|
||||
wantAllow: false,
|
||||
},
|
||||
{
|
||||
name: "denywrite wins over allowwrite",
|
||||
policy: Policy{AllowWrite: []string{filepath.Join(ext, "build")}, DenyWrite: []string{extProtected}},
|
||||
path: filepath.Join(extProtected, "x"),
|
||||
wantAllow: false,
|
||||
},
|
||||
{
|
||||
name: "allowwrite does not allow unlisted external path",
|
||||
policy: Policy{AllowWrite: []string{filepath.Join(ext, "build")}},
|
||||
path: filepath.Join(outside, "x"),
|
||||
wantAllow: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// This matrix exercises the workspace-enforced write precedence.
|
||||
violation := validateWritePath(scope, tc.policy, true, ws, tc.path)
|
||||
gotAllow := violation == nil
|
||||
if gotAllow != tc.wantAllow {
|
||||
t.Fatalf("validateWritePath(%q) allow=%v (violation=%v), want allow=%v", tc.path, gotAllow, violation, tc.wantAllow)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadExclusionGlobs(t *testing.T) {
|
||||
ws := resolvedTempDir(t)
|
||||
secret := mkdir(t, filepath.Join(ws, "secret"))
|
||||
scope, err := NewScope(ws, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
}
|
||||
|
||||
// Empty DenyRead → no globs (default behavior unchanged).
|
||||
if globs := ReadExclusionGlobs(Policy{}, scope); globs != nil {
|
||||
t.Fatalf("empty DenyRead must yield no globs, got %v", globs)
|
||||
}
|
||||
|
||||
// DenyRead inside the workspace → ripgrep exclusion globs for the rel path.
|
||||
got := strings.Join(ReadExclusionGlobs(Policy{DenyRead: []string{secret}}, scope), " ")
|
||||
want := "--glob !secret --glob !secret/**"
|
||||
if got != want {
|
||||
t.Fatalf("ReadExclusionGlobs = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// DenyRead OUTSIDE the workspace → skipped (a rooted search never reaches it).
|
||||
outside := resolvedTempDir(t)
|
||||
if globs := ReadExclusionGlobs(Policy{DenyRead: []string{outside}}, scope); globs != nil {
|
||||
t.Fatalf("out-of-workspace DenyRead must yield no globs, got %v", globs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadDirExcludedDescendsForNestedAllow verifies a read-denied dir is NOT
|
||||
// skipped wholesale when it contains a nested AllowRead root, so the walk can
|
||||
// still reach the re-included subtree.
|
||||
func TestReadDirExcludedDescendsForNestedAllow(t *testing.T) {
|
||||
ws := resolvedTempDir(t)
|
||||
secret := mkdir(t, filepath.Join(ws, "secret"))
|
||||
public := mkdir(t, filepath.Join(ws, "secret", "public"))
|
||||
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: ws,
|
||||
Policy: Policy{Mode: ModeEnforce, EnforceWorkspace: true, DenyRead: []string{secret}, AllowRead: []string{public}},
|
||||
})
|
||||
// The denied dir must be descended (nested allow), but the denied dir's own
|
||||
// files are still excluded per-file.
|
||||
rx := engine.ReadExclusions()
|
||||
if rx.DirExcluded(secret) {
|
||||
t.Fatalf("a denied dir with a nested AllowRead must not be skipped wholesale")
|
||||
}
|
||||
if !rx.PathExcluded(filepath.Join(secret, "creds")) {
|
||||
t.Fatalf("a denied file outside the re-included subtree must be excluded")
|
||||
}
|
||||
if rx.PathExcluded(filepath.Join(public, "ok")) {
|
||||
t.Fatalf("a re-included AllowRead file must not be excluded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirExcludedResolvesSymlinkPrefix verifies DirExcluded resolves a symlink
|
||||
// in the path prefix before checking for a nested AllowRead root, so a denied dir
|
||||
// reached through a symlink is NOT skipped wholesale when it contains a
|
||||
// re-included subtree (the resolved allowRoots would otherwise never match).
|
||||
func TestDirExcludedResolvesSymlinkPrefix(t *testing.T) {
|
||||
ws := resolvedTempDir(t)
|
||||
real := mkdir(t, filepath.Join(ws, "real"))
|
||||
secret := mkdir(t, filepath.Join(real, "secret"))
|
||||
public := mkdir(t, filepath.Join(secret, "public"))
|
||||
link := filepath.Join(ws, "link")
|
||||
if err := os.Symlink(real, link); err != nil {
|
||||
t.Skipf("symlinks unavailable on this platform: %v", err)
|
||||
}
|
||||
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: ws,
|
||||
Policy: Policy{Mode: ModeEnforce, EnforceWorkspace: true, DenyRead: []string{secret}, AllowRead: []string{public}},
|
||||
})
|
||||
rx := engine.ReadExclusions()
|
||||
// secret reached THROUGH the symlink still contains the re-included AllowRead
|
||||
// subtree once the prefix is resolved, so it must not be skipped wholesale.
|
||||
if rx.DirExcluded(filepath.Join(link, "secret")) {
|
||||
t.Fatalf("DirExcluded must resolve the symlink prefix and detect the nested AllowRead (must not skip)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPathListsApplyWithoutWorkspaceEnforcement verifies the explicit path lists
|
||||
// (DenyWrite/DenyRead) are honored even when EnforceWorkspace is off, while the
|
||||
// workspace boundary itself is not enforced in that mode.
|
||||
func TestPathListsApplyWithoutWorkspaceEnforcement(t *testing.T) {
|
||||
ws := resolvedTempDir(t)
|
||||
secret := mkdir(t, filepath.Join(ws, "secret"))
|
||||
outside := resolvedTempDir(t)
|
||||
scope, err := NewScope(ws, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
}
|
||||
|
||||
// DenyWrite still blocks even with workspace enforcement off.
|
||||
denyPolicy := Policy{DenyWrite: []string{secret}}
|
||||
if v := validateWritePath(scope, denyPolicy, false, ws, filepath.Join(secret, "creds")); v == nil {
|
||||
t.Fatal("DenyWrite must block even when EnforceWorkspace is off")
|
||||
}
|
||||
// With enforcement off and no DenyWrite match, an out-of-workspace write is
|
||||
// NOT blocked (the workspace boundary is intentionally not applied).
|
||||
if v := validateWritePath(scope, denyPolicy, false, ws, filepath.Join(outside, "x")); v != nil {
|
||||
t.Fatalf("with EnforceWorkspace off, a non-denied path must be allowed, got %v", v)
|
||||
}
|
||||
// DenyRead still blocks reads even with workspace enforcement off.
|
||||
if v := validatePathWithPolicy(scope, Policy{DenyRead: []string{secret}}, SideEffectRead, false, ws, filepath.Join(secret, "x")); v == nil {
|
||||
t.Fatal("DenyRead must block reads even when EnforceWorkspace is off")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluatePathListsWithoutWorkspaceRoot verifies the path lists are enforced
|
||||
// even on an engine built WITHOUT a workspace root: absolute paths are matched
|
||||
// against DenyWrite, and an unanchorable relative path fails closed.
|
||||
func TestEvaluatePathListsWithoutWorkspaceRoot(t *testing.T) {
|
||||
secret := resolvedTempDir(t)
|
||||
other := resolvedTempDir(t)
|
||||
engine := NewEngine(EngineOptions{
|
||||
Policy: Policy{Mode: ModeEnforce, MaxAutonomy: AutonomyHigh, DenyWrite: []string{secret}},
|
||||
})
|
||||
|
||||
denied := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "write_file", SideEffect: SideEffectWrite,
|
||||
Args: map[string]any{"path": filepath.Join(secret, "x")},
|
||||
})
|
||||
if denied.Action != ActionDeny {
|
||||
t.Fatalf("absolute DenyWrite path must be denied without a workspace root, got %q", denied.Action)
|
||||
}
|
||||
|
||||
ok := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "write_file", SideEffect: SideEffectWrite,
|
||||
Args: map[string]any{"path": filepath.Join(other, "x")},
|
||||
})
|
||||
if ok.Action == ActionDeny {
|
||||
t.Fatalf("a non-denied absolute path must not be denied without a workspace root, got deny: %s", ok.ErrorString())
|
||||
}
|
||||
|
||||
rel := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "write_file", SideEffect: SideEffectWrite,
|
||||
Args: map[string]any{"path": "x"},
|
||||
})
|
||||
if rel.Action != ActionDeny {
|
||||
t.Fatalf("an unanchorable relative path must fail closed when path lists exist, got %q", rel.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadExclusionsInactiveWhenDisabled verifies a disabled policy filters
|
||||
// nothing — ReadExclusions/ReadExclusionGlobs are inert under ModeDisabled even
|
||||
// when DenyRead is configured.
|
||||
func TestReadExclusionsInactiveWhenDisabled(t *testing.T) {
|
||||
ws := resolvedTempDir(t)
|
||||
secret := mkdir(t, filepath.Join(ws, "secret"))
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: ws,
|
||||
Policy: Policy{Mode: ModeDisabled, DenyRead: []string{secret}},
|
||||
})
|
||||
if rx := engine.ReadExclusions(); rx.Active() {
|
||||
t.Fatal("ReadExclusions must be inactive under a disabled policy")
|
||||
}
|
||||
if globs := engine.ReadExclusionGlobs(); globs != nil {
|
||||
t.Fatalf("ReadExclusionGlobs must be empty under a disabled policy, got %v", globs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluateAppliesReadDeny verifies the lists are wired into Evaluate: a read
|
||||
// under DenyRead is denied, while a sibling read is not.
|
||||
func TestEvaluateAppliesReadDeny(t *testing.T) {
|
||||
ws := resolvedTempDir(t)
|
||||
mkdir(t, filepath.Join(ws, "src"))
|
||||
secret := mkdir(t, filepath.Join(ws, "secret"))
|
||||
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: ws,
|
||||
Policy: Policy{
|
||||
Mode: ModeEnforce,
|
||||
EnforceWorkspace: true,
|
||||
MaxAutonomy: AutonomyHigh,
|
||||
DenyRead: []string{secret},
|
||||
},
|
||||
})
|
||||
|
||||
denied := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "read_file",
|
||||
SideEffect: SideEffectRead,
|
||||
Args: map[string]any{"path": filepath.Join(secret, "creds.txt")},
|
||||
})
|
||||
if denied.Action != ActionDeny {
|
||||
t.Fatalf("read under DenyRead = %q, want deny", denied.Action)
|
||||
}
|
||||
|
||||
ok := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "read_file",
|
||||
SideEffect: SideEffectRead,
|
||||
Args: map[string]any{"path": filepath.Join(ws, "src", "main.go")},
|
||||
})
|
||||
if ok.Action == ActionDeny {
|
||||
t.Fatalf("read of a non-denied sibling must not be denied, got %q (%s)", ok.Action, ok.ErrorString())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluateAppliesWriteAllow verifies an external write is denied by default
|
||||
// but permitted once the path is on AllowWrite.
|
||||
func TestEvaluateAppliesWriteAllow(t *testing.T) {
|
||||
ws := resolvedTempDir(t)
|
||||
ext := resolvedTempDir(t)
|
||||
build := mkdir(t, filepath.Join(ext, "build"))
|
||||
|
||||
base := Policy{Mode: ModeEnforce, EnforceWorkspace: true, MaxAutonomy: AutonomyHigh}
|
||||
target := filepath.Join(build, "out.o")
|
||||
|
||||
denyEngine := NewEngine(EngineOptions{WorkspaceRoot: ws, Policy: base})
|
||||
denied := denyEngine.Evaluate(context.Background(), Request{
|
||||
ToolName: "write_file",
|
||||
SideEffect: SideEffectWrite,
|
||||
Args: map[string]any{"path": target},
|
||||
})
|
||||
if denied.Action != ActionDeny {
|
||||
t.Fatalf("external write without AllowWrite = %q, want deny", denied.Action)
|
||||
}
|
||||
|
||||
allowPolicy := base
|
||||
allowPolicy.AllowWrite = []string{build}
|
||||
allowEngine := NewEngine(EngineOptions{WorkspaceRoot: ws, Policy: allowPolicy})
|
||||
allowed := allowEngine.Evaluate(context.Background(), Request{
|
||||
ToolName: "write_file",
|
||||
SideEffect: SideEffectWrite,
|
||||
Args: map[string]any{"path": target},
|
||||
})
|
||||
if allowed.Action == ActionDeny {
|
||||
t.Fatalf("external write under AllowWrite must not be denied, got deny: %s", allowed.ErrorString())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteRootsReflectAllowWrite verifies AllowWrite roots flow into the OS
|
||||
// backend write binds (so a sandboxed shell can write there).
|
||||
func TestWriteRootsReflectAllowWrite(t *testing.T) {
|
||||
ws := resolvedTempDir(t)
|
||||
ext := resolvedTempDir(t)
|
||||
build := mkdir(t, filepath.Join(ext, "build"))
|
||||
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: ws,
|
||||
Policy: Policy{Mode: ModeEnforce, EnforceWorkspace: true, AllowWrite: []string{build}},
|
||||
})
|
||||
roots := engine.writeRoots(ws)
|
||||
found := false
|
||||
for _, r := range roots {
|
||||
if r == build {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("writeRoots must include AllowWrite root %q, got %v", build, roots)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSandboxExecProfileEmitsDenyWriteRule verifies a DenyWrite directory becomes
|
||||
// an explicit seatbelt deny clause AFTER the write-allow (last-match-wins).
|
||||
func TestSandboxExecProfileEmitsDenyWriteRule(t *testing.T) {
|
||||
ws := resolvedTempDir(t)
|
||||
secret := mkdir(t, filepath.Join(ws, "secret"))
|
||||
|
||||
policy := Policy{Mode: ModeEnforce, EnforceWorkspace: true, DenyWrite: []string{secret}}
|
||||
profile := sandboxExecProfile([]string{ws}, policy, "", "", "")
|
||||
|
||||
// The path is escaped the same way the profile escapes it (e.g. Windows
|
||||
// backslashes are doubled), so the assertion holds on every platform.
|
||||
wantDeny := `(deny file-write* (subpath "` + sandboxProfileString(secret) + `"))`
|
||||
if !strings.Contains(profile, wantDeny) {
|
||||
t.Fatalf("profile missing DenyWrite rule %q:\n%s", wantDeny, profile)
|
||||
}
|
||||
// The deny clause must appear AFTER the write-allow so seatbelt's
|
||||
// last-match-wins lets it override the bind.
|
||||
allowIdx := strings.Index(profile, "(allow file-write*")
|
||||
denyIdx := strings.Index(profile, wantDeny)
|
||||
if allowIdx < 0 || denyIdx < 0 || denyIdx < allowIdx {
|
||||
t.Fatalf("DenyWrite rule must follow the write-allow (allow@%d deny@%d):\n%s", allowIdx, denyIdx, profile)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package sandbox
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsAlreadySandboxed(t *testing.T) {
|
||||
// A lone ZERO_SANDBOXED=1 (no corroborating backend marker) must NOT count as
|
||||
// sandboxed — stronger provenance than a single ambient flag.
|
||||
t.Setenv(EnvSandboxBackend, "")
|
||||
t.Setenv(EnvSandboxed, "1")
|
||||
if IsAlreadySandboxed() {
|
||||
t.Fatalf("IsAlreadySandboxed must be false when only %s=1 is set without %s", EnvSandboxed, EnvSandboxBackend)
|
||||
}
|
||||
// The backend marker alone (no ZERO_SANDBOXED=1) must not count either.
|
||||
t.Setenv(EnvSandboxBackend, string(BackendBubblewrap))
|
||||
t.Setenv(EnvSandboxed, "")
|
||||
if IsAlreadySandboxed() {
|
||||
t.Fatalf("IsAlreadySandboxed must be false when only %s is set", EnvSandboxBackend)
|
||||
}
|
||||
// Both correlated markers present (as sandboxEnvironment sets them) → sandboxed.
|
||||
t.Setenv(EnvSandboxed, "1")
|
||||
if !IsAlreadySandboxed() {
|
||||
t.Fatalf("IsAlreadySandboxed must be true when both %s=1 and %s are set", EnvSandboxed, EnvSandboxBackend)
|
||||
}
|
||||
t.Setenv(EnvSandboxed, "0")
|
||||
if IsAlreadySandboxed() {
|
||||
t.Fatalf("IsAlreadySandboxed must be false when %s=0", EnvSandboxed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxEnvironmentMarksSandboxed(t *testing.T) {
|
||||
env := sandboxEnvironment(DefaultPolicy(), BackendBubblewrap, "/home/agent")
|
||||
// Both correlated markers must be set: IsAlreadySandboxed requires BOTH, so a
|
||||
// regression that drops either one would silently break re-entrancy detection.
|
||||
wantSandboxed := EnvSandboxed + "=1"
|
||||
wantBackend := EnvSandboxBackend + "=" + string(BackendBubblewrap)
|
||||
var gotSandboxed, gotBackend bool
|
||||
for _, entry := range env {
|
||||
switch entry {
|
||||
case wantSandboxed:
|
||||
gotSandboxed = true
|
||||
case wantBackend:
|
||||
gotBackend = true
|
||||
}
|
||||
}
|
||||
if !gotSandboxed || !gotBackend {
|
||||
t.Fatalf("sandboxEnvironment must set %q and %q so a wrapped command is detectable; got %#v", wantSandboxed, wantBackend, env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommandPlanWrapsWhenNotAlreadySandboxed(t *testing.T) {
|
||||
// Ensure neither re-entrancy marker is set so we are NOT seen as sandboxed.
|
||||
t.Setenv(EnvSandboxed, "")
|
||||
t.Setenv(EnvSandboxBackend, "")
|
||||
root := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: DefaultPolicy(),
|
||||
Backend: Backend{Name: BackendBubblewrap, Available: true, Executable: "/usr/bin/bwrap"},
|
||||
})
|
||||
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "pwd"}, Dir: root})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan: %v", err)
|
||||
}
|
||||
if !plan.Wrapped || plan.Name != "/usr/bin/bwrap" {
|
||||
t.Fatalf("expected a wrapped bubblewrap plan, got wrapped=%v name=%q", plan.Wrapped, plan.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommandPlanReEntrancyGuardReturnsPassThrough(t *testing.T) {
|
||||
// Simulate running inside an already-wrapped process: BOTH correlated markers
|
||||
// that sandboxEnvironment sets together must be present for the guard to fire.
|
||||
t.Setenv(EnvSandboxed, "1")
|
||||
t.Setenv(EnvSandboxBackend, string(BackendBubblewrap))
|
||||
root := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: DefaultPolicy(),
|
||||
Backend: Backend{Name: BackendBubblewrap, Available: true, Executable: "/usr/bin/bwrap"},
|
||||
})
|
||||
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "pwd"}, Dir: root})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan: %v", err)
|
||||
}
|
||||
if plan.Wrapped {
|
||||
t.Fatalf("re-entrancy guard must return an unwrapped pass-through plan, got wrapped=%v name=%q args=%v", plan.Wrapped, plan.Name, plan.Args)
|
||||
}
|
||||
if plan.Name != "/bin/sh" {
|
||||
t.Fatalf("pass-through plan must run the command directly, got name=%q", plan.Name)
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,22 @@ func classifyWithScope(request Request, scope *Scope) Risk {
|
||||
if pipedInstallerPattern.MatchString(command) {
|
||||
add("piped_installer", RiskCritical)
|
||||
}
|
||||
// AST second opinion (analyzer.go): walks the parsed shell tree, so it
|
||||
// catches destructive/network programs the regexes miss — e.g. shred,
|
||||
// fdisk, parted, and commands hidden behind sudo/env wrappers or a
|
||||
// `sh -c <payload>` launcher — and flags an unparseable (obfuscated)
|
||||
// script as elevated risk. It only ADDS categories, so a benign,
|
||||
// parseable command is classified exactly as before.
|
||||
analysis := AnalyzeCommand(command)
|
||||
if analysis.Network {
|
||||
add("network", RiskCritical)
|
||||
}
|
||||
if analysis.Destructive {
|
||||
add("destructive", RiskCritical)
|
||||
}
|
||||
if analysis.TooComplex {
|
||||
add("unparseable_command", RiskHigh)
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range requestPaths(request) {
|
||||
|
||||
@@ -229,3 +229,69 @@ func TestClassifyNoneSideEffectIsLowRisk(t *testing.T) {
|
||||
t.Fatalf("control-only tool must not classify as out_of_workspace: %#v", risk)
|
||||
}
|
||||
}
|
||||
|
||||
// The following tests cover the AST analyzer wired into classifyWithScope as a
|
||||
// second opinion to the regex detectors.
|
||||
|
||||
func TestClassifyASTCatchesDestructiveProgramsRegexMisses(t *testing.T) {
|
||||
// shred/fdisk/parted are irrecoverably destructive but absent from the regex
|
||||
// pattern; the AST analyzer flags them — including behind a sh -c launcher or
|
||||
// a sudo/env wrapper (effectiveProgram resolves the real program). The escalated
|
||||
// level (Critical) is part of the contract, so assert it alongside the category.
|
||||
for _, command := range []string{
|
||||
"shred -u secret.txt",
|
||||
"fdisk /dev/sda",
|
||||
"parted /dev/sda mklabel gpt",
|
||||
"bash -c 'shred /etc/passwd'",
|
||||
"sudo shred -u secret.txt",
|
||||
"env shred -u secret.txt",
|
||||
} {
|
||||
risk := classifyCommand(command)
|
||||
if risk.Level != RiskCritical || !HasRiskCategory(risk, "destructive") {
|
||||
t.Fatalf("Classify(%q) = level %s, categories %v; want critical destructive", command, risk.Level, risk.Categories)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyASTCatchesNetworkProgramsRegexMisses(t *testing.T) {
|
||||
for _, command := range []string{
|
||||
"telnet example.com 23",
|
||||
"ftp ftp.example.com",
|
||||
"sftp user@host",
|
||||
"sudo telnet example.com 23",
|
||||
} {
|
||||
risk := classifyCommand(command)
|
||||
if risk.Level != RiskCritical || !HasRiskCategory(risk, "network") {
|
||||
t.Fatalf("Classify(%q) = level %s, categories %v; want critical network", command, risk.Level, risk.Categories)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyFlagsUnparseableCommand(t *testing.T) {
|
||||
// An unparseable (e.g. obfuscated) script can't be analyzed statically; the
|
||||
// AST analyzer reports TooComplex so the classifier elevates it to High.
|
||||
risk := classifyCommand(`echo "unterminated`)
|
||||
if risk.Level != RiskHigh || !HasRiskCategory(risk, "unparseable_command") {
|
||||
t.Fatalf("Classify of unparseable command = level %s, categories %v; want high unparseable_command", risk.Level, risk.Categories)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyASTDoesNotFlagQuotedProgramName(t *testing.T) {
|
||||
// A program name inside a quoted argument is not a command, so the AST
|
||||
// analyzer must not flag it (documenting a destructive command in an echo).
|
||||
risk := classifyCommand(`echo "shred wipes files"`)
|
||||
if HasRiskCategory(risk, "destructive") {
|
||||
t.Fatalf("Classify(%q) wrongly flagged destructive: %v", `echo "shred wipes files"`, risk.Categories)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyBenignCommandStaysClean(t *testing.T) {
|
||||
for _, command := range []string{"echo hello", "ls -la", "go build ./..."} {
|
||||
risk := classifyCommand(command)
|
||||
for _, category := range []string{"destructive", "network", "unparseable_command"} {
|
||||
if HasRiskCategory(risk, category) {
|
||||
t.Fatalf("Classify(%q) wrongly flagged %s: %v", command, category, risk.Categories)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+220
-24
@@ -17,6 +17,13 @@ const bubblewrapWorkspace = "/workspace"
|
||||
|
||||
var errPolicyOnlyRunnerDisabled = errors.New("policy-only sandbox runner is disabled")
|
||||
|
||||
// errWSLPolicyOnlyDisabled is returned when running under WSL without bubblewrap
|
||||
// and the policy has NOT opted into the policy-only runner — fail closed rather
|
||||
// than run with no native isolation.
|
||||
var errWSLPolicyOnlyDisabled = errors.New("bubblewrap is unavailable under WSL and Policy.AllowPolicyOnlyRunner is disabled: " +
|
||||
"enable AllowPolicyOnlyRunner to run under the policy-only WSL fallback (engine policy + proxy egress, no native isolation), " +
|
||||
"or install/enable bubblewrap")
|
||||
|
||||
type CommandSpec struct {
|
||||
Name string
|
||||
Args []string
|
||||
@@ -39,6 +46,10 @@ type CommandPlan struct {
|
||||
// StartDenialMonitor to capture what the sandbox blocked. Empty unless
|
||||
// Policy.MonitorDenials is set on a macOS sandbox-exec plan.
|
||||
MonitorTag string `json:"monitorTag,omitempty"`
|
||||
// Notes records auditable least-privilege downgrade notes — e.g. the WSL
|
||||
// policy-only fallback noting that native isolation was unavailable. Surfaced to
|
||||
// the operator; never affects enforcement.
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
// cleanup releases resources tied to the plan's lifetime — currently the
|
||||
// scoped-egress proxy, which must outlive the command run and be shut down
|
||||
// afterwards. It is never serialized; callers invoke it via Cleanup() once the
|
||||
@@ -99,6 +110,29 @@ func ProxyEnv(addr string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// ProxyEnvWithSocks is the SOCKS-aware form of ProxyEnv built ON TOP of it:
|
||||
// HTTP_PROXY/HTTPS_PROXY stay on the HTTP listener (httpAddr) for HTTP(S)-aware
|
||||
// clients, while ALL_PROXY/all_proxy are re-pointed at the SOCKS5 front-end
|
||||
// (socksAddr) so a client that honors ALL_PROXY tunnels arbitrary TCP through the
|
||||
// same allow/deny gate. When socksAddr is empty it returns exactly ProxyEnv's
|
||||
// output, so the SOCKS upgrade is purely additive and never weakens the default.
|
||||
func ProxyEnvWithSocks(httpAddr, socksAddr string) []string {
|
||||
env := ProxyEnv(httpAddr)
|
||||
if socksAddr == "" {
|
||||
return env
|
||||
}
|
||||
socksURL := "socks5://" + socksAddr
|
||||
for i, kv := range env {
|
||||
switch {
|
||||
case strings.HasPrefix(kv, "ALL_PROXY="):
|
||||
env[i] = "ALL_PROXY=" + socksURL
|
||||
case strings.HasPrefix(kv, "all_proxy="):
|
||||
env[i] = "all_proxy=" + socksURL
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func (engine *Engine) CommandContext(ctx context.Context, spec CommandSpec) (*exec.Cmd, CommandPlan, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
@@ -118,10 +152,20 @@ func (engine *Engine) CommandContext(ctx context.Context, spec CommandSpec) (*ex
|
||||
// only applies to engines built without a workspace root (NewEngine always
|
||||
// builds a scope otherwise); it is kept as defense in depth.
|
||||
func (engine *Engine) writeRoots(workspaceRoot string) []string {
|
||||
var roots []string
|
||||
if engine.scope != nil {
|
||||
return engine.scope.Roots()
|
||||
roots = engine.scope.Roots()
|
||||
} else {
|
||||
roots = []string{workspaceRoot}
|
||||
}
|
||||
return []string{workspaceRoot}
|
||||
// Reflect the policy's AllowWrite roots in the OS backend write binds so a
|
||||
// sandboxed shell command may write where the policy grants writes. DenyWrite
|
||||
// is enforced at the policy gate, and on sandbox-exec additionally as an
|
||||
// explicit deny rule (see sandboxExecProfile).
|
||||
if extra := resolveWriteRootPaths(engine.policy.AllowWrite); len(extra) > 0 {
|
||||
roots = dedupeStrings(append(roots, extra...))
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
|
||||
@@ -146,6 +190,15 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
|
||||
if backend.Name == "" {
|
||||
backend = Backend{Name: BackendPolicyOnly, Message: "policy-only fallback: sandbox backend was not selected"}
|
||||
}
|
||||
// Re-entrancy guard: a command spawned by a process we already wrapped (both
|
||||
// ZERO_SANDBOXED=1 and ZERO_SANDBOX_BACKEND set in its env — see
|
||||
// IsAlreadySandboxed) must not be wrapped again — nested bwrap / sandbox-exec
|
||||
// fails and a second egress proxy would be redundant. Return a pass-through
|
||||
// plan. Mirrors the already-sandboxed guard used by comparable executor
|
||||
// sandboxes.
|
||||
if IsAlreadySandboxed() {
|
||||
return directCommandPlan(spec, backend, policy, workspaceRoot), nil
|
||||
}
|
||||
if policy.Mode == ModeDisabled {
|
||||
return directCommandPlan(spec, backend, policy, workspaceRoot), nil
|
||||
}
|
||||
@@ -166,6 +219,18 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
|
||||
}
|
||||
return sandboxExecCommandPlan(spec, workspaceRoot, engine.writeRoots(workspaceRoot), policy, backend, egress), nil
|
||||
}
|
||||
case BackendWSL:
|
||||
// WSL fallback: no native isolation available. Fail closed unless the policy
|
||||
// opted into the policy-only runner; otherwise run policy-only with the
|
||||
// command's egress routed through the filtering proxy and an auditable note.
|
||||
if !policy.AllowPolicyOnlyRunner {
|
||||
return CommandPlan{}, errWSLPolicyOnlyDisabled
|
||||
}
|
||||
egress, err := startScopedEgress(policy, backend)
|
||||
if err != nil {
|
||||
return CommandPlan{}, err
|
||||
}
|
||||
return wslCommandPlan(spec, workspaceRoot, policy, backend, egress), nil
|
||||
}
|
||||
if !policy.AllowPolicyOnlyRunner {
|
||||
return CommandPlan{}, errPolicyOnlyRunnerDisabled
|
||||
@@ -173,12 +238,79 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
|
||||
return directCommandPlan(spec, backend, policy, workspaceRoot), nil
|
||||
}
|
||||
|
||||
// wslCommandPlan builds the WSL policy-only fallback plan: the command runs
|
||||
// DIRECTLY (no native wrap, since bubblewrap is unavailable under WSL) but with
|
||||
// the sandbox env markers (ZERO_SANDBOXED=1, ZERO_SANDBOX_BACKEND=wsl) and, when
|
||||
// a scoped-egress proxy was started, the proxy env so well-behaved clients route
|
||||
// through the allow/deny gate. It carries a least-privilege downgrade note.
|
||||
func wslCommandPlan(spec CommandSpec, workspaceRoot string, policy Policy, backend Backend, egress *scopedEgress) CommandPlan {
|
||||
// The WSL fallback runs the command DIRECTLY (no native wrap), so it inherits
|
||||
// the caller's environment like the generic policy-only runner; the sandbox
|
||||
// markers are appended last so they cannot be shadowed by the caller's env.
|
||||
var env []string
|
||||
if spec.Env != nil {
|
||||
env = cloneStrings(spec.Env)
|
||||
} else {
|
||||
env = append(env, os.Environ()...)
|
||||
}
|
||||
env = append(env,
|
||||
EnvSandboxBackend+"="+string(BackendWSL),
|
||||
"ZERO_SANDBOX_NETWORK="+string(policy.Network),
|
||||
EnvSandboxed+"=1",
|
||||
)
|
||||
note := "WSL: native process isolation (bubblewrap) is unavailable; downgraded to the policy-only runner " +
|
||||
"(least privilege). The engine still enforces the full policy."
|
||||
if egress != nil {
|
||||
env = append(env, ProxyEnvWithSocks(egress.addr, egress.socksAddr)...)
|
||||
env = appendCACertEnv(env, egress)
|
||||
note = "WSL: native process isolation (bubblewrap) is unavailable; downgraded to the policy-only runner " +
|
||||
"with proxy egress (least privilege). The engine still enforces the full policy."
|
||||
}
|
||||
plan := CommandPlan{
|
||||
Backend: backend,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: policy,
|
||||
Wrapped: false,
|
||||
Name: spec.Name,
|
||||
Args: cloneStrings(spec.Args),
|
||||
Dir: spec.Dir,
|
||||
Env: env,
|
||||
Notes: []string{note},
|
||||
}
|
||||
if egress != nil {
|
||||
plan.cleanup = egress.cleanup
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
// scopedEgress holds the address of a started scoped-egress proxy and the
|
||||
// cleanup that shuts it down. A nil *scopedEgress means scoped egress is not in
|
||||
// effect for this command (the network mode is allow or deny-equivalent).
|
||||
type scopedEgress struct {
|
||||
addr string
|
||||
cleanup func()
|
||||
addr string
|
||||
// socksAddr is the loopback address of the proxy's SOCKS5 front-end, used to
|
||||
// set ALL_PROXY=socks5://<socksAddr>. Empty when the proxy exposes no SOCKS
|
||||
// listener, in which case ALL_PROXY falls back to the HTTP proxy.
|
||||
socksAddr string
|
||||
// caCertPath is the path to the MITM CA's public cert (PEM), set only when
|
||||
// Policy.InspectTLS started a TLS-terminating proxy. Surfaced to the sandboxed
|
||||
// client via ZERO_SANDBOX_CA_CERT so it can trust the minted leaves.
|
||||
caCertPath string
|
||||
cleanup func()
|
||||
}
|
||||
|
||||
// EnvCACert is the env var pointing the sandboxed client at the MITM CA's public
|
||||
// cert so it can trust the proxy's minted leaf certificates (only set when
|
||||
// Policy.InspectTLS is on).
|
||||
const EnvCACert = "ZERO_SANDBOX_CA_CERT"
|
||||
|
||||
// appendCACertEnv adds ZERO_SANDBOX_CA_CERT when the egress proxy is terminating
|
||||
// TLS (InspectTLS). It is a no-op for a plain passthrough proxy.
|
||||
func appendCACertEnv(env []string, egress *scopedEgress) []string {
|
||||
if egress == nil || egress.caCertPath == "" {
|
||||
return env
|
||||
}
|
||||
return append(env, EnvCACert+"="+egress.caCertPath)
|
||||
}
|
||||
|
||||
// startScopedEgress starts the local filtering proxy when the policy's effective
|
||||
@@ -196,13 +328,32 @@ func startScopedEgress(policy Policy, backend Backend) (*scopedEgress, error) {
|
||||
return nil, nil
|
||||
}
|
||||
proxy, err := startEgressProxy(egressOptions{
|
||||
Allowed: policy.AllowedDomains,
|
||||
Denied: policy.DeniedDomains,
|
||||
Allowed: policy.AllowedDomains,
|
||||
Denied: policy.DeniedDomains,
|
||||
InspectTLS: policy.InspectTLS,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scoped egress unavailable, denying network: %w", err)
|
||||
}
|
||||
return &scopedEgress{addr: proxy.Addr(), cleanup: func() { _ = proxy.Close() }}, nil
|
||||
se := &scopedEgress{addr: proxy.Addr(), socksAddr: proxy.SocksAddr(), cleanup: func() { _ = proxy.Close() }}
|
||||
if policy.InspectTLS {
|
||||
// Write the MITM CA's PUBLIC cert so the sandboxed client can trust the
|
||||
// minted leaves; surface it via ZERO_SANDBOX_CA_CERT and clean it up with
|
||||
// the proxy. Reuses no new bind path: sandbox-exec already allows file-read,
|
||||
// and the WSL policy-only runner shares the host filesystem.
|
||||
caPath, werr := writeMITMCAFile(proxy.CACertPEM())
|
||||
if werr != nil {
|
||||
_ = proxy.Close()
|
||||
return nil, werr
|
||||
}
|
||||
se.caCertPath = caPath
|
||||
base := se.cleanup
|
||||
se.cleanup = func() {
|
||||
base()
|
||||
_ = os.Remove(caPath)
|
||||
}
|
||||
}
|
||||
return se, nil
|
||||
}
|
||||
|
||||
func directCommandPlan(spec CommandSpec, backend Backend, policy Policy, workspaceRoot string) CommandPlan {
|
||||
@@ -374,21 +525,25 @@ func findSeccompHelper() string {
|
||||
}
|
||||
|
||||
func sandboxExecCommandPlan(spec CommandSpec, workspaceRoot string, writeRoots []string, policy Policy, backend Backend, egress *scopedEgress) CommandPlan {
|
||||
var proxyPort string
|
||||
var proxyPort, socksPort string
|
||||
if egress != nil {
|
||||
if _, port, err := net.SplitHostPort(egress.addr); err == nil {
|
||||
proxyPort = port
|
||||
}
|
||||
if _, port, err := net.SplitHostPort(egress.socksAddr); err == nil {
|
||||
socksPort = port
|
||||
}
|
||||
}
|
||||
denialTag := ""
|
||||
if policy.MonitorDenials {
|
||||
denialTag = nextSandboxDenialTag()
|
||||
}
|
||||
args := []string{"-p", sandboxExecProfile(writeRoots, policy, proxyPort, denialTag), spec.Name}
|
||||
args := []string{"-p", sandboxExecProfile(writeRoots, policy, proxyPort, socksPort, denialTag), spec.Name}
|
||||
args = append(args, spec.Args...)
|
||||
env := sandboxEnvironment(policy, BackendSandboxExec, workspaceRoot)
|
||||
if egress != nil {
|
||||
env = append(env, ProxyEnv(egress.addr)...)
|
||||
env = append(env, ProxyEnvWithSocks(egress.addr, egress.socksAddr)...)
|
||||
env = appendCACertEnv(env, egress)
|
||||
}
|
||||
plan := CommandPlan{
|
||||
Backend: backend,
|
||||
@@ -426,8 +581,9 @@ func sandboxEnvironment(policy Policy, backend BackendName, home string) []strin
|
||||
"HOME=" + home,
|
||||
"PATH=" + firstEnv("PATH", defaultPath()),
|
||||
"TERM=" + firstEnv("TERM", "dumb"),
|
||||
"ZERO_SANDBOX_BACKEND=" + string(backend),
|
||||
EnvSandboxBackend + "=" + string(backend),
|
||||
"ZERO_SANDBOX_NETWORK=" + string(policy.Network),
|
||||
EnvSandboxed + "=1",
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
env = append(env, "COMSPEC="+firstEnv("COMSPEC", "cmd.exe"))
|
||||
@@ -540,8 +696,8 @@ func sandboxMachLookupRule() string {
|
||||
return "(allow mach-lookup\n " + strings.Join(filters, "\n ") + ")"
|
||||
}
|
||||
|
||||
func sandboxExecProfile(writeRoots []string, policy Policy, proxyPort string, denialTag string) string {
|
||||
networkRule := networkRuleFor(policy, proxyPort)
|
||||
func sandboxExecProfile(writeRoots []string, policy Policy, proxyPort string, socksPort string, denialTag string) string {
|
||||
networkRule := networkRuleFor(policy, proxyPort, socksPort)
|
||||
writeRule := "(allow file-write*)"
|
||||
if policy.EnforceWorkspace {
|
||||
// The granted write roots are the only writable *project* locations. Temp
|
||||
@@ -566,7 +722,7 @@ func sandboxExecProfile(writeRoots []string, policy Policy, proxyPort string, de
|
||||
// filters `log stream` for this exact (per-plan) tag.
|
||||
denyDefault = `(deny default (with message "` + sandboxProfileString(denialTag) + `"))`
|
||||
}
|
||||
return strings.Join([]string{
|
||||
rules := []string{
|
||||
"(version 1)",
|
||||
denyDefault,
|
||||
"(allow process*)",
|
||||
@@ -579,27 +735,67 @@ func sandboxExecProfile(writeRoots []string, policy Policy, proxyPort string, de
|
||||
sandboxMachLookupRule(),
|
||||
"(allow file-read*)",
|
||||
writeRule,
|
||||
networkRule,
|
||||
}, "\n")
|
||||
}
|
||||
// DenyWrite entries are emitted AFTER the write allow so seatbelt's
|
||||
// last-match-wins makes them override the workspace/AllowWrite binds — so
|
||||
// "DenyWrite wins" holds for shell commands too, not just the policy gate.
|
||||
rules = append(rules, denyWriteRules(policy)...)
|
||||
rules = append(rules, networkRule)
|
||||
return strings.Join(rules, "\n")
|
||||
}
|
||||
|
||||
// denyWriteRules returns seatbelt deny clauses for the policy's resolved
|
||||
// DenyWrite paths: a (subpath ...) clause for a directory, a (literal ...) clause
|
||||
// for a single file. Empty when DenyWrite is unset.
|
||||
func denyWriteRules(policy Policy) []string {
|
||||
resolved := resolvePolicyPaths(policy.DenyWrite)
|
||||
if len(resolved) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(resolved))
|
||||
for _, path := range resolved {
|
||||
filter := `(subpath "` + sandboxProfileString(path) + `")`
|
||||
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
||||
filter = `(literal "` + sandboxProfileString(path) + `")`
|
||||
}
|
||||
out = append(out, "(deny file-write* "+filter+")")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// networkRuleFor returns the seatbelt network clause for a policy. allow opens
|
||||
// all network; deny (and an empty-allowlist scoped policy, which effectiveNetwork
|
||||
// collapses to deny) blocks all network; scoped denies general network but
|
||||
// permits only outbound to the local proxy port on localhost, so traffic must
|
||||
// flow through the filtering proxy. A scoped policy with no resolvable proxy port
|
||||
// falls back to a full deny (fail closed).
|
||||
func networkRuleFor(policy Policy, proxyPort string) string {
|
||||
// permits only outbound to the local proxy ports on localhost, so traffic must
|
||||
// flow through the filtering proxy. Both the HTTP proxy port and the SOCKS5
|
||||
// front-end port are allowed (a sandboxed process configured with
|
||||
// ALL_PROXY=socks5://... must reach the SOCKS listener). A scoped policy with no
|
||||
// resolvable proxy port falls back to a full deny (fail closed).
|
||||
func networkRuleFor(policy Policy, proxyPort string, socksPort string) string {
|
||||
switch effectiveNetwork(policy) {
|
||||
case NetworkAllow:
|
||||
return "(allow network*)"
|
||||
case NetworkScoped:
|
||||
if strings.TrimSpace(proxyPort) == "" {
|
||||
rules := []string{"(deny network*)"}
|
||||
seen := map[string]struct{}{}
|
||||
// Deny by default, then allow only outbound to each distinct proxy port on
|
||||
// loopback. Duplicate/empty ports are skipped so the clause stays minimal.
|
||||
for _, port := range []string{proxyPort, socksPort} {
|
||||
port = strings.TrimSpace(port)
|
||||
if port == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[port]; ok {
|
||||
continue
|
||||
}
|
||||
seen[port] = struct{}{}
|
||||
rules = append(rules, `(allow network-outbound (remote ip "localhost:`+sandboxProfileString(port)+`"))`)
|
||||
}
|
||||
if len(rules) == 1 {
|
||||
// No resolvable proxy port: fail closed.
|
||||
return "(deny network*)"
|
||||
}
|
||||
// Deny by default, then allow only outbound to the proxy on loopback.
|
||||
return "(deny network*)\n" +
|
||||
`(allow network-outbound (remote ip "localhost:` + sandboxProfileString(proxyPort) + `"))`
|
||||
return strings.Join(rules, "\n")
|
||||
default:
|
||||
return "(deny network*)"
|
||||
}
|
||||
|
||||
@@ -23,8 +23,17 @@ func TestScopedNetworkGateInEvaluate(t *testing.T) {
|
||||
}
|
||||
sandboxExec := Backend{Name: BackendSandboxExec, Available: true, Executable: "/usr/sbin/sandbox-exec", ScopedEgress: true}
|
||||
|
||||
// This test exercises the network GATE for a network tool, so it opts the
|
||||
// in-process tools into the network policy (EnforceToolNetwork); by default
|
||||
// those tools are exempt and would not be gated here.
|
||||
enforcedScoped := func(allowed, denied []string) Policy {
|
||||
p := scopedPolicy(allowed, denied)
|
||||
p.EnforceToolNetwork = true
|
||||
return p
|
||||
}
|
||||
|
||||
// An enforcing backend lets a populated scoped policy permit a network tool.
|
||||
enforcing := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: scopedPolicy([]string{"github.com"}, nil), Backend: sandboxExec})
|
||||
enforcing := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: enforcedScoped([]string{"github.com"}, nil), Backend: sandboxExec})
|
||||
if decision := enforcing.Evaluate(context.Background(), networkRequest); decision.Action == ActionDeny {
|
||||
t.Fatalf("enforcing backend + scoped policy denied a network tool: %#v", decision)
|
||||
}
|
||||
@@ -35,7 +44,7 @@ func TestScopedNetworkGateInEvaluate(t *testing.T) {
|
||||
"policy-only": {},
|
||||
}
|
||||
for name, backend := range unenforceable {
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: scopedPolicy([]string{"github.com"}, nil), Backend: backend})
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: enforcedScoped([]string{"github.com"}, nil), Backend: backend})
|
||||
decision := engine.Evaluate(context.Background(), networkRequest)
|
||||
if decision.Action != ActionDeny || decision.Violation == nil || decision.Violation.Code != ViolationNetwork {
|
||||
t.Fatalf("%s backend must deny scoped network it cannot enforce, got %#v", name, decision)
|
||||
@@ -43,7 +52,7 @@ func TestScopedNetworkGateInEvaluate(t *testing.T) {
|
||||
}
|
||||
|
||||
// Empty allowlist still fails closed regardless of backend.
|
||||
empty := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: scopedPolicy(nil, nil), Backend: sandboxExec})
|
||||
empty := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: enforcedScoped(nil, nil), Backend: sandboxExec})
|
||||
decision := empty.Evaluate(context.Background(), networkRequest)
|
||||
if decision.Action != ActionDeny || decision.Violation == nil || decision.Violation.Code != ViolationNetwork {
|
||||
t.Fatalf("empty scoped policy must deny network like NetworkDeny, got %#v", decision)
|
||||
|
||||
@@ -233,7 +233,7 @@ func resolvedTestPath(t *testing.T, path string) string {
|
||||
}
|
||||
|
||||
func TestSandboxExecProfileIncludesExtraWriteRoots(t *testing.T) {
|
||||
profile := sandboxExecProfile([]string{"/ws", "/extra root"}, Policy{Mode: ModeEnforce, EnforceWorkspace: true}, "", "")
|
||||
profile := sandboxExecProfile([]string{"/ws", "/extra root"}, Policy{Mode: ModeEnforce, EnforceWorkspace: true}, "", "", "")
|
||||
if !strings.Contains(profile, "(allow file-write*") {
|
||||
t.Fatalf("profile missing file-write rule:\n%s", profile)
|
||||
}
|
||||
@@ -251,7 +251,7 @@ func TestSandboxExecProfileIncludesExtraWriteRoots(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSandboxExecProfileTagsDenialsWhenMonitoring(t *testing.T) {
|
||||
off := sandboxExecProfile([]string{"/ws"}, Policy{Mode: ModeEnforce, EnforceWorkspace: true}, "", "")
|
||||
off := sandboxExecProfile([]string{"/ws"}, Policy{Mode: ModeEnforce, EnforceWorkspace: true}, "", "", "")
|
||||
if strings.Contains(off, "with message") {
|
||||
t.Fatalf("denials must not be tagged when monitoring is off:\n%s", off)
|
||||
}
|
||||
@@ -259,7 +259,7 @@ func TestSandboxExecProfileTagsDenialsWhenMonitoring(t *testing.T) {
|
||||
t.Fatalf("profile missing the plain default-deny:\n%s", off)
|
||||
}
|
||||
|
||||
on := sandboxExecProfile([]string{"/ws"}, Policy{Mode: ModeEnforce, EnforceWorkspace: true, MonitorDenials: true}, "", "run-tag-123")
|
||||
on := sandboxExecProfile([]string{"/ws"}, Policy{Mode: ModeEnforce, EnforceWorkspace: true, MonitorDenials: true}, "", "", "run-tag-123")
|
||||
if !strings.Contains(on, `(deny default (with message "run-tag-123"))`) {
|
||||
t.Fatalf("denials must be tagged when monitoring is on:\n%s", on)
|
||||
}
|
||||
@@ -291,7 +291,7 @@ func TestSandboxExecCommandPlanUsesUniquePerPlanDenialTag(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSandboxExecProfileGrantsSignalAndMachLookup(t *testing.T) {
|
||||
profile := sandboxExecProfile([]string{"/ws"}, Policy{Mode: ModeEnforce, EnforceWorkspace: true}, "", "")
|
||||
profile := sandboxExecProfile([]string{"/ws"}, Policy{Mode: ModeEnforce, EnforceWorkspace: true}, "", "", "")
|
||||
|
||||
// Signalling own process group lets a sandboxed script kill the children it
|
||||
// spawns; without it seatbelt denies kill() with "Operation not permitted".
|
||||
@@ -465,3 +465,47 @@ func TestProxyEnv(t *testing.T) {
|
||||
t.Fatalf("ProxyEnv NO_PROXY must exclude loopback, got %q/%q", env["NO_PROXY"], env["no_proxy"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyEnvWithSocks(t *testing.T) {
|
||||
env := map[string]string{}
|
||||
for _, kv := range ProxyEnvWithSocks("127.0.0.1:8899", "127.0.0.1:1080") {
|
||||
if i := strings.IndexByte(kv, '='); i >= 0 {
|
||||
env[kv[:i]] = kv[i+1:]
|
||||
}
|
||||
}
|
||||
// HTTP_PROXY/HTTPS_PROXY stay on the HTTP listener...
|
||||
const httpURL = "http://127.0.0.1:8899"
|
||||
for _, key := range []string{"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"} {
|
||||
if env[key] != httpURL {
|
||||
t.Fatalf("ProxyEnvWithSocks[%s] = %q, want %q", key, env[key], httpURL)
|
||||
}
|
||||
}
|
||||
// ...while ALL_PROXY/all_proxy point at the SOCKS5 front-end.
|
||||
const socksURL = "socks5://127.0.0.1:1080"
|
||||
for _, key := range []string{"ALL_PROXY", "all_proxy"} {
|
||||
if env[key] != socksURL {
|
||||
t.Fatalf("ProxyEnvWithSocks[%s] = %q, want %q", key, env[key], socksURL)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(env["NO_PROXY"], "127.0.0.1") {
|
||||
t.Fatalf("ProxyEnvWithSocks NO_PROXY must exclude loopback, got %q", env["NO_PROXY"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyEnvWithSocksEmptyFallsBackToHTTP verifies the SOCKS upgrade is purely
|
||||
// additive: with no SOCKS address, ALL_PROXY falls back to the HTTP proxy exactly
|
||||
// as the legacy ProxyEnv produced, never weakening the default.
|
||||
func TestProxyEnvWithSocksEmptyFallsBackToHTTP(t *testing.T) {
|
||||
env := map[string]string{}
|
||||
for _, kv := range ProxyEnvWithSocks("127.0.0.1:8899", "") {
|
||||
if i := strings.IndexByte(kv, '='); i >= 0 {
|
||||
env[kv[:i]] = kv[i+1:]
|
||||
}
|
||||
}
|
||||
const httpURL = "http://127.0.0.1:8899"
|
||||
for _, key := range []string{"ALL_PROXY", "all_proxy"} {
|
||||
if env[key] != httpURL {
|
||||
t.Fatalf("ProxyEnvWithSocks(empty socks)[%s] = %q, want HTTP fallback %q", key, env[key], httpURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,31 @@ import (
|
||||
// only an explicit truthy value enables it.
|
||||
const EnvAutoAllowBash = "ZERO_SANDBOX_AUTO_ALLOW_BASH"
|
||||
|
||||
// EnvSandboxed marks a process that zero has already wrapped in a sandbox: every
|
||||
// wrapped command carries ZERO_SANDBOXED=1 in its environment. When such a
|
||||
// process spawns another command through the engine, the re-entrancy guard
|
||||
// returns a pass-through plan instead of double-wrapping it — nested bwrap /
|
||||
// sandbox-exec fails, and a second egress proxy would be redundant. Mirrors the
|
||||
// already-sandboxed guard used by comparable executor sandboxes. Unset by default.
|
||||
const EnvSandboxed = "ZERO_SANDBOXED"
|
||||
|
||||
// EnvSandboxBackend records which backend wrapped the command. sandboxEnvironment
|
||||
// always sets it alongside EnvSandboxed, so it serves as a corroborating marker:
|
||||
// the re-entrancy guard requires BOTH, raising the provenance bar above a single
|
||||
// ambient flag (a stray or hand-exported ZERO_SANDBOXED=1 with no backend marker
|
||||
// no longer forces an unsandboxed pass-through).
|
||||
const EnvSandboxBackend = "ZERO_SANDBOX_BACKEND"
|
||||
|
||||
// IsAlreadySandboxed reports whether the current process is already running
|
||||
// inside a zero-created sandbox. It requires BOTH correlated markers that
|
||||
// sandboxEnvironment sets together — EnvSandboxed == "1" AND a non-empty
|
||||
// EnvSandboxBackend — so a single user-set/inherited ZERO_SANDBOXED=1 cannot by
|
||||
// itself disable wrapping. zero sets both only on genuinely wrapped commands;
|
||||
// pass-through (direct) plans set neither.
|
||||
func IsAlreadySandboxed() bool {
|
||||
return os.Getenv(EnvSandboxed) == "1" && strings.TrimSpace(os.Getenv(EnvSandboxBackend)) != ""
|
||||
}
|
||||
|
||||
type SideEffect string
|
||||
type Permission string
|
||||
type PermissionMode string
|
||||
@@ -106,6 +131,11 @@ const (
|
||||
BackendBubblewrap BackendName = "bubblewrap"
|
||||
BackendSandboxExec BackendName = "sandbox-exec"
|
||||
BackendPolicyOnly BackendName = "policy-only"
|
||||
// BackendWSL is the policy-only fallback used under WSL when bubblewrap is
|
||||
// unavailable/unreliable: there is no native OS isolation, but network egress
|
||||
// is still routed through the local filtering proxy and the command runs under
|
||||
// the full policy engine. It is surfaced via ZERO_SANDBOX_BACKEND=wsl.
|
||||
BackendWSL BackendName = "wsl"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -133,6 +163,14 @@ type Policy struct {
|
||||
// NetworkDeny so existing policies keep their exact behaviour.
|
||||
AllowedDomains []string `json:"allowedDomains,omitempty"`
|
||||
DeniedDomains []string `json:"deniedDomains,omitempty"`
|
||||
// EnforceToolNetwork, when true, subjects the first-party in-process network
|
||||
// tools (web_search / web_fetch) to the sandbox network policy via
|
||||
// NetworkHostAllowed. It is OFF by default: that policy exists to confine the
|
||||
// sandboxed SHELL's egress, which these tools do not use, so by default they
|
||||
// are allowed to run (keeping their own SSRF/port/redirect/redaction
|
||||
// safeguards). The sandboxed-shell egress decision is independent of this flag.
|
||||
// Turn it on to also hold web_search/web_fetch to the allow/scoped/deny policy.
|
||||
EnforceToolNetwork bool `json:"enforceToolNetwork,omitempty"`
|
||||
// AutoAllowBashWhenSandboxed, when true, auto-allows the bash tool WITHOUT a
|
||||
// permission prompt — but only when the sandbox is actually active (a
|
||||
// native-isolation backend wraps the command). The sandbox is then the safety
|
||||
@@ -153,6 +191,40 @@ type Policy struct {
|
||||
// (runs without the filter) when the helper binary is not found. Ignored on
|
||||
// non-bubblewrap backends.
|
||||
BlockUnixSockets bool `json:"blockUnixSockets,omitempty"`
|
||||
// AllowRead/DenyRead/AllowWrite/DenyWrite are fine-grained path lists layered
|
||||
// ON TOP of the workspace + Scope guards; they never bypass the symlink /
|
||||
// out-of-workspace protections. Each entry is home-expanded, made absolute, and
|
||||
// symlink-resolved (an entry that does not exist is dropped). All default empty,
|
||||
// so an unconfigured policy behaves exactly as before. Semantics:
|
||||
//
|
||||
// - Read: a path readable under the base workspace/Scope guard is denied if it
|
||||
// falls under a DenyRead entry, UNLESS a more-specific AllowRead entry (one
|
||||
// nested inside that DenyRead entry) re-includes it. AllowRead only
|
||||
// re-includes within a DenyRead carve-out; it never extends reads beyond the
|
||||
// workspace.
|
||||
// - Write: DenyWrite wins over everything; otherwise a path writable under the
|
||||
// workspace/Scope guard is allowed; otherwise an absolute path under an
|
||||
// AllowWrite root is allowed; otherwise it is denied. AllowWrite roots are
|
||||
// also reflected in the OS backend write binds, and on sandbox-exec DenyWrite
|
||||
// entries are emitted as explicit deny rules so the precedence holds for
|
||||
// shell commands too.
|
||||
AllowRead []string `json:"allowRead,omitempty"`
|
||||
DenyRead []string `json:"denyRead,omitempty"`
|
||||
AllowWrite []string `json:"allowWrite,omitempty"`
|
||||
DenyWrite []string `json:"denyWrite,omitempty"`
|
||||
// InspectTLS, when true, lets the in-process scoped-egress proxy TERMINATE TLS
|
||||
// (using a locally generated, ephemeral CA) so it can enforce the per-host
|
||||
// allow/deny rules on the DECRYPTED request Host — today a CONNECT tunnel only
|
||||
// reveals the host once. OFF by default: the proxy stays a pure CONNECT
|
||||
// passthrough, byte-for-byte unchanged. SECURITY/TRUST: enabling this means the
|
||||
// sandbox can read the plaintext of the sandboxed process's TLS traffic. The
|
||||
// MITM only re-signs toward the sandboxed client; the upstream connection is
|
||||
// ALWAYS validated against the system roots (never InsecureSkipVerify), and the
|
||||
// decrypted Host still passes the SAME authorize()/domainAllowed() gate — MITM
|
||||
// widens visibility, never authority. The generated CA's public cert is written
|
||||
// to the sandbox runtime dir and surfaced via ZERO_SANDBOX_CA_CERT so the
|
||||
// sandboxed client can trust it.
|
||||
InspectTLS bool `json:"inspectTLS,omitempty"`
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package sandbox
|
||||
|
||||
import "strings"
|
||||
|
||||
// WSLInfo describes the Windows Subsystem for Linux environment, if any. It is
|
||||
// derived from /proc/version. Mirrors reference wsl/index.js isWSL/isWSL2 and
|
||||
// sandbox-manager.js isWsl (reads /proc/version, matches "microsoft").
|
||||
type WSLInfo struct {
|
||||
IsWSL bool
|
||||
IsWSL2 bool
|
||||
Kernel string
|
||||
}
|
||||
|
||||
// parseWSL classifies a /proc/version string. IsWSL is set when it contains
|
||||
// "microsoft" or "wsl"; IsWSL2 additionally requires "microsoft" and the absence
|
||||
// of the legacy "wsl1" marker. Factored out (pure) so it is table-testable
|
||||
// without a real /proc on any GOOS.
|
||||
func parseWSL(procVersion string) WSLInfo {
|
||||
lower := strings.ToLower(procVersion)
|
||||
isWSL := strings.Contains(lower, "microsoft") || strings.Contains(lower, "wsl")
|
||||
info := WSLInfo{IsWSL: isWSL, Kernel: strings.TrimSpace(procVersion)}
|
||||
if isWSL {
|
||||
info.IsWSL2 = strings.Contains(lower, "microsoft") && !strings.Contains(lower, "wsl1")
|
||||
}
|
||||
return info
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build linux
|
||||
|
||||
package sandbox
|
||||
|
||||
import "os"
|
||||
|
||||
// detectWSL reads /proc/version once and classifies the WSL environment. A read
|
||||
// error (no /proc) yields the zero WSLInfo (not WSL).
|
||||
func detectWSL() WSLInfo {
|
||||
data, err := os.ReadFile("/proc/version")
|
||||
if err != nil {
|
||||
return WSLInfo{}
|
||||
}
|
||||
return parseWSL(string(data))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !linux
|
||||
|
||||
package sandbox
|
||||
|
||||
// detectWSL is a no-op on non-Linux platforms: WSL is a Linux-under-Windows
|
||||
// environment, so the host GOOS is never WSL. Keeps the package building on all
|
||||
// platforms.
|
||||
func detectWSL() WSLInfo { return WSLInfo{} }
|
||||
@@ -0,0 +1,139 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseWSL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantWSL bool
|
||||
wantWSL2 bool
|
||||
}{
|
||||
{
|
||||
name: "wsl2 microsoft kernel",
|
||||
input: "Linux version 5.15.90.1-microsoft-standard-WSL2 (...)",
|
||||
wantWSL: true,
|
||||
wantWSL2: true,
|
||||
},
|
||||
{
|
||||
name: "wsl1 legacy marker",
|
||||
input: "Linux version 4.4.0-19041-Microsoft (wsl1 build) ...",
|
||||
wantWSL: true,
|
||||
wantWSL2: false,
|
||||
},
|
||||
{
|
||||
name: "plain linux",
|
||||
input: "Linux version 6.8.0-generic (gcc ...) #1 SMP",
|
||||
wantWSL: false,
|
||||
wantWSL2: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := parseWSL(tc.input)
|
||||
if got.IsWSL != tc.wantWSL || got.IsWSL2 != tc.wantWSL2 {
|
||||
t.Fatalf("parseWSL(%q) = {IsWSL:%v IsWSL2:%v}, want {%v %v}", tc.input, got.IsWSL, got.IsWSL2, tc.wantWSL, tc.wantWSL2)
|
||||
}
|
||||
if got.IsWSL && got.Kernel == "" {
|
||||
t.Fatalf("parseWSL should record the kernel string")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func wslBackendForTest() Backend {
|
||||
return Backend{Name: BackendWSL, Platform: "linux", Fallback: true, ProxyEgress: true}
|
||||
}
|
||||
|
||||
func TestWSLPlanFailsClosedWithoutPolicyOnly(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
policy := scopedPolicy([]string{"github.com"}, nil)
|
||||
policy.AllowPolicyOnlyRunner = false // explicitly refuse the degraded runner
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: policy, Backend: wslBackendForTest()})
|
||||
|
||||
_, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "pwd"}, Dir: root})
|
||||
if !errors.Is(err, errWSLPolicyOnlyDisabled) {
|
||||
t.Fatalf("WSL without AllowPolicyOnlyRunner err = %v, want errWSLPolicyOnlyDisabled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSLPolicyOnlyPlanCarriesProxyAndMarkers(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// scopedPolicy uses DefaultPolicy (AllowPolicyOnlyRunner=true) + an allowlist,
|
||||
// so the WSL fallback starts the filtering proxy and runs policy-only.
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: scopedPolicy([]string{"github.com"}, nil), Backend: wslBackendForTest()})
|
||||
|
||||
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "pwd"}, Dir: root, Env: []string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan: %v", err)
|
||||
}
|
||||
defer plan.Cleanup()
|
||||
|
||||
// Policy-only: the command runs directly, not OS-wrapped.
|
||||
if plan.Wrapped || plan.Name != "/bin/sh" {
|
||||
t.Fatalf("WSL plan must run the command directly, got wrapped=%v name=%q", plan.Wrapped, plan.Name)
|
||||
}
|
||||
env := strings.Join(plan.Env, "\n")
|
||||
if !strings.Contains(env, EnvSandboxed+"=1") {
|
||||
t.Fatalf("WSL plan env missing %s=1:\n%s", EnvSandboxed, env)
|
||||
}
|
||||
if !strings.Contains(env, EnvSandboxBackend+"=wsl") {
|
||||
t.Fatalf("WSL plan env missing %s=wsl:\n%s", EnvSandboxBackend, env)
|
||||
}
|
||||
if !strings.Contains(env, "HTTP_PROXY=") || !strings.Contains(env, "ALL_PROXY=") {
|
||||
t.Fatalf("WSL plan env missing proxy env (ProxyEnvWithSocks):\n%s", env)
|
||||
}
|
||||
if len(plan.Notes) == 0 || !strings.Contains(strings.Join(plan.Notes, " "), "policy-only") {
|
||||
t.Fatalf("WSL plan must record a least-privilege downgrade note, got %v", plan.Notes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSLPlanPreservesCallerEnv(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: wslBackendForTest()})
|
||||
plan, err := engine.BuildCommandPlan(CommandSpec{
|
||||
Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: root,
|
||||
Env: []string{"FOO=bar", "PATH=/custom/bin"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan: %v", err)
|
||||
}
|
||||
defer plan.Cleanup()
|
||||
env := strings.Join(plan.Env, "\n")
|
||||
// The WSL direct-run inherits the caller's env (it is not OS-wrapped)...
|
||||
if !strings.Contains(env, "FOO=bar") || !strings.Contains(env, "PATH=/custom/bin") {
|
||||
t.Fatalf("WSL plan must preserve caller env, got:\n%s", env)
|
||||
}
|
||||
// ...with the sandbox markers appended (so they win over caller env).
|
||||
if !strings.Contains(env, EnvSandboxed+"=1") || !strings.Contains(env, EnvSandboxBackend+"=wsl") {
|
||||
t.Fatalf("WSL plan must append sandbox markers:\n%s", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSLPolicyOnlyDenyPlanHasMarkersNoProxy(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Default policy (NetworkDeny) → no proxy started, but the WSL markers + note
|
||||
// still apply and the plan still runs (AllowPolicyOnlyRunner default true).
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: wslBackendForTest()})
|
||||
// Explicit empty env so the assertion below is not confused by a dev/CI
|
||||
// HTTP_PROXY inherited via os.Environ().
|
||||
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "pwd"}, Dir: root, Env: []string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan: %v", err)
|
||||
}
|
||||
defer plan.Cleanup()
|
||||
env := strings.Join(plan.Env, "\n")
|
||||
if !strings.Contains(env, EnvSandboxBackend+"=wsl") || !strings.Contains(env, EnvSandboxed+"=1") {
|
||||
t.Fatalf("WSL deny plan must still carry sandbox markers:\n%s", env)
|
||||
}
|
||||
if len(plan.Notes) == 0 || !strings.Contains(strings.Join(plan.Notes, " "), "policy-only") {
|
||||
t.Fatalf("WSL deny plan must record a least-privilege downgrade note, got %v", plan.Notes)
|
||||
}
|
||||
if strings.Contains(env, "HTTP_PROXY=") || strings.Contains(env, "HTTPS_PROXY=") || strings.Contains(env, "ALL_PROXY=") {
|
||||
t.Fatalf("WSL deny plan must NOT export any proxy env vars (network denied):\n%s", env)
|
||||
}
|
||||
}
|
||||
+44
-12
@@ -58,14 +58,20 @@ type BuildArgsInput struct {
|
||||
CurrentDepth int
|
||||
Description string
|
||||
Cwd string
|
||||
// PermissionMode is the parent's resolved permission mode. Empty preserves
|
||||
// the historical "--auto high" (the Task tool omits it); a caller that
|
||||
// passes a non-unsafe mode (e.g. the swarm propagating a non-unsafe
|
||||
// orchestrator) gets a non-unsafe child so authority is never widened.
|
||||
PermissionMode string
|
||||
}
|
||||
|
||||
type BuildResumeArgsInput struct {
|
||||
SessionID string
|
||||
Prompt string
|
||||
CurrentDepth int
|
||||
Manifest Manifest
|
||||
Cwd string
|
||||
SessionID string
|
||||
Prompt string
|
||||
CurrentDepth int
|
||||
Manifest Manifest
|
||||
Cwd string
|
||||
PermissionMode string
|
||||
}
|
||||
|
||||
type BuildArgsResult struct {
|
||||
@@ -90,8 +96,32 @@ type TaskRunOptions struct {
|
||||
ParentReasoningEffort string
|
||||
CurrentDepth int
|
||||
Cwd string
|
||||
// PermissionMode propagates the parent's resolved permission mode to the
|
||||
// child. Empty keeps the historical "--auto high"; a non-unsafe value runs
|
||||
// the child non-unsafe so it never gains more authority than the parent.
|
||||
PermissionMode string
|
||||
}
|
||||
|
||||
// specialistAutonomy maps a parent permission mode to the child's "--auto"
|
||||
// level. A headless specialist child cannot answer interactive prompts, so it
|
||||
// runs autonomously — but only at "high" (unsafe) when the parent is itself
|
||||
// unsafe (or when no mode is provided, preserving the Task tool's behavior).
|
||||
// Any other parent mode yields the non-unsafe "low" level, so the child's
|
||||
// authority never exceeds the parent's.
|
||||
func specialistAutonomy(permissionMode string) string {
|
||||
switch strings.TrimSpace(permissionMode) {
|
||||
case "", string(permissionModeUnsafe):
|
||||
return "high"
|
||||
default:
|
||||
return "low"
|
||||
}
|
||||
}
|
||||
|
||||
// permissionModeUnsafe mirrors agent.PermissionModeUnsafe without importing the
|
||||
// agent package (which would create an import cycle): exec resolves "--auto high"
|
||||
// to this mode.
|
||||
const permissionModeUnsafe = "unsafe"
|
||||
|
||||
type ExecResult struct {
|
||||
Result tools.Result
|
||||
SessionID string
|
||||
@@ -147,7 +177,7 @@ func (executor Executor) BuildArgs(input BuildArgsInput) (BuildArgsResult, error
|
||||
args := []string{"exec", "--init-session-id", sessionID}
|
||||
args = append(args, promptArgs...)
|
||||
args = appendModelArgs(args, input.Manifest, input.ParentModel, input.ParentReasoningEffort)
|
||||
args = append(args, "--auto", "high", "--output-format", "stream-json")
|
||||
args = append(args, "--auto", specialistAutonomy(input.PermissionMode), "--output-format", "stream-json")
|
||||
toolAllowlist, err := resolvedToolAllowlist(input.Manifest)
|
||||
if err != nil {
|
||||
return BuildArgsResult{}, err
|
||||
@@ -199,7 +229,7 @@ func (executor Executor) BuildResumeArgs(input BuildResumeArgsInput) (BuildArgsR
|
||||
}
|
||||
args := []string{"exec", "--resume", sessionID}
|
||||
args = append(args, promptArgs...)
|
||||
args = append(args, "--auto", "high", "--output-format", "stream-json")
|
||||
args = append(args, "--auto", specialistAutonomy(input.PermissionMode), "--output-format", "stream-json")
|
||||
toolAllowlist, err := resolvedToolAllowlist(input.Manifest)
|
||||
if err != nil {
|
||||
return BuildArgsResult{}, err
|
||||
@@ -230,6 +260,7 @@ func (executor Executor) runFresh(ctx context.Context, params TaskParameters, op
|
||||
CurrentDepth: options.CurrentDepth,
|
||||
Description: params.Description,
|
||||
Cwd: options.Cwd,
|
||||
PermissionMode: options.PermissionMode,
|
||||
})
|
||||
if err != nil {
|
||||
return ExecResult{}, err
|
||||
@@ -257,11 +288,12 @@ func (executor Executor) runResume(ctx context.Context, params TaskParameters, o
|
||||
return ExecResult{}, err
|
||||
}
|
||||
built, err := executor.BuildResumeArgs(BuildResumeArgsInput{
|
||||
SessionID: params.Resume,
|
||||
Prompt: params.Prompt,
|
||||
CurrentDepth: options.CurrentDepth,
|
||||
Manifest: manifest,
|
||||
Cwd: options.Cwd,
|
||||
SessionID: params.Resume,
|
||||
Prompt: params.Prompt,
|
||||
CurrentDepth: options.CurrentDepth,
|
||||
Manifest: manifest,
|
||||
Cwd: options.Cwd,
|
||||
PermissionMode: options.PermissionMode,
|
||||
})
|
||||
if err != nil {
|
||||
return ExecResult{}, err
|
||||
|
||||
@@ -64,6 +64,65 @@ func TestBuildArgsCreatesFreshSpecialistExecInvocation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecialistAutonomyByPermissionMode(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "high", // back-compat: the Task tool omits the mode
|
||||
"unsafe": "high",
|
||||
"auto": "low",
|
||||
"ask": "low",
|
||||
"spec-draft": "low",
|
||||
"whatever": "low",
|
||||
}
|
||||
for mode, want := range cases {
|
||||
if got := specialistAutonomy(mode); got != want {
|
||||
t.Errorf("specialistAutonomy(%q) = %q, want %q", mode, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildArgsAutonomyHonorsPermissionMode(t *testing.T) {
|
||||
executor := Executor{NewSessionID: func() (string, error) { return "child", nil }}
|
||||
manifest := Manifest{Metadata: Metadata{Name: "reviewer"}, SystemPrompt: "x", ResolvedTools: []string{"read_file"}}
|
||||
|
||||
// A non-unsafe parent mode yields a non-unsafe child (--auto low), so a
|
||||
// swarm member never gains more authority than a non-unsafe orchestrator.
|
||||
res, err := executor.BuildArgs(BuildArgsInput{Manifest: manifest, Prompt: "p", PermissionMode: "auto"})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildArgs: %v", err)
|
||||
}
|
||||
if !containsSequence(res.Args, []string{"--auto", "low"}) {
|
||||
t.Fatalf("non-unsafe parent must yield --auto low, got %v", res.Args)
|
||||
}
|
||||
if containsSequence(res.Args, []string{"--auto", "high"}) {
|
||||
t.Fatalf("non-unsafe parent must NOT yield --auto high: %v", res.Args)
|
||||
}
|
||||
|
||||
// An unsafe parent (and the empty back-compat case) keep --auto high.
|
||||
for _, mode := range []string{"unsafe", ""} {
|
||||
out, err := executor.BuildArgs(BuildArgsInput{Manifest: manifest, Prompt: "p", PermissionMode: mode})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildArgs(%q): %v", mode, err)
|
||||
}
|
||||
if !containsSequence(out.Args, []string{"--auto", "high"}) {
|
||||
t.Fatalf("mode %q must yield --auto high, got %v", mode, out.Args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResumeArgsAutonomyHonorsPermissionMode(t *testing.T) {
|
||||
executor := Executor{}
|
||||
manifest := Manifest{Metadata: Metadata{Name: "reviewer"}, ResolvedTools: []string{"read_file"}}
|
||||
res, err := executor.BuildResumeArgs(BuildResumeArgsInput{
|
||||
SessionID: "child_session", Prompt: "p", Manifest: manifest, PermissionMode: "auto",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildResumeArgs: %v", err)
|
||||
}
|
||||
if !containsSequence(res.Args, []string{"--auto", "low"}) || containsSequence(res.Args, []string{"--auto", "high"}) {
|
||||
t.Fatalf("resume non-unsafe parent must yield --auto low, got %v", res.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildArgsSessionTitleFallsBackToNameWithoutDescription(t *testing.T) {
|
||||
executor := Executor{NewSessionID: func() (string, error) { return "child", nil }}
|
||||
manifest := Manifest{
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TaskStatus is the lifecycle state of a swarm task.
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
// StatusPending: registered but the member has not started.
|
||||
StatusPending TaskStatus = "pending"
|
||||
// StatusRunning: the member is executing.
|
||||
StatusRunning TaskStatus = "running"
|
||||
// StatusDone: the member finished successfully.
|
||||
StatusDone TaskStatus = "done"
|
||||
// StatusFailed: the member exited with an error.
|
||||
StatusFailed TaskStatus = "failed"
|
||||
// StatusHandedOff: ownership was transferred to another agent.
|
||||
StatusHandedOff TaskStatus = "handed-off"
|
||||
)
|
||||
|
||||
// terminal reports whether a status is final (no further transitions expected).
|
||||
func (s TaskStatus) terminal() bool {
|
||||
return s == StatusDone || s == StatusFailed || s == StatusHandedOff
|
||||
}
|
||||
|
||||
// valid reports whether a status is one of the known lifecycle states. Unknown
|
||||
// statuses are rejected (fail closed) so a task can never enter an undefined
|
||||
// state that Summarize and the lifecycle logic cannot reason about.
|
||||
func (s TaskStatus) valid() bool {
|
||||
switch s {
|
||||
case StatusPending, StatusRunning, StatusDone, StatusFailed, StatusHandedOff:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Task is one unit of work tracked by the coordinator. It is returned by value
|
||||
// from snapshots so callers cannot mutate coordinator state without the lock.
|
||||
type Task struct {
|
||||
ID string
|
||||
AgentID string
|
||||
Team string
|
||||
Description string
|
||||
Status TaskStatus
|
||||
Result string
|
||||
Err string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// agentColors palette for stable per-agent coloring in the TUI/status. Provider
|
||||
// -neutral ANSI-ish names; the renderer maps them to styles.
|
||||
var agentColors = []string{"cyan", "magenta", "green", "yellow", "blue", "red"}
|
||||
|
||||
// Coordinator is the in-memory task registry + team color assigner shared by an
|
||||
// orchestrator and its members. It is safe for concurrent use.
|
||||
type Coordinator struct {
|
||||
mu sync.RWMutex
|
||||
tasks map[string]*Task
|
||||
colors map[string]string // agentID -> color
|
||||
colorIndex int
|
||||
now func() time.Time // injectable clock for tests
|
||||
}
|
||||
|
||||
// NewCoordinator returns an empty coordinator using the wall clock.
|
||||
func NewCoordinator() *Coordinator {
|
||||
return &Coordinator{
|
||||
tasks: map[string]*Task{},
|
||||
colors: map[string]string{},
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// ErrTaskExists is returned when registering a duplicate task ID.
|
||||
var ErrTaskExists = errors.New("swarm: task already registered")
|
||||
|
||||
// ErrUnknownTask is returned when updating/removing a missing task ID.
|
||||
var ErrUnknownTask = errors.New("swarm: unknown task")
|
||||
|
||||
// Register adds a new pending task. The ID must be unique and non-empty.
|
||||
func (c *Coordinator) Register(id, agentID, team, description string) (Task, error) {
|
||||
if id == "" {
|
||||
return Task{}, errors.New("swarm: task id is required")
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if _, ok := c.tasks[id]; ok {
|
||||
return Task{}, fmt.Errorf("%w: %s", ErrTaskExists, id)
|
||||
}
|
||||
now := c.now()
|
||||
t := &Task{
|
||||
ID: id,
|
||||
AgentID: agentID,
|
||||
Team: team,
|
||||
Description: description,
|
||||
Status: StatusPending,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
c.tasks[id] = t
|
||||
c.assignColorLocked(agentID)
|
||||
return *t, nil
|
||||
}
|
||||
|
||||
// SetStatus transitions a task. Transitions out of a terminal state are
|
||||
// rejected (fail closed) so a late member update cannot resurrect a finished
|
||||
// task.
|
||||
func (c *Coordinator) SetStatus(id string, status TaskStatus) error {
|
||||
if !status.valid() {
|
||||
return fmt.Errorf("swarm: invalid task status %q", status)
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
t, ok := c.tasks[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrUnknownTask, id)
|
||||
}
|
||||
if t.Status.terminal() && t.Status != status {
|
||||
return fmt.Errorf("swarm: task %s is %s (terminal); cannot move to %s", id, t.Status, status)
|
||||
}
|
||||
t.Status = status
|
||||
t.UpdatedAt = c.now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Complete marks a task done with its result.
|
||||
func (c *Coordinator) Complete(id, result string) error {
|
||||
return c.finish(id, StatusDone, result, "")
|
||||
}
|
||||
|
||||
// Fail marks a task failed with an error message.
|
||||
func (c *Coordinator) Fail(id, errMsg string) error {
|
||||
return c.finish(id, StatusFailed, "", errMsg)
|
||||
}
|
||||
|
||||
func (c *Coordinator) finish(id string, status TaskStatus, result, errMsg string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
t, ok := c.tasks[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrUnknownTask, id)
|
||||
}
|
||||
if t.Status.terminal() {
|
||||
return fmt.Errorf("swarm: task %s already %s", id, t.Status)
|
||||
}
|
||||
t.Status = status
|
||||
t.Result = result
|
||||
t.Err = errMsg
|
||||
t.UpdatedAt = c.now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reassign transfers a task to a new agent (handoff/orphan-adoption). The task
|
||||
// returns to pending under the new owner unless it is already terminal.
|
||||
func (c *Coordinator) Reassign(id, newAgentID string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
t, ok := c.tasks[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrUnknownTask, id)
|
||||
}
|
||||
if t.Status.terminal() {
|
||||
return fmt.Errorf("swarm: task %s already %s; cannot reassign", id, t.Status)
|
||||
}
|
||||
t.AgentID = newAgentID
|
||||
t.Status = StatusPending
|
||||
t.UpdatedAt = c.now()
|
||||
c.assignColorLocked(newAgentID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns a snapshot of one task.
|
||||
func (c *Coordinator) Get(id string) (Task, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
t, ok := c.tasks[id]
|
||||
if !ok {
|
||||
return Task{}, false
|
||||
}
|
||||
return *t, true
|
||||
}
|
||||
|
||||
// List returns snapshots of all tasks, ordered by creation time then ID for
|
||||
// stable output.
|
||||
func (c *Coordinator) List() []Task {
|
||||
c.mu.RLock()
|
||||
out := make([]Task, 0, len(c.tasks))
|
||||
for _, t := range c.tasks {
|
||||
out = append(out, *t)
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].CreatedAt.Equal(out[j].CreatedAt) {
|
||||
return out[i].ID < out[j].ID
|
||||
}
|
||||
return out[i].CreatedAt.Before(out[j].CreatedAt)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Color returns the stable color assigned to an agent (assigning one on first
|
||||
// use), so status output colors each member consistently.
|
||||
func (c *Coordinator) Color(agentID string) string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.assignColorLocked(agentID)
|
||||
}
|
||||
|
||||
func (c *Coordinator) assignColorLocked(agentID string) string {
|
||||
if agentID == "" {
|
||||
return ""
|
||||
}
|
||||
if color, ok := c.colors[agentID]; ok {
|
||||
return color
|
||||
}
|
||||
color := agentColors[c.colorIndex%len(agentColors)]
|
||||
c.colors[agentID] = color
|
||||
c.colorIndex++
|
||||
return color
|
||||
}
|
||||
|
||||
// Summary is an aggregate count of tasks by status for the status tool.
|
||||
type Summary struct {
|
||||
Total int
|
||||
Pending int
|
||||
Running int
|
||||
Done int
|
||||
Failed int
|
||||
HandedOff int
|
||||
}
|
||||
|
||||
// Summarize aggregates current task counts.
|
||||
func (c *Coordinator) Summarize() Summary {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
s := Summary{Total: len(c.tasks)}
|
||||
for _, t := range c.tasks {
|
||||
switch t.Status {
|
||||
case StatusPending:
|
||||
s.Pending++
|
||||
case StatusRunning:
|
||||
s.Running++
|
||||
case StatusDone:
|
||||
s.Done++
|
||||
case StatusFailed:
|
||||
s.Failed++
|
||||
case StatusHandedOff:
|
||||
s.HandedOff++
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCoordinatorRegisterAndDuplicate(t *testing.T) {
|
||||
c := NewCoordinator()
|
||||
if _, err := c.Register("t1", "a1", "team", "desc"); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
task, ok := c.Get("t1")
|
||||
if !ok || task.Status != StatusPending {
|
||||
t.Fatalf("Get after register: ok=%v status=%v", ok, task.Status)
|
||||
}
|
||||
if _, err := c.Register("t1", "a1", "team", "desc"); !errors.Is(err, ErrTaskExists) {
|
||||
t.Fatalf("duplicate Register err = %v, want ErrTaskExists", err)
|
||||
}
|
||||
if _, err := c.Register("", "a", "team", "d"); err == nil {
|
||||
t.Fatal("empty id must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinatorLifecycleTransitions(t *testing.T) {
|
||||
c := NewCoordinator()
|
||||
_, _ = c.Register("t1", "a1", "team", "desc")
|
||||
if err := c.SetStatus("t1", StatusRunning); err != nil {
|
||||
t.Fatalf("SetStatus running: %v", err)
|
||||
}
|
||||
if err := c.Complete("t1", "result-data"); err != nil {
|
||||
t.Fatalf("Complete: %v", err)
|
||||
}
|
||||
task, _ := c.Get("t1")
|
||||
if task.Status != StatusDone || task.Result != "result-data" {
|
||||
t.Fatalf("after Complete: status=%v result=%q", task.Status, task.Result)
|
||||
}
|
||||
// Terminal guard: cannot move done -> running, nor complete twice.
|
||||
if err := c.SetStatus("t1", StatusRunning); err == nil {
|
||||
t.Fatal("moving a done task to running must fail")
|
||||
}
|
||||
if err := c.Complete("t1", "x"); err == nil {
|
||||
t.Fatal("completing a done task must fail")
|
||||
}
|
||||
if err := c.Fail("t1", "boom"); err == nil {
|
||||
t.Fatal("failing a done task must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinatorRejectsInvalidStatus(t *testing.T) {
|
||||
c := NewCoordinator()
|
||||
_, _ = c.Register("t1", "a1", "team", "desc")
|
||||
if err := c.SetStatus("t1", TaskStatus("bogus")); err == nil {
|
||||
t.Fatal("SetStatus must reject an unknown status")
|
||||
}
|
||||
// The task stays in its prior (valid) state.
|
||||
if task, _ := c.Get("t1"); task.Status != StatusPending {
|
||||
t.Fatalf("rejected status must not mutate the task, got %v", task.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinatorUnknownTask(t *testing.T) {
|
||||
c := NewCoordinator()
|
||||
if err := c.SetStatus("missing", StatusRunning); !errors.Is(err, ErrUnknownTask) {
|
||||
t.Fatalf("SetStatus(missing) = %v, want ErrUnknownTask", err)
|
||||
}
|
||||
if err := c.Complete("missing", "x"); !errors.Is(err, ErrUnknownTask) {
|
||||
t.Fatalf("Complete(missing) = %v, want ErrUnknownTask", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinatorReassign(t *testing.T) {
|
||||
c := NewCoordinator()
|
||||
_, _ = c.Register("t1", "a1", "team", "desc")
|
||||
_ = c.SetStatus("t1", StatusRunning)
|
||||
if err := c.Reassign("t1", "a2"); err != nil {
|
||||
t.Fatalf("Reassign: %v", err)
|
||||
}
|
||||
task, _ := c.Get("t1")
|
||||
if task.AgentID != "a2" || task.Status != StatusPending {
|
||||
t.Fatalf("after Reassign: agent=%q status=%v", task.AgentID, task.Status)
|
||||
}
|
||||
// Cannot reassign a terminal task.
|
||||
_ = c.Complete("t1", "done")
|
||||
if err := c.Reassign("t1", "a3"); err == nil {
|
||||
t.Fatal("reassigning a terminal task must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinatorColorStability(t *testing.T) {
|
||||
c := NewCoordinator()
|
||||
first := c.Color("a1")
|
||||
if first == "" {
|
||||
t.Fatal("Color should assign a non-empty color")
|
||||
}
|
||||
if again := c.Color("a1"); again != first {
|
||||
t.Fatalf("Color not stable: %q vs %q", first, again)
|
||||
}
|
||||
if other := c.Color("a2"); other == "" {
|
||||
t.Fatal("second agent should also get a color")
|
||||
}
|
||||
if c.Color("") != "" {
|
||||
t.Fatal("empty agent id should get no color")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinatorSummarize(t *testing.T) {
|
||||
c := NewCoordinator()
|
||||
_, _ = c.Register("p", "a", "team", "")
|
||||
_, _ = c.Register("r", "b", "team", "")
|
||||
_ = c.SetStatus("r", StatusRunning)
|
||||
_, _ = c.Register("d", "c", "team", "")
|
||||
_ = c.Complete("d", "ok")
|
||||
s := c.Summarize()
|
||||
if s.Total != 3 || s.Pending != 1 || s.Running != 1 || s.Done != 1 {
|
||||
t.Fatalf("Summarize = %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinatorConcurrentRegister(t *testing.T) {
|
||||
c := NewCoordinator()
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
id := "task-" + string(rune('a'+n%26)) + "-" + itoa(n)
|
||||
_, _ = c.Register(id, id, "team", "desc")
|
||||
_ = c.SetStatus(id, StatusRunning)
|
||||
_ = c.Complete(id, "done")
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
if got := len(c.List()); got != 50 {
|
||||
t.Fatalf("List len = %d, want 50", got)
|
||||
}
|
||||
}
|
||||
|
||||
// itoa avoids importing strconv just for the concurrency test id.
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b []byte
|
||||
for n > 0 {
|
||||
b = append([]byte{byte('0' + n%10)}, b...)
|
||||
n /= 10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Package swarm adds a multi-agent SWARM on top of zero's single sub-agent
|
||||
// mechanism (internal/specialist): an orchestrator can spawn and coordinate
|
||||
// MULTIPLE specialist members that run concurrently, communicate via per-agent
|
||||
// mailboxes, and hand work off. It composes internal/specialist (to launch each
|
||||
// member), internal/daemon (pooled workers when a daemon is running) and
|
||||
// internal/background. Everything is additive and opt-in — the existing single
|
||||
// "task" tool is unchanged; swarm tools are only active when invoked.
|
||||
//
|
||||
// Every member runs under the SAME sandbox + risk/autonomy policy as the
|
||||
// orchestrator: the swarm never grants a member more authority than its parent.
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ErrUnknownAgentType is returned when a definition lookup misses.
|
||||
var ErrUnknownAgentType = errors.New("swarm: unknown agent type")
|
||||
|
||||
// modelInherit is the sentinel meaning "use the orchestrator's model" — members
|
||||
// never silently pick a different (e.g. more capable) model.
|
||||
const modelInherit = "inherit"
|
||||
|
||||
// Definition is one entry in the agent roster: how a member of a given type
|
||||
// behaves (agent type, when to use it, model — "inherit" by default —,
|
||||
// permission mode, and a system-prompt builder).
|
||||
type Definition struct {
|
||||
AgentType string
|
||||
WhenToUse string
|
||||
Model string // "inherit" => use the orchestrator's model
|
||||
PermissionMode string
|
||||
// SystemPrompt returns the member's system prompt for the given task context.
|
||||
// It is a func so a definition can fold the task briefing in.
|
||||
SystemPrompt func(ctx PromptContext) string
|
||||
}
|
||||
|
||||
// PromptContext is the minimal context handed to a definition's SystemPrompt.
|
||||
type PromptContext struct {
|
||||
Team string
|
||||
Task string
|
||||
}
|
||||
|
||||
// Registry is the agent roster: definitions looked up by agent type. Built-ins
|
||||
// are seeded at construction; user-defined definitions extend or override them.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
defs map[string]Definition
|
||||
}
|
||||
|
||||
// NewRegistry builds a registry seeded with the built-in roster.
|
||||
func NewRegistry() *Registry {
|
||||
r := &Registry{defs: map[string]Definition{}}
|
||||
for _, def := range builtinDefinitions() {
|
||||
r.defs[def.AgentType] = def
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Register adds or overrides a definition (user-defined agents extend the
|
||||
// built-ins). An empty AgentType is rejected.
|
||||
func (r *Registry) Register(def Definition) error {
|
||||
agentType := strings.TrimSpace(def.AgentType)
|
||||
if agentType == "" {
|
||||
return errors.New("swarm: definition requires an agentType")
|
||||
}
|
||||
if def.Model == "" {
|
||||
def.Model = modelInherit
|
||||
}
|
||||
def.AgentType = agentType
|
||||
r.mu.Lock()
|
||||
r.defs[agentType] = def
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Lookup returns the definition for agentType, or ErrUnknownAgentType.
|
||||
func (r *Registry) Lookup(agentType string) (Definition, error) {
|
||||
r.mu.RLock()
|
||||
def, ok := r.defs[strings.TrimSpace(agentType)]
|
||||
r.mu.RUnlock()
|
||||
if !ok {
|
||||
return Definition{}, ErrUnknownAgentType
|
||||
}
|
||||
return def, nil
|
||||
}
|
||||
|
||||
// AgentTypes returns the registered agent types, sorted, for status/help.
|
||||
func (r *Registry) AgentTypes() []string {
|
||||
r.mu.RLock()
|
||||
types := make([]string, 0, len(r.defs))
|
||||
for t := range r.defs {
|
||||
types = append(types, t)
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
sort.Strings(types)
|
||||
return types
|
||||
}
|
||||
|
||||
// builtinDefinitions returns the seeded roster (teammate, subagent) — kept
|
||||
// minimal; user-defined agents extend it via Register.
|
||||
func builtinDefinitions() []Definition {
|
||||
return []Definition{
|
||||
{
|
||||
AgentType: "teammate",
|
||||
WhenToUse: "In-process teammate for parallel task execution; delegate work to run alongside the orchestrator.",
|
||||
Model: modelInherit,
|
||||
// Empty PermissionMode => inherit the orchestrator's mode (never widened).
|
||||
SystemPrompt: func(ctx PromptContext) string {
|
||||
return "You are a teammate agent collaborating with an orchestrator on team " +
|
||||
displayTeam(ctx.Team) + ". Complete your assigned task and report results.\n\nTask: " + ctx.Task
|
||||
},
|
||||
},
|
||||
{
|
||||
AgentType: "subagent",
|
||||
WhenToUse: "General-purpose subagent for an isolated, delegated task; starts with zero prior context.",
|
||||
Model: modelInherit,
|
||||
SystemPrompt: func(ctx PromptContext) string {
|
||||
return "You are a subagent spawned to complete a specific task. You start with zero context — " +
|
||||
"the briefing below is all you know.\n\nTask: " + ctx.Task
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func displayTeam(team string) string {
|
||||
if strings.TrimSpace(team) == "" {
|
||||
return "default"
|
||||
}
|
||||
return team
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegistryBuiltins(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
for _, want := range []string{"teammate", "subagent"} {
|
||||
def, err := r.Lookup(want)
|
||||
if err != nil {
|
||||
t.Fatalf("builtin %q missing: %v", want, err)
|
||||
}
|
||||
if def.Model != modelInherit {
|
||||
t.Fatalf("builtin %q model = %q, want inherit", want, def.Model)
|
||||
}
|
||||
if def.SystemPrompt == nil {
|
||||
t.Fatalf("builtin %q must have a SystemPrompt", want)
|
||||
}
|
||||
if got := def.SystemPrompt(PromptContext{Team: "t", Task: "do x"}); got == "" {
|
||||
t.Fatalf("builtin %q SystemPrompt returned empty", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryLookupUnknown(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
if _, err := r.Lookup("nope"); !errors.Is(err, ErrUnknownAgentType) {
|
||||
t.Fatalf("Lookup(nope) err = %v, want ErrUnknownAgentType", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryRegisterOverrideAndDefaultModel(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
if err := r.Register(Definition{AgentType: " custom "}); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
def, err := r.Lookup("custom")
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup(custom): %v", err)
|
||||
}
|
||||
if def.AgentType != "custom" {
|
||||
t.Fatalf("agentType not trimmed: %q", def.AgentType)
|
||||
}
|
||||
if def.Model != modelInherit {
|
||||
t.Fatalf("default model = %q, want inherit", def.Model)
|
||||
}
|
||||
// Override a builtin.
|
||||
if err := r.Register(Definition{AgentType: "teammate", Model: "fixed-model"}); err != nil {
|
||||
t.Fatalf("override Register: %v", err)
|
||||
}
|
||||
if got, _ := r.Lookup("teammate"); got.Model != "fixed-model" {
|
||||
t.Fatalf("override model = %q, want fixed-model", got.Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryRegisterRejectsEmpty(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
if err := r.Register(Definition{AgentType: " "}); err == nil {
|
||||
t.Fatal("Register with blank agentType must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryAgentTypesSorted(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
_ = r.Register(Definition{AgentType: "alpha"})
|
||||
types := r.AgentTypes()
|
||||
for i := 1; i < len(types); i++ {
|
||||
if types[i-1] > types[i] {
|
||||
t.Fatalf("AgentTypes not sorted: %v", types)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/specialist"
|
||||
)
|
||||
|
||||
// NewSpecialistLauncher adapts internal/specialist.Executor into a
|
||||
// MemberLauncher: each swarm member runs as a specialist sub-agent. This is the
|
||||
// production "direct" launch path (used when no daemon pool is wired). The
|
||||
// member inherits the orchestrator's model via spec.Model and runs in spec.Cwd,
|
||||
// so it stays under the same sandbox + policy as its parent — the swarm never
|
||||
// grants a member more authority.
|
||||
//
|
||||
// agentType maps to the specialist agent name; the specialist's own definition
|
||||
// for that name governs the member's system prompt and tools. RunInBackground is
|
||||
// deliberately left false: the swarm provides concurrency itself (one goroutine
|
||||
// per member), so each Run executes to completion inside its goroutine.
|
||||
//
|
||||
// spec.PermissionMode (resolved + clamped by buildSpec, never above the
|
||||
// orchestrator's) is propagated so the child only runs unsafe/high autonomy when
|
||||
// the orchestrator is itself unsafe; otherwise it runs non-unsafe. An empty mode
|
||||
// is substituted with the non-unsafe "auto" default so a swarm member can never
|
||||
// fall into the historical empty-means-high path — that is the Task tool's
|
||||
// behavior, not the swarm's.
|
||||
func NewSpecialistLauncher(executor specialist.Executor) MemberLauncher {
|
||||
return FuncLauncher{Run: func(ctx context.Context, spec MemberSpec) (MemberResult, error) {
|
||||
permissionMode := spec.PermissionMode
|
||||
if permissionMode == "" {
|
||||
permissionMode = permissionModeAuto
|
||||
}
|
||||
res, err := executor.Run(ctx, specialist.TaskParameters{
|
||||
Name: spec.AgentType,
|
||||
Prompt: spec.Task,
|
||||
Description: spec.AgentType + " · team " + spec.Team,
|
||||
}, specialist.TaskRunOptions{
|
||||
ParentModel: spec.Model,
|
||||
Cwd: spec.Cwd,
|
||||
PermissionMode: permissionMode,
|
||||
})
|
||||
if err != nil {
|
||||
return MemberResult{}, err
|
||||
}
|
||||
return MemberResult{Result: res.Result.Output, SessionID: res.SessionID}, nil
|
||||
}}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Spawn registers a task and launches a member of agentType to run it under the
|
||||
// given team, inheriting the orchestrator's policy. It returns the task id
|
||||
// immediately; the member runs concurrently (under the Swarm's base context, so
|
||||
// it outlives this call) and reports its result through the coordinator. If the
|
||||
// team is at its slot cap the member is queued and launches when a slot frees
|
||||
// (it stays pending in the coordinator until then).
|
||||
func (s *Swarm) Spawn(pol Policy, teamName, agentType, task, cwd string) (string, error) {
|
||||
def, err := s.registry.Lookup(agentType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
team := sanitizeName(teamName)
|
||||
id := s.nextID(agentType)
|
||||
if _, err := s.coord.Register(id, id, team, task); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.rememberCwd(id, cwd)
|
||||
spec := s.buildSpec(pol, id, id, team, def, task, cwd)
|
||||
s.dispatch(spec)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// dispatch admits a spec to its team (launching now or queuing for a slot).
|
||||
func (s *Swarm) dispatch(spec MemberSpec) {
|
||||
t := s.team(spec.Team)
|
||||
if t.admit(spec) {
|
||||
s.launch(t, spec)
|
||||
}
|
||||
// Otherwise the spec is queued; the coordinator task stays pending until a
|
||||
// slot frees and afterExit launches it.
|
||||
}
|
||||
|
||||
// launch starts a member for spec and supervises it. A synchronous launch
|
||||
// failure fails the task and frees the slot.
|
||||
func (s *Swarm) launch(t *Team, spec MemberSpec) {
|
||||
handle, err := s.launcher.Launch(s.baseCtx, spec)
|
||||
if err != nil {
|
||||
_ = s.coord.Fail(spec.TaskID, "launch: "+err.Error())
|
||||
s.afterExit(t)
|
||||
return
|
||||
}
|
||||
m := &Member{ID: spec.ID, AgentType: spec.AgentType, TaskID: spec.TaskID, handle: handle}
|
||||
t.addMember(m)
|
||||
_ = s.coord.SetStatus(spec.TaskID, StatusRunning)
|
||||
go s.watch(t, m, spec)
|
||||
}
|
||||
|
||||
// watch awaits a member, applies bounded relaunch on temporary failures, records
|
||||
// the terminal outcome, then frees the slot and drains the queue.
|
||||
//
|
||||
// The relaunch counter is per-member (m.restarts) and the same spec is reused on
|
||||
// retry. This is sound because a Member is bound 1:1 to its spec for its whole
|
||||
// life (Member.ID == MemberSpec.ID); a retry never reuses the struct for a
|
||||
// different spec.
|
||||
func (s *Swarm) watch(t *Team, m *Member, spec MemberSpec) {
|
||||
res, err := m.handle.Wait()
|
||||
if err != nil {
|
||||
if isRetryable(err) && m.restarts < maxMemberRestarts {
|
||||
if nh, relErr := s.launcher.Launch(s.baseCtx, spec); relErr == nil {
|
||||
m.restarts++
|
||||
m.handle = nh
|
||||
go s.watch(t, m, spec)
|
||||
return
|
||||
}
|
||||
// fall through to record the original error if relaunch fails
|
||||
}
|
||||
_ = s.coord.Fail(m.TaskID, memberError(err))
|
||||
} else {
|
||||
_ = s.coord.Complete(m.TaskID, res.Result)
|
||||
}
|
||||
t.removeMember(m.ID)
|
||||
s.afterExit(t)
|
||||
}
|
||||
|
||||
// afterExit releases the just-vacated slot and launches the next queued spec, if
|
||||
// any. Each exit drains at most one queued member; that member's own exit drains
|
||||
// the next, so the queue empties one-per-slot without unbounded recursion.
|
||||
func (s *Swarm) afterExit(t *Team) {
|
||||
next, ok := t.onExit()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.launch(t, next)
|
||||
}
|
||||
|
||||
// Handoff transfers a task to a fresh member of toAgentType, delivering a note to
|
||||
// the new member's inbox and marking the original task handed-off. It returns the
|
||||
// new task id. A handoff of an already-terminal task is rejected (fail closed).
|
||||
func (s *Swarm) Handoff(pol Policy, teamName, taskID, toAgentType, note string) (string, error) {
|
||||
task, ok := s.coord.Get(taskID)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%w: %s", ErrUnknownTask, taskID)
|
||||
}
|
||||
if task.Status.terminal() {
|
||||
return "", fmt.Errorf("swarm: task %s already %s; cannot hand off", taskID, task.Status)
|
||||
}
|
||||
def, err := s.registry.Lookup(toAgentType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
team := sanitizeName(teamName)
|
||||
newID := s.nextID(toAgentType)
|
||||
handoffTask := task.Description
|
||||
if note != "" {
|
||||
handoffTask += "\n\nHandoff note: " + note
|
||||
}
|
||||
if _, err := s.coord.Register(newID, newID, team, handoffTask); err != nil {
|
||||
return "", err
|
||||
}
|
||||
cwd := s.cwdFor(taskID)
|
||||
s.rememberCwd(newID, cwd)
|
||||
// Deliver the handoff note to the new member's inbox. A mailbox failure must
|
||||
// not silently strand the handoff, so it is surfaced to the caller.
|
||||
if note != "" {
|
||||
if mbErr := s.mailbox.Send(team, newID, Message{
|
||||
From: task.AgentID, Subject: "handoff", Body: note, Type: "handoff", Time: nowRFC3339(),
|
||||
}); mbErr != nil {
|
||||
return "", fmt.Errorf("swarm: deliver handoff note: %w", mbErr)
|
||||
}
|
||||
}
|
||||
// Retire the original task (it has been re-delegated).
|
||||
_ = s.coord.SetStatus(taskID, StatusHandedOff)
|
||||
spec := s.buildSpec(pol, newID, newID, team, def, handoffTask, cwd)
|
||||
s.dispatch(spec)
|
||||
return newID, nil
|
||||
}
|
||||
|
||||
// AdoptOrphans re-parents tasks in a team whose owning member is no longer live
|
||||
// (e.g. a crashed worker) onto fresh members of toAgentType, returning the
|
||||
// adopted task ids. Terminal tasks and tasks with a live owner are left alone.
|
||||
func (s *Swarm) AdoptOrphans(pol Policy, teamName, toAgentType string) ([]string, error) {
|
||||
def, err := s.registry.Lookup(toAgentType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
team := sanitizeName(teamName)
|
||||
t := s.team(team)
|
||||
live := t.liveAgents()
|
||||
var adopted []string
|
||||
for _, task := range s.coord.List() {
|
||||
if task.Team != team || task.Status.terminal() {
|
||||
continue
|
||||
}
|
||||
if _, ok := live[task.AgentID]; ok {
|
||||
continue // still has a live member
|
||||
}
|
||||
newAgent := s.nextID(toAgentType)
|
||||
if err := s.coord.Reassign(task.ID, newAgent); err != nil {
|
||||
continue // raced to terminal; skip
|
||||
}
|
||||
cwd := s.cwdFor(task.ID)
|
||||
s.rememberCwd(task.ID, cwd)
|
||||
spec := s.buildSpec(pol, newAgent, task.ID, team, def, task.Description, cwd)
|
||||
s.dispatch(spec)
|
||||
adopted = append(adopted, task.ID)
|
||||
}
|
||||
return adopted, nil
|
||||
}
|
||||
|
||||
// Collect returns snapshots of every task in a team, for swarm_collect/status.
|
||||
func (s *Swarm) Collect(teamName string) []Task {
|
||||
team := sanitizeName(teamName)
|
||||
var out []Task
|
||||
for _, task := range s.coord.List() {
|
||||
if task.Team == team {
|
||||
out = append(out, task)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// controllableLauncher records every launched spec and lets a test control each
|
||||
// member's outcome and (optionally) gate completion until a channel is closed.
|
||||
type controllableLauncher struct {
|
||||
mu sync.Mutex
|
||||
specs []MemberSpec
|
||||
attempts map[string]int
|
||||
gate chan struct{} // nil => members return immediately
|
||||
result func(spec MemberSpec, attempt int) (MemberResult, error)
|
||||
launchErr error
|
||||
}
|
||||
|
||||
func newLauncher(result func(MemberSpec, int) (MemberResult, error)) *controllableLauncher {
|
||||
return &controllableLauncher{attempts: map[string]int{}, result: result}
|
||||
}
|
||||
|
||||
func (l *controllableLauncher) Launch(ctx context.Context, spec MemberSpec) (MemberHandle, error) {
|
||||
l.mu.Lock()
|
||||
if l.launchErr != nil {
|
||||
err := l.launchErr
|
||||
l.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
l.specs = append(l.specs, spec)
|
||||
l.attempts[spec.ID]++
|
||||
attempt := l.attempts[spec.ID]
|
||||
gate := l.gate
|
||||
result := l.result
|
||||
l.mu.Unlock()
|
||||
|
||||
h := &funcHandle{id: spec.ID, done: make(chan struct{})}
|
||||
go func() {
|
||||
defer close(h.done)
|
||||
if gate != nil {
|
||||
select {
|
||||
case <-gate:
|
||||
case <-ctx.Done():
|
||||
h.err = ctx.Err()
|
||||
return
|
||||
}
|
||||
}
|
||||
if result != nil {
|
||||
h.res, h.err = result(spec, attempt)
|
||||
}
|
||||
}()
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func (l *controllableLauncher) recorded() []MemberSpec {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
out := make([]MemberSpec, len(l.specs))
|
||||
copy(out, l.specs)
|
||||
return out
|
||||
}
|
||||
|
||||
func (l *controllableLauncher) attemptCount(id string) int {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.attempts[id]
|
||||
}
|
||||
|
||||
func newSwarmFor(t *testing.T, l MemberLauncher) *Swarm {
|
||||
t.Helper()
|
||||
sw, err := New(Options{BaseDir: t.TempDir(), Launcher: l, MaxTeamSize: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
t.Cleanup(sw.Close)
|
||||
return sw
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, what string, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s", what)
|
||||
}
|
||||
|
||||
func okFor(spec MemberSpec, _ int) (MemberResult, error) {
|
||||
return MemberResult{Result: "ok:" + spec.Task, SessionID: "sess-" + spec.ID}, nil
|
||||
}
|
||||
|
||||
func TestSpawnCompletes(t *testing.T) {
|
||||
l := newLauncher(okFor)
|
||||
sw := newSwarmFor(t, l)
|
||||
id, err := sw.Spawn(Policy{Model: "m"}, "team", "teammate", "build widget", "/work")
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn: %v", err)
|
||||
}
|
||||
waitFor(t, "task done", func() bool {
|
||||
task, ok := sw.Coordinator().Get(id)
|
||||
return ok && task.Status == StatusDone
|
||||
})
|
||||
task, _ := sw.Coordinator().Get(id)
|
||||
if task.Result != "ok:build widget" {
|
||||
t.Fatalf("result = %q", task.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnInheritsPolicy(t *testing.T) {
|
||||
l := newLauncher(okFor)
|
||||
sw := newSwarmFor(t, l)
|
||||
_, err := sw.Spawn(Policy{Model: "orch-model", PermissionMode: permissionModeAuto}, "team", "teammate", "task", "/cwd")
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn: %v", err)
|
||||
}
|
||||
waitFor(t, "spec recorded", func() bool { return len(l.recorded()) == 1 })
|
||||
spec := l.recorded()[0]
|
||||
if spec.Model != "orch-model" {
|
||||
t.Fatalf("member model = %q, want inherited orch-model", spec.Model)
|
||||
}
|
||||
if spec.PermissionMode != permissionModeAuto {
|
||||
t.Fatalf("member permission mode = %q, want inherited auto", spec.PermissionMode)
|
||||
}
|
||||
if spec.Cwd != "/cwd" {
|
||||
t.Fatalf("member cwd = %q, want /cwd", spec.Cwd)
|
||||
}
|
||||
if spec.SystemPrompt == "" {
|
||||
t.Fatal("member should carry a resolved system prompt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrencyCapAndQueueDrains(t *testing.T) {
|
||||
gate := make(chan struct{})
|
||||
l := newLauncher(okFor)
|
||||
l.gate = gate
|
||||
sw := newSwarmFor(t, l) // MaxTeamSize 2
|
||||
pol := Policy{Model: "m"}
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := sw.Spawn(pol, "team", "teammate", "task", ""); err != nil {
|
||||
t.Fatalf("Spawn %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
team := sw.team("team")
|
||||
if team.Running() != 2 || team.QueueDepth() != 3 {
|
||||
t.Fatalf("cap not enforced: running=%d queue=%d, want 2/3", team.Running(), team.QueueDepth())
|
||||
}
|
||||
// Release everyone; the queue should drain one-per-slot until all are done.
|
||||
close(gate)
|
||||
waitFor(t, "all tasks done", func() bool { return sw.Coordinator().Summarize().Done == 5 })
|
||||
if team.Running() != 0 || team.QueueDepth() != 0 {
|
||||
t.Fatalf("after drain running=%d queue=%d, want 0/0", team.Running(), team.QueueDepth())
|
||||
}
|
||||
if got := len(l.recorded()); got != 5 {
|
||||
t.Fatalf("launched %d members, want 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryOnTemporaryError(t *testing.T) {
|
||||
l := newLauncher(func(spec MemberSpec, attempt int) (MemberResult, error) {
|
||||
if attempt < 3 {
|
||||
return MemberResult{}, ErrMemberTemporary
|
||||
}
|
||||
return MemberResult{Result: "recovered"}, nil
|
||||
})
|
||||
sw := newSwarmFor(t, l)
|
||||
id, _ := sw.Spawn(Policy{}, "team", "teammate", "task", "")
|
||||
waitFor(t, "task recovered", func() bool {
|
||||
task, ok := sw.Coordinator().Get(id)
|
||||
return ok && task.Status == StatusDone
|
||||
})
|
||||
if got := l.attemptCount(id); got != 3 {
|
||||
t.Fatalf("attempts = %d, want 3 (initial + 2 retries)", got)
|
||||
}
|
||||
task, _ := sw.Coordinator().Get(id)
|
||||
if task.Result != "recovered" {
|
||||
t.Fatalf("result = %q, want recovered", task.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryExhaustionFails(t *testing.T) {
|
||||
l := newLauncher(func(MemberSpec, int) (MemberResult, error) {
|
||||
return MemberResult{}, ErrMemberTemporary
|
||||
})
|
||||
sw := newSwarmFor(t, l)
|
||||
id, _ := sw.Spawn(Policy{}, "team", "teammate", "task", "")
|
||||
waitFor(t, "task failed", func() bool {
|
||||
task, ok := sw.Coordinator().Get(id)
|
||||
return ok && task.Status == StatusFailed
|
||||
})
|
||||
if got := l.attemptCount(id); got != maxMemberRestarts+1 {
|
||||
t.Fatalf("attempts = %d, want %d", got, maxMemberRestarts+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermanentErrorNoRetry(t *testing.T) {
|
||||
l := newLauncher(func(MemberSpec, int) (MemberResult, error) {
|
||||
return MemberResult{}, errPlain("hard failure")
|
||||
})
|
||||
sw := newSwarmFor(t, l)
|
||||
id, _ := sw.Spawn(Policy{}, "team", "teammate", "task", "")
|
||||
waitFor(t, "task failed", func() bool {
|
||||
task, ok := sw.Coordinator().Get(id)
|
||||
return ok && task.Status == StatusFailed
|
||||
})
|
||||
if got := l.attemptCount(id); got != 1 {
|
||||
t.Fatalf("attempts = %d, want 1 (no retry on permanent error)", got)
|
||||
}
|
||||
task, _ := sw.Coordinator().Get(id)
|
||||
if task.Err == "" {
|
||||
t.Fatal("failed task should record an error message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandoffDeliversNoteAndRetiresOriginal(t *testing.T) {
|
||||
gate := make(chan struct{})
|
||||
l := newLauncher(okFor)
|
||||
l.gate = gate // keep the original member running so it is non-terminal
|
||||
sw := newSwarmFor(t, l)
|
||||
pol := Policy{Model: "m"}
|
||||
origID, _ := sw.Spawn(pol, "team", "teammate", "original task", "/w")
|
||||
waitFor(t, "original running", func() bool {
|
||||
task, ok := sw.Coordinator().Get(origID)
|
||||
return ok && task.Status == StatusRunning
|
||||
})
|
||||
|
||||
newID, err := sw.Handoff(pol, "team", origID, "subagent", "please continue")
|
||||
if err != nil {
|
||||
t.Fatalf("Handoff: %v", err)
|
||||
}
|
||||
// Original retired.
|
||||
orig, _ := sw.Coordinator().Get(origID)
|
||||
if orig.Status != StatusHandedOff {
|
||||
t.Fatalf("original status = %v, want handed-off", orig.Status)
|
||||
}
|
||||
// Note delivered to the new member's inbox.
|
||||
msgs, err := sw.Mailbox().ReadAndConsume("team", newID)
|
||||
if err != nil {
|
||||
t.Fatalf("read new inbox: %v", err)
|
||||
}
|
||||
if len(msgs) != 1 || msgs[0].Body != "please continue" || msgs[0].Type != "handoff" {
|
||||
t.Fatalf("handoff note not delivered: %+v", msgs)
|
||||
}
|
||||
// The new member carries the handoff note in its task and preserves cwd.
|
||||
close(gate)
|
||||
waitFor(t, "spec for new member", func() bool {
|
||||
for _, s := range l.recorded() {
|
||||
if s.ID == newID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
for _, s := range l.recorded() {
|
||||
if s.ID == newID {
|
||||
if s.Cwd != "/w" {
|
||||
t.Fatalf("handoff lost cwd: %q", s.Cwd)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A handoff of an already-terminal task is rejected.
|
||||
waitFor(t, "new task done", func() bool {
|
||||
task, ok := sw.Coordinator().Get(newID)
|
||||
return ok && task.Status == StatusDone
|
||||
})
|
||||
if _, err := sw.Handoff(pol, "team", newID, "teammate", "again"); err == nil {
|
||||
t.Fatal("handoff of a terminal task must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdoptOrphans(t *testing.T) {
|
||||
l := newLauncher(okFor)
|
||||
sw := newSwarmFor(t, l)
|
||||
// Simulate a crashed member: a running task in the coordinator whose owning
|
||||
// agent has no live member in the team.
|
||||
if _, err := sw.Coordinator().Register("orphan-1", "ghost", "team", "stranded work"); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
_ = sw.Coordinator().SetStatus("orphan-1", StatusRunning)
|
||||
|
||||
adopted, err := sw.AdoptOrphans(Policy{Model: "m"}, "team", "subagent")
|
||||
if err != nil {
|
||||
t.Fatalf("AdoptOrphans: %v", err)
|
||||
}
|
||||
if len(adopted) != 1 || adopted[0] != "orphan-1" {
|
||||
t.Fatalf("adopted = %v, want [orphan-1]", adopted)
|
||||
}
|
||||
// The orphan is relaunched under a fresh agent and completes.
|
||||
waitFor(t, "orphan completed", func() bool {
|
||||
task, ok := sw.Coordinator().Get("orphan-1")
|
||||
return ok && task.Status == StatusDone
|
||||
})
|
||||
task, _ := sw.Coordinator().Get("orphan-1")
|
||||
if task.AgentID == "ghost" {
|
||||
t.Fatal("orphan should be reassigned to a new agent")
|
||||
}
|
||||
// A second adoption finds nothing (the task now has a live/owned outcome).
|
||||
again, _ := sw.AdoptOrphans(Policy{}, "team", "subagent")
|
||||
if len(again) != 0 {
|
||||
t.Fatalf("second adoption = %v, want none", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScopesToTeam(t *testing.T) {
|
||||
l := newLauncher(okFor)
|
||||
sw := newSwarmFor(t, l)
|
||||
a, _ := sw.Spawn(Policy{}, "alpha", "teammate", "ta", "")
|
||||
_, _ = sw.Spawn(Policy{}, "beta", "teammate", "tb", "")
|
||||
waitFor(t, "alpha done", func() bool {
|
||||
task, ok := sw.Coordinator().Get(a)
|
||||
return ok && task.Status == StatusDone
|
||||
})
|
||||
collected := sw.Collect("alpha")
|
||||
if len(collected) != 1 || collected[0].Team != "alpha" {
|
||||
t.Fatalf("Collect(alpha) = %+v, want one alpha task", collected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuncLauncherRecoversPanic(t *testing.T) {
|
||||
// A panic inside a member's Run must surface as that member's error, never
|
||||
// escape the goroutine and crash the orchestrator.
|
||||
l := FuncLauncher{Run: func(context.Context, MemberSpec) (MemberResult, error) {
|
||||
panic("boom in member")
|
||||
}}
|
||||
h, err := l.Launch(context.Background(), MemberSpec{ID: "m1"})
|
||||
if err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
_, err = h.Wait()
|
||||
if err == nil || !strings.Contains(err.Error(), "panicked") {
|
||||
t.Fatalf("panic must surface as a member error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnUnknownAgentType(t *testing.T) {
|
||||
sw := newSwarmFor(t, newLauncher(okFor))
|
||||
if _, err := sw.Spawn(Policy{}, "team", "does-not-exist", "task", ""); err == nil {
|
||||
t.Fatal("Spawn with unknown agent type must error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build !windows
|
||||
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
// isLockContended reports whether an O_EXCL lock-create error means the lock is
|
||||
// currently held (so the caller should retry/wait) rather than a hard failure.
|
||||
// On POSIX an existing lock surfaces only as os.ErrExist.
|
||||
func isLockContended(err error) bool {
|
||||
return errors.Is(err, os.ErrExist)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//go:build windows
|
||||
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Windows error codes that an O_EXCL create (or a racing delete) can surface
|
||||
// when another writer holds the lock file open, instead of ERROR_FILE_EXISTS.
|
||||
// Treating them as "contended" makes the lock retry/wait rather than fail the
|
||||
// whole send/read spuriously under contention.
|
||||
const (
|
||||
errorAccessDenied = syscall.Errno(5) // ERROR_ACCESS_DENIED
|
||||
errorSharingViolation = syscall.Errno(32) // ERROR_SHARING_VIOLATION
|
||||
)
|
||||
|
||||
// isLockContended reports whether an O_EXCL lock-create error means the lock is
|
||||
// currently held (so the caller should retry/wait) rather than a hard failure.
|
||||
// On Windows a held lock can appear as ERROR_FILE_EXISTS (mapped to os.ErrExist)
|
||||
// but also as ERROR_ACCESS_DENIED / ERROR_SHARING_VIOLATION when the file is
|
||||
// concurrently open or pending deletion.
|
||||
func isLockContended(err error) bool {
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
return true
|
||||
}
|
||||
return errors.Is(err, errorAccessDenied) || errors.Is(err, errorSharingViolation)
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Mailbox is a per-agent, per-team message inbox persisted as a JSON array on
|
||||
// disk at <baseDir>/<team>/inboxes/<agent>.json, written atomically under a
|
||||
// lock file. It is hardened for untrusted input:
|
||||
//
|
||||
// - inbox files and their parent dirs are owner-only (0600/0700);
|
||||
// - every write takes an exclusive lock file (bounded retry, stale-break);
|
||||
// - message bodies and whole inboxes are size-capped (fail closed on oversize);
|
||||
// - team/agent names are sanitized so a name can never escape the base dir;
|
||||
// - malformed inbox JSON is rejected rather than silently reset (fail closed).
|
||||
type Mailbox struct {
|
||||
// BaseDir is the root under which <team>/inboxes/<agent>.json live. It must
|
||||
// be set (NewMailbox enforces this).
|
||||
BaseDir string
|
||||
// MaxMessageBytes caps a single serialized message. 0 => defaultMaxMessageBytes.
|
||||
MaxMessageBytes int
|
||||
// MaxMessages caps the number of messages retained in one inbox. 0 =>
|
||||
// defaultMaxMessages. Sends past the cap fail closed.
|
||||
MaxMessages int
|
||||
// LockTimeout bounds how long Send/MarkRead wait for the inbox lock.
|
||||
LockTimeout time.Duration
|
||||
}
|
||||
|
||||
const (
|
||||
defaultMaxMessageBytes = 64 * 1024 // 64 KiB per message
|
||||
defaultMaxMessages = 1000 // per inbox
|
||||
defaultLockTimeout = 5 * time.Second
|
||||
lockStaleAfter = 30 * time.Second
|
||||
inboxFileMode = 0o600
|
||||
inboxDirMode = 0o700
|
||||
)
|
||||
|
||||
// ErrMailboxFull is returned when an inbox is at MaxMessages.
|
||||
var ErrMailboxFull = errors.New("swarm: mailbox is full")
|
||||
|
||||
// ErrMessageTooLarge is returned when a single message exceeds MaxMessageBytes.
|
||||
var ErrMessageTooLarge = errors.New("swarm: message exceeds size cap")
|
||||
|
||||
// Message is one inbox entry. Time is RFC3339 and set on Send if empty.
|
||||
type Message struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Body string `json:"body"`
|
||||
Type string `json:"type"`
|
||||
Read bool `json:"read"`
|
||||
Time string `json:"time,omitempty"`
|
||||
}
|
||||
|
||||
// NewMailbox validates baseDir and returns a Mailbox with defaults applied.
|
||||
func NewMailbox(baseDir string) (*Mailbox, error) {
|
||||
if strings.TrimSpace(baseDir) == "" {
|
||||
return nil, errors.New("swarm: mailbox base dir is required")
|
||||
}
|
||||
abs, err := filepath.Abs(baseDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("swarm: resolve mailbox base dir: %w", err)
|
||||
}
|
||||
return &Mailbox{
|
||||
BaseDir: abs,
|
||||
MaxMessageBytes: defaultMaxMessageBytes,
|
||||
MaxMessages: defaultMaxMessages,
|
||||
LockTimeout: defaultLockTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var nameUnsafe = regexp.MustCompile(`[^a-zA-Z0-9\-_]`)
|
||||
|
||||
// sanitizeName replaces any char outside [A-Za-z0-9-_] with '-', defaulting
|
||||
// empty input to "default" and an all-unsafe result to "unknown". This
|
||||
// guarantees the name is a single safe path segment (no '/', '\\', '.', '..').
|
||||
func sanitizeName(name string) string {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
trimmed = "default"
|
||||
}
|
||||
cleaned := nameUnsafe.ReplaceAllString(trimmed, "-")
|
||||
if cleaned == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func (m *Mailbox) maxMessageBytes() int {
|
||||
if m.MaxMessageBytes > 0 {
|
||||
return m.MaxMessageBytes
|
||||
}
|
||||
return defaultMaxMessageBytes
|
||||
}
|
||||
|
||||
func (m *Mailbox) maxMessages() int {
|
||||
if m.MaxMessages > 0 {
|
||||
return m.MaxMessages
|
||||
}
|
||||
return defaultMaxMessages
|
||||
}
|
||||
|
||||
func (m *Mailbox) lockTimeout() time.Duration {
|
||||
if m.LockTimeout > 0 {
|
||||
return m.LockTimeout
|
||||
}
|
||||
return defaultLockTimeout
|
||||
}
|
||||
|
||||
// inboxPath returns the sanitized, base-confined inbox path for (team, agent)
|
||||
// and verifies it cannot escape BaseDir lexically (defense in depth on top of
|
||||
// sanitize). Symlink-aware confinement happens in ensureInboxDir.
|
||||
func (m *Mailbox) inboxPath(team, agent string) (string, error) {
|
||||
dir := filepath.Join(m.BaseDir, sanitizeName(team), "inboxes")
|
||||
path := filepath.Join(dir, sanitizeName(agent)+".json")
|
||||
if within, err := pathWithin(m.BaseDir, path); err != nil || !within {
|
||||
return "", fmt.Errorf("swarm: inbox path escapes base dir (team=%q agent=%q)", team, agent)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// pathWithin reports whether target is base or lives under it (lexically).
|
||||
func pathWithin(base, target string) (bool, error) {
|
||||
rel, err := filepath.Rel(base, target)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ensureInboxDir creates the inbox's parent directories owner-only, then
|
||||
// verifies — after resolving symlinks — that the real directory stays within the
|
||||
// real base dir. This defends against a symlink planted under BaseDir (e.g. a
|
||||
// pre-existing "inboxes" -> /etc) that the lexical inboxPath check cannot catch.
|
||||
// Only after confinement is confirmed are the directories tightened to 0700, so
|
||||
// a chmod can never land outside the base via a symlink.
|
||||
func (m *Mailbox) ensureInboxDir(inboxPath string) error {
|
||||
dir := filepath.Dir(inboxPath) // <base>/<team>/inboxes
|
||||
if err := os.MkdirAll(dir, inboxDirMode); err != nil {
|
||||
return fmt.Errorf("swarm: create inbox dir: %w", err)
|
||||
}
|
||||
realBase, err := filepath.EvalSymlinks(m.BaseDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("swarm: resolve base dir: %w", err)
|
||||
}
|
||||
realDir, err := filepath.EvalSymlinks(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("swarm: resolve inbox dir: %w", err)
|
||||
}
|
||||
if within, err := pathWithin(realBase, realDir); err != nil || !within {
|
||||
return fmt.Errorf("swarm: inbox dir escapes base dir via symlink")
|
||||
}
|
||||
// MkdirAll leaves an existing dir's permissions untouched; tighten the team
|
||||
// and inboxes dirs to owner-only so mailbox contents are never group/world
|
||||
// readable even if a dir pre-existed with broad perms.
|
||||
_ = os.Chmod(dir, inboxDirMode)
|
||||
_ = os.Chmod(filepath.Dir(dir), inboxDirMode) // <base>/<team>
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send appends msg to the recipient's inbox under the team, taking the inbox
|
||||
// lock. It fails closed on oversize messages or a full inbox.
|
||||
//
|
||||
// Each send rewrites the whole inbox (read all → append one → write all), so it
|
||||
// is O(N) in the inbox size. That is intentional given the small bounds
|
||||
// (MaxMessages × MaxMessageBytes); if those caps are raised substantially,
|
||||
// switch to an append-only log with periodic compaction.
|
||||
func (m *Mailbox) Send(team, recipient string, msg Message) error {
|
||||
path, err := m.inboxPath(team, recipient)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if msg.Type == "" {
|
||||
msg.Type = "message"
|
||||
}
|
||||
if msg.To == "" {
|
||||
msg.To = sanitizeName(recipient)
|
||||
}
|
||||
if msg.Time == "" {
|
||||
msg.Time = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
encoded, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("swarm: encode message: %w", err)
|
||||
}
|
||||
if len(encoded) > m.maxMessageBytes() {
|
||||
return fmt.Errorf("%w: %d > %d bytes", ErrMessageTooLarge, len(encoded), m.maxMessageBytes())
|
||||
}
|
||||
if err := m.ensureInboxDir(path); err != nil {
|
||||
return err
|
||||
}
|
||||
release, err := acquireLock(path+".lock", m.lockTimeout())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
messages, err := m.readLocked(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(messages) >= m.maxMessages() {
|
||||
return fmt.Errorf("%w: %d messages", ErrMailboxFull, len(messages))
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
return atomicWriteJSON(path, messages)
|
||||
}
|
||||
|
||||
// ReadAndConsume reads the recipient's inbox and marks every previously-unread
|
||||
// message read under one lock, returning the messages with their read flags as
|
||||
// they were BEFORE this read (so the caller can tell which were new). The inbox
|
||||
// is empty (no error) if it does not yet exist; malformed inbox JSON is reported
|
||||
// as an error (fail closed) and nothing is written.
|
||||
func (m *Mailbox) ReadAndConsume(team, recipient string) ([]Message, error) {
|
||||
path, err := m.inboxPath(team, recipient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The lock file lives beside the inbox; create the dir so it can be taken
|
||||
// even on first read of a not-yet-existing inbox.
|
||||
if err := m.ensureInboxDir(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
release, err := acquireLock(path+".lock", m.lockTimeout())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
messages, err := m.readLocked(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return messages, nil
|
||||
}
|
||||
// Snapshot pre-consume state to return, then flip unread -> read on disk.
|
||||
snapshot := make([]Message, len(messages))
|
||||
copy(snapshot, messages)
|
||||
changed := false
|
||||
for i := range messages {
|
||||
if !messages[i].Read {
|
||||
messages[i].Read = true
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
if err := atomicWriteJSON(path, messages); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
// readLocked reads and decodes an inbox file. Callers that mutate must hold the
|
||||
// lock; pure readers may call it directly (a torn read surfaces as a decode
|
||||
// error and is retried by the caller, never silently treated as empty).
|
||||
func (m *Mailbox) readLocked(path string) ([]Message, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("swarm: read inbox: %w", err)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// Reject an oversized inbox file outright rather than decoding untrusted,
|
||||
// unbounded JSON into memory. The bound is the cap on total message bytes
|
||||
// (maxMessages × maxMessageBytes) doubled to allow for pretty-printed JSON
|
||||
// (indentation, field names, brackets) overhead versus the compact bytes the
|
||||
// per-message cap measures.
|
||||
if maxFile := m.maxMessages() * m.maxMessageBytes() * 2; len(raw) > maxFile {
|
||||
return nil, fmt.Errorf("swarm: inbox file too large (%d bytes)", len(raw))
|
||||
}
|
||||
var messages []Message
|
||||
if err := json.Unmarshal(raw, &messages); err != nil {
|
||||
return nil, fmt.Errorf("swarm: malformed inbox %s: %w", filepath.Base(path), err)
|
||||
}
|
||||
for i := range messages {
|
||||
if messages[i].Type == "" {
|
||||
messages[i].Type = "message"
|
||||
}
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// atomicWriteJSON writes data as pretty JSON to a sibling temp file (0600) then
|
||||
// renames it over path, so a reader never observes a partial write.
|
||||
func atomicWriteJSON(path string, data any) error {
|
||||
encoded, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("swarm: encode inbox: %w", err)
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := os.CreateTemp(dir, ".inbox-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("swarm: create temp inbox: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName) // no-op after a successful rename
|
||||
if err := tmp.Chmod(inboxFileMode); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("swarm: chmod temp inbox: %w", err)
|
||||
}
|
||||
if _, err := tmp.Write(encoded); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("swarm: write temp inbox: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("swarm: close temp inbox: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("swarm: commit inbox: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lockSeq makes each lock token unique within the process even when two
|
||||
// goroutines acquire in the same nanosecond.
|
||||
var lockSeq atomic.Uint64
|
||||
|
||||
// acquireLock takes an exclusive lock by creating lockPath with O_EXCL. It
|
||||
// retries with a short backoff until timeout, breaking a lock whose file is
|
||||
// older than lockStaleAfter (so a crashed holder cannot deadlock the swarm).
|
||||
//
|
||||
// Each holder writes a unique token into the lock file and release is
|
||||
// OWNERSHIP-AWARE: it removes the lock only if it still holds our token. After a
|
||||
// stale-break another writer may legitimately own the lock; unconditionally
|
||||
// removing it would let a third writer in and create split-brain writers that
|
||||
// can corrupt or drop mailbox updates.
|
||||
func acquireLock(lockPath string, timeout time.Duration) (func(), error) {
|
||||
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), time.Now().UnixNano(), lockSeq.Add(1))
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, inboxFileMode)
|
||||
if err == nil {
|
||||
// Record our ownership token; the exclusive create already holds the
|
||||
// lock, so a write error only weakens diagnostics, not safety.
|
||||
_, _ = f.WriteString(token)
|
||||
f.Close()
|
||||
var released bool
|
||||
return func() {
|
||||
if released {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
|
||||
os.Remove(lockPath)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
if !isLockContended(err) {
|
||||
return nil, fmt.Errorf("swarm: acquire lock: %w", err)
|
||||
}
|
||||
// Lock held: break it if stale, otherwise wait. The break is conditional
|
||||
// on the file's content being unchanged since we observed it, so we do not
|
||||
// delete a lock another writer rotated in between.
|
||||
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > lockStaleAfter {
|
||||
stale, _ := os.ReadFile(lockPath)
|
||||
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == string(stale) {
|
||||
os.Remove(lockPath)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, fmt.Errorf("swarm: timed out acquiring lock %s", filepath.Base(lockPath))
|
||||
}
|
||||
// Short retry so a freed lock is re-acquired promptly under heavy
|
||||
// contention (Windows file ops are slow; a coarse sleep starves waiters).
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user