diff --git a/README.md b/README.md index 59d3279..95a81bd 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,22 @@ Agent tool call -> local dashboard ``` +## Connect to your Kontext workspace (self-serve) + +Stream Claude Code activity from this Mac into your team's hosted Kontext +dashboard — no MDM required. Generate an install token on your workspace's +Deployments page, then: + +```bash +kontext setup +``` + +Setup validates the token, stores it in your login keychain, installs the +Claude Code hooks, and starts a background agent. Sessions appear in your +dashboard seconds after your next Claude Code activity. Re-running `kontext +setup` rotates the token; `kontext setup --uninstall` removes everything it +installed. macOS only. + ## Managed sessions Use managed sessions when you want hosted identity, short-lived provider credentials, and shared traces on top of the local safety path: diff --git a/cmd/kontext/main.go b/cmd/kontext/main.go index 25bda8d..43c16d4 100644 --- a/cmd/kontext/main.go +++ b/cmd/kontext/main.go @@ -44,6 +44,7 @@ func main() { } root.AddCommand(startCmd()) + root.AddCommand(setupCmd()) root.AddCommand(loginCmd()) root.AddCommand(logoutCmd()) root.AddCommand(hookCmd()) diff --git a/cmd/kontext/setup.go b/cmd/kontext/setup.go new file mode 100644 index 0000000..25643b4 --- /dev/null +++ b/cmd/kontext/setup.go @@ -0,0 +1,44 @@ +package main + +import ( + "github.com/spf13/cobra" + + "github.com/kontext-security/kontext-cli/internal/setup" +) + +func setupCmd() *cobra.Command { + var token, cloudURL string + var uninstall bool + cmd := &cobra.Command{ + Use: "setup", + Short: "Connect this Mac to your Kontext organization", + Long: `Connect this Mac to your Kontext organization (self-serve managed observe). + +Setup asks for the install token created in the Kontext dashboard, stores it +in your login keychain, installs the Claude Code hooks, and starts a +background agent that streams Claude Code activity to your workspace. + +Re-running setup is safe: it rotates the stored token and restarts the agent. +Use --uninstall to remove everything setup installed (the kontext binary +itself stays — it is managed by Homebrew).`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + opts := setup.Options{ + Token: token, + CloudURL: cloudURL, + Version: version, + Stdout: cmd.OutOrStdout(), + Stderr: cmd.ErrOrStderr(), + } + if uninstall { + return setup.Uninstall(cmd.Context(), opts) + } + return setup.Run(cmd.Context(), opts) + }, + } + cmd.Flags().StringVar(&token, "token", "", "install token from the Kontext dashboard (prompted interactively when omitted)") + cmd.Flags().StringVar(&cloudURL, "cloud-url", setup.DefaultCloudURL, "Kontext cloud URL") + cmd.Flags().BoolVar(&uninstall, "uninstall", false, "remove the self-serve managed install from this Mac") + _ = cmd.Flags().MarkHidden("cloud-url") + return cmd +} diff --git a/cmd/kontext/setup_test.go b/cmd/kontext/setup_test.go new file mode 100644 index 0000000..c7f661e --- /dev/null +++ b/cmd/kontext/setup_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "testing" + + "github.com/kontext-security/kontext-cli/internal/setup" +) + +func TestSetupCmdFlags(t *testing.T) { + cmd := setupCmd() + + token := cmd.Flags().Lookup("token") + if token == nil || token.DefValue != "" { + t.Fatalf("--token flag = %v", token) + } + + cloudURL := cmd.Flags().Lookup("cloud-url") + if cloudURL == nil { + t.Fatal("setup command missing --cloud-url flag") + } + if cloudURL.DefValue != setup.DefaultCloudURL { + t.Fatalf("--cloud-url default = %q, want %q", cloudURL.DefValue, setup.DefaultCloudURL) + } + if !cloudURL.Hidden { + t.Fatal("--cloud-url must be hidden (staging/dev override only)") + } + + uninstall := cmd.Flags().Lookup("uninstall") + if uninstall == nil || uninstall.DefValue != "false" { + t.Fatalf("--uninstall flag = %v", uninstall) + } +} + +func TestSetupCmdRegistered(t *testing.T) { + // setup must be a visible top-level command — it is the self-serve + // onboarding entrypoint printed in the dashboard. + cmd := setupCmd() + if cmd.Hidden { + t.Fatal("setup command must not be hidden") + } + if cmd.Use != "setup" { + t.Fatalf("Use = %q", cmd.Use) + } +} diff --git a/go.mod b/go.mod index 6551991..e027eff 100644 --- a/go.mod +++ b/go.mod @@ -6,10 +6,12 @@ require ( connectrpc.com/connect v1.19.2 github.com/cli/browser v1.3.0 github.com/google/uuid v1.6.0 + github.com/mattn/go-isatty v0.0.20 github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.8 golang.org/x/oauth2 v0.36.0 - golang.org/x/sys v0.42.0 + golang.org/x/sys v0.46.0 + golang.org/x/term v0.44.0 google.golang.org/protobuf v1.36.11 modernc.org/sqlite v1.50.1 ) @@ -19,7 +21,6 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/spf13/pflag v1.0.9 // indirect diff --git a/go.sum b/go.sum index f085107..915eb01 100644 --- a/go.sum +++ b/go.sum @@ -48,8 +48,10 @@ golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7 golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/internal/managedconfig/config.go b/internal/managedconfig/config.go index 2f6722c..c95a321 100644 --- a/internal/managedconfig/config.go +++ b/internal/managedconfig/config.go @@ -268,6 +268,14 @@ func normalizeAndValidate(cfg Config) (Config, error) { return cfg, nil } +// ValidateCloudURL enforces the managed.json cloud_url shape (https with +// host only; loopback http behind EnvAllowHTTP). Exported so `kontext setup` +// can fail a bad --cloud-url before any state is written, with exactly the +// rules the daemon's parser will apply later. +func ValidateCloudURL(value string) error { + return validateCloudURL(value) +} + func validateCloudURL(value string) error { if value == "" { return errors.New("cloud_url is required") diff --git a/internal/managedobserve/daemon.go b/internal/managedobserve/daemon.go index 8c757d3..8a10e87 100644 --- a/internal/managedobserve/daemon.go +++ b/internal/managedobserve/daemon.go @@ -39,6 +39,17 @@ func RunDaemon(ctx context.Context, opts DaemonOptions) error { } return fmt.Errorf("load managed config: %w", err) } + if expected := strings.TrimSpace(os.Getenv(EnvExpectedConfigScope)); expected != "" && + expected != string(loadedConfig.Scope) { + // An MDM config appeared after this agent was installed (system scope + // outranks user scope). Park instead of serving the wrong config — + // exiting would just make launchd KeepAlive restart-loop us. + fmt.Fprintf(os.Stderr, + "managed config scope is %q but this agent was installed for %q — parking; run `kontext setup --uninstall` to remove this agent\n", + loadedConfig.Scope, expected) + <-ctx.Done() + return nil + } installationState, err := installation.EnsureFile(installationPathForScope(loadedConfig.Scope)) if err != nil { return fmt.Errorf("ensure installation identity: %w", err) diff --git a/internal/managedobserve/defaults.go b/internal/managedobserve/defaults.go index 6e07a91..681e838 100644 --- a/internal/managedobserve/defaults.go +++ b/internal/managedobserve/defaults.go @@ -13,6 +13,12 @@ import ( const ( DefaultLaunchdLabel = "security.kontext.managed-observe" + // EnvExpectedConfigScope marks which managed-config scope a daemon was + // installed for. The self-serve LaunchAgent sets it to "user"; the daemon + // parks instead of running when the resolved scope differs, so an MDM + // config appearing later is never served by the leftover self-serve agent. + EnvExpectedConfigScope = "KONTEXT_EXPECTED_CONFIG_SCOPE" + envSocketPath = "KONTEXT_MANAGED_OBSERVE_SOCKET" envDBPath = "KONTEXT_MANAGED_OBSERVE_DB" envIdleTimeout = "KONTEXT_MANAGED_OBSERVE_IDLE_TIMEOUT" diff --git a/internal/managedobserve/scope_test.go b/internal/managedobserve/scope_test.go index cae6cd7..61aef68 100644 --- a/internal/managedobserve/scope_test.go +++ b/internal/managedobserve/scope_test.go @@ -1,9 +1,11 @@ package managedobserve import ( + "context" "os" "path/filepath" "testing" + "time" "github.com/kontext-security/kontext-cli/internal/installation" "github.com/kontext-security/kontext-cli/internal/managedconfig" @@ -56,6 +58,38 @@ func TestInstallationPathForScopeEnvOverrideWins(t *testing.T) { } } +func TestRunDaemonParksOnScopeMismatch(t *testing.T) { + // A self-serve agent (expected scope "user") must park — not serve, not + // crash-loop — when config resolution lands on another scope (an MDM + // config appeared after setup). + dir := t.TempDir() + config := filepath.Join(dir, "managed.json") + if err := os.WriteFile(config, []byte(`{ + "version": "managed-install-v1", + "organization_id": "org_x", + "cloud_url": "https://api.kontext.dev", + "mode": "observe", + "agent": "claude", + "credentials": {"install_token_ref": "env:KONTEXT_INSTALL_TOKEN"} +}`), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(managedconfig.EnvPath, config) // resolves as scope "env" + t.Setenv(EnvExpectedConfigScope, "user") + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + start := time.Now() + if err := RunDaemon(ctx, DaemonOptions{}); err != nil { + t.Fatalf("RunDaemon() = %v, want clean park until ctx done", err) + } + // Parked means it waited for the context, not returned immediately. + if time.Since(start) < 250*time.Millisecond { + t.Fatal("RunDaemon returned before context cancellation — did not park") + } +} + func TestDeploymentVersionWithFallback(t *testing.T) { dir := t.TempDir() marker := filepath.Join(dir, "deployment-version") diff --git a/internal/setup/keychain.go b/internal/setup/keychain.go new file mode 100644 index 0000000..36f0299 --- /dev/null +++ b/internal/setup/keychain.go @@ -0,0 +1,73 @@ +package setup + +import ( + "context" + "fmt" + "strings" +) + +// writeKeychainToken stores the raw token as a login-keychain generic +// password, symmetric with the daemon's read path +// (`security find-generic-password -s -w`). The write happens in +// two phases: +// +// 1. delete every existing item with our service name — find-generic-password +// matches by service only, so a stale item (different account, previous +// org) could otherwise win the read; +// 2. add the new item, feeding the command through `security -i` STDIN so +// the token never appears in the process argument list. +// +// go-keyring is deliberately NOT used: its darwin Set() stores +// "go-keyring-base64:" which the daemon's raw read would return +// verbatim. +func writeKeychainToken(ctx context.Context, token string) error { + if err := deleteKeychainTokens(ctx); err != nil { + return err + } + command := fmt.Sprintf( + "add-generic-password -U -s %s -a %s -w %s\n", + KeychainItemName, keychainAccount, securityQuote(token), + ) + if out, err := execCommand(ctx, command, "security", "-i"); err != nil { + return fmt.Errorf("store install token in keychain: %w (%s)", err, strings.TrimSpace(out)) + } + return nil +} + +// maxKeychainDeletions is a runaway guard, not an expected count — the loop +// normally ends on the first "not found" (0 or 1 items). +const maxKeychainDeletions = 32 + +// deleteKeychainTokens removes ALL items with our service name (delete only +// removes one match per invocation). Only the explicit "not found" outcome +// ends the loop as success — a locked keychain or denied access must surface, +// otherwise uninstall would report the token removed while it still exists +// (and a rotation could proceed on top of a stale item). +func deleteKeychainTokens(ctx context.Context) error { + for attempt := 0; attempt < maxKeychainDeletions; attempt++ { + out, err := execCommand(ctx, "", "security", "delete-generic-password", "-s", KeychainItemName) + if err == nil { + continue // one item deleted; loop for more + } + if isSecurityNotFound(out) { + return nil // no (more) matching items + } + return fmt.Errorf("delete keychain item %s: %w (%s)", KeychainItemName, err, strings.TrimSpace(out)) + } + return fmt.Errorf("more than %d keychain items named %s; clean them up in Keychain Access and retry", maxKeychainDeletions, KeychainItemName) +} + +func isSecurityNotFound(output string) bool { + normalized := strings.ToLower(output) + return strings.Contains(normalized, "could not be found") || + strings.Contains(normalized, "not found") || + strings.Contains(normalized, "specified item could not be found") +} + +// securityQuote wraps a value for the `security -i` command parser, which +// accepts double-quoted strings with backslash escapes. +func securityQuote(value string) string { + value = strings.ReplaceAll(value, `\`, `\\`) + value = strings.ReplaceAll(value, `"`, `\"`) + return `"` + value + `"` +} diff --git a/internal/setup/launchagent.go b/internal/setup/launchagent.go new file mode 100644 index 0000000..4a172a3 --- /dev/null +++ b/internal/setup/launchagent.go @@ -0,0 +1,160 @@ +package setup + +import ( + "context" + "encoding/xml" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/kontext-security/kontext-cli/internal/managedobserve" +) + +// LaunchAgentLabel matches the enterprise LaunchAgent so the hook-side +// kickstart (managedobserve.Lifecycle) works identically for both install +// kinds. The refusal gate in Run keeps the two from coexisting on one Mac. +const LaunchAgentLabel = managedobserve.DefaultLaunchdLabel + +func launchAgentPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Library", "LaunchAgents", LaunchAgentLabel+".plist"), nil +} + +func logFilePath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Library", "Logs", "Kontext", "managed-observe.log"), nil +} + +// renderLaunchAgentPlist produces the user LaunchAgent. KeepAlive + a 30s +// throttle keeps the pipeline always-on (matching the enterprise agent) +// without thrashing if the config is removed out from under the daemon; +// RunAtLoad covers login, and the hook-side kickstart covers everything else. +func renderLaunchAgentPlist(binary, logPath string) string { + return ` + + + + Label + ` + xmlEscape(LaunchAgentLabel) + ` + ProgramArguments + + ` + xmlEscape(binary) + ` + managed-observe-daemon + + EnvironmentVariables + + KONTEXT_EXPECTED_CONFIG_SCOPE + user + + RunAtLoad + + KeepAlive + + ThrottleInterval + 30 + ProcessType + Background + StandardOutPath + ` + xmlEscape(logPath) + ` + StandardErrorPath + ` + xmlEscape(logPath) + ` + + +` +} + +func xmlEscape(value string) string { + var builder strings.Builder + _ = xml.EscapeText(&builder, []byte(value)) + return builder.String() +} + +// installLaunchAgent writes the plist and (re)starts the agent in the user's +// GUI launchd domain — no sudo anywhere. Bootout failure is expected on first +// install; bootstrap failure usually means no GUI session (SSH). +func installLaunchAgent(ctx context.Context, binary string) (plistPath, logPath string, err error) { + plistPath, err = launchAgentPath() + if err != nil { + return "", "", err + } + logPath, err = logFilePath() + if err != nil { + return "", "", err + } + if err := os.MkdirAll(filepath.Dir(plistPath), 0o755); err != nil { + return "", "", err + } + if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { + return "", "", err + } + if err := os.WriteFile(plistPath, []byte(renderLaunchAgentPlist(binary, logPath)), 0o644); err != nil { + return "", "", err + } + + domainTarget := "gui/" + strconv.Itoa(os.Getuid()) + serviceTarget := domainTarget + "/" + LaunchAgentLabel + + // Not loaded on first install is fine. A failed bootout while the service + // is still loaded is not fine: bootstrap can then leave launchd running the + // old plist definition while setup reports success. + if out, err := execCommand(ctx, "", "launchctl", "bootout", serviceTarget); err != nil { + if _, printErr := execCommand(ctx, "", "launchctl", "print", serviceTarget); printErr == nil { + return "", "", fmt.Errorf("launchctl bootout failed before reload: %w (%s)", err, strings.TrimSpace(out)) + } + } + + if out, err := execCommand(ctx, "", "launchctl", "bootstrap", domainTarget, plistPath); err != nil { + detail := strings.TrimSpace(out) + if strings.Contains(detail, "Input/output error") { + return "", "", fmt.Errorf("launchctl bootstrap failed (%s) — this usually means no GUI login session; run `kontext setup` from a logged-in desktop session, not SSH", detail) + } + return "", "", fmt.Errorf("launchctl bootstrap failed: %w (%s)", err, detail) + } + // -k restarts a running agent: a re-run with a rotated token must not + // leave the old process flushing with the old credential. + if out, err := execCommand(ctx, "", "launchctl", "kickstart", "-k", serviceTarget); err != nil { + return "", "", fmt.Errorf("launchctl kickstart failed: %w (%s)", err, strings.TrimSpace(out)) + } + return plistPath, logPath, nil +} + +// removeLaunchAgent reverses installLaunchAgent; both steps tolerate +// already-removed state. Bootout targets OUR plist by path, not the shared +// label: if an MDM install's agent holds the same label, a label-target +// bootout could unload the wrong service (or "succeed" while our daemon +// keeps streaming with the token still in memory). +func removeLaunchAgent(ctx context.Context) (string, error) { + plistPath, err := launchAgentPath() + if err != nil { + return "", err + } + domainTarget := "gui/" + strconv.Itoa(os.Getuid()) + serviceTarget := domainTarget + "/" + LaunchAgentLabel + plistExists := true + if _, err := os.Lstat(plistPath); errors.Is(err, os.ErrNotExist) { + plistExists = false + } else if err != nil { + return "", err + } + if out, err := execCommand(ctx, "", "launchctl", "bootout", domainTarget, plistPath); err != nil { + if plistExists { + return "", fmt.Errorf("launchctl bootout failed: %w (%s)", err, strings.TrimSpace(out)) + } + if _, printErr := execCommand(ctx, "", "launchctl", "print", serviceTarget); printErr == nil { + return "", fmt.Errorf("launchctl bootout failed and %s is still loaded: %w (%s)", LaunchAgentLabel, err, strings.TrimSpace(out)) + } + } + if err := os.Remove(plistPath); err != nil && !os.IsNotExist(err) { + return "", err + } + return plistPath, nil +} diff --git a/internal/setup/launchagent_test.go b/internal/setup/launchagent_test.go new file mode 100644 index 0000000..d3cfd65 --- /dev/null +++ b/internal/setup/launchagent_test.go @@ -0,0 +1,54 @@ +package setup + +import ( + "strings" + "testing" +) + +func TestRenderLaunchAgentPlistGolden(t *testing.T) { + got := renderLaunchAgentPlist("/opt/homebrew/bin/kontext", "/Users/x/Library/Logs/Kontext/managed-observe.log") + want := ` + + + + Label + security.kontext.managed-observe + ProgramArguments + + /opt/homebrew/bin/kontext + managed-observe-daemon + + EnvironmentVariables + + KONTEXT_EXPECTED_CONFIG_SCOPE + user + + RunAtLoad + + KeepAlive + + ThrottleInterval + 30 + ProcessType + Background + StandardOutPath + /Users/x/Library/Logs/Kontext/managed-observe.log + StandardErrorPath + /Users/x/Library/Logs/Kontext/managed-observe.log + + +` + if got != want { + t.Fatalf("plist mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +func TestRenderLaunchAgentPlistEscapesXML(t *testing.T) { + got := renderLaunchAgentPlist(`/Users/a&b/bin/kontext `, "/tmp/log") + if !strings.Contains(got, "/Users/a&b/bin/kontext <v2>") { + t.Fatalf("binary path not XML-escaped:\n%s", got) + } + if strings.Contains(got, "a&b") { + t.Fatal("raw ampersand leaked into plist") + } +} diff --git a/internal/setup/setup.go b/internal/setup/setup.go new file mode 100644 index 0000000..bb6b918 --- /dev/null +++ b/internal/setup/setup.go @@ -0,0 +1,411 @@ +// Package setup implements `kontext setup`: connecting a single Mac to a +// Kontext organization without MDM. It produces the same managed-observe +// pipeline as an enterprise package install — managed config, installation +// identity, Claude Code hooks, LaunchAgent running the daemon — but at user +// scope (~/Library, ~/.claude) with the install token in the login keychain. +package setup + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "golang.org/x/term" + + "github.com/kontext-security/kontext-cli/internal/claudemanaged" + "github.com/kontext-security/kontext-cli/internal/installation" + "github.com/kontext-security/kontext-cli/internal/managedconfig" + "github.com/kontext-security/kontext-cli/internal/managedobserve" +) + +const ( + DefaultCloudURL = "https://api.kontext.security" + + // KeychainItemName is the generic-password service name. It MUST stay in + // lockstep with the managed.json token ref below: the daemon reads the + // token with `security find-generic-password -s -w`. + KeychainItemName = "kontext-install-token" + keychainAccount = "kontext" + + pingPath = "/api/v1/authorization-ledger/ping" + + settingsBackupLabel = "kontext-setup" +) + +// Test seams (repo convention, cf. update.go's brewUpgradeFn). All external +// process and terminal interactions go through these so tests never touch +// launchctl/security/scutil or a real TTY. +var ( + execCommand = func(ctx context.Context, stdin string, name string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) + if stdin != "" { + cmd.Stdin = strings.NewReader(stdin) + } + out, err := cmd.CombinedOutput() + return string(out), err + } + readPassword = func(fd int) ([]byte, error) { + return term.ReadPassword(fd) + } + isTerminal = func(fd int) bool { + return term.IsTerminal(fd) + } + executablePath = os.Executable + resolveToken = managedconfig.ResolveInstallToken + dialSocket = func(path string, timeout time.Duration) error { + conn, err := net.DialTimeout("unix", path, timeout) + if err != nil { + return err + } + return conn.Close() + } + systemConfigPath = managedconfig.DefaultPath + goos = runtime.GOOS +) + +type Options struct { + Token string + CloudURL string + Version string + Stdout io.Writer + Stderr io.Writer + // HTTPClient overrides the ping client (tests). Nil uses a 10s-timeout + // default. + HTTPClient *http.Client +} + +type pingResponse struct { + OrganizationID string `json:"organization_id"` + // JSON null (the legacy env-fallback org) decodes to "". + OrganizationName string `json:"organization_name"` +} + +// Run connects this Mac to the org owning the install token. Steps are +// ordered so every irreversible action happens after the token is proven +// valid, and re-running is always safe (token rotation restarts the agent). +func Run(ctx context.Context, opts Options) error { + if goos != "darwin" { + return errors.New("kontext setup is currently macOS-only") + } + if err := refuseManagedEnvironments(); err != nil { + return err + } + + cloudURL := strings.TrimSpace(opts.CloudURL) + if cloudURL == "" { + cloudURL = DefaultCloudURL + } + // Same rules the daemon's parser applies, so a bad --cloud-url fails + // before any state is written. + if err := managedconfig.ValidateCloudURL(cloudURL); err != nil { + return err + } + + token, err := acquireToken(opts) + if err != nil { + return err + } + + ping, err := validateToken(ctx, opts.HTTPClient, cloudURL, token) + if err != nil { + return err + } + orgLabel := ping.OrganizationID + if ping.OrganizationName != "" { + orgLabel = fmt.Sprintf("%s (%s)", ping.OrganizationName, ping.OrganizationID) + } + fmt.Fprintf(opts.Stdout, "✓ Token accepted — organization %s\n", orgLabel) + + if err := writeKeychainToken(ctx, token); err != nil { + return err + } + // Read back through the daemon's actual code path so a write/read + // asymmetry fails HERE, not silently at the first flush under launchd. + stored, err := resolveToken(ctx, managedconfig.TokenRef{Source: "keychain", Name: KeychainItemName}) + if err != nil { + return fmt.Errorf("keychain read-back failed: %w", err) + } + if stored != token { + return errors.New("keychain read-back returned a different token; remove stale 'kontext-install-token' keychain items and retry") + } + fmt.Fprintf(opts.Stdout, "✓ Install token stored in your login keychain (%s)\n", KeychainItemName) + + configPath, err := writeUserManagedConfig(cloudURL, ping.OrganizationID, deviceLabel(ctx)) + if err != nil { + return err + } + fmt.Fprintf(opts.Stdout, "✓ Managed config written to %s\n", configPath) + + identityPath := installation.UserPath() + if identityPath == "" { + return errors.New("cannot resolve your home directory") + } + identity, err := installation.EnsureFile(identityPath) + if err != nil { + return fmt.Errorf("ensure installation identity: %w", err) + } + fmt.Fprintf(opts.Stdout, "✓ Installation identity %s\n", identity.InstallationID) + + binary, binaryNote := stableBinaryPath() + if binaryNote != "" { + fmt.Fprintln(opts.Stderr, binaryNote) + } + + warnings, err := installUserHooks(binary) + if err != nil { + return err + } + for _, warning := range warnings { + fmt.Fprintf(opts.Stderr, "warning: %s\n", warning) + } + fmt.Fprintln(opts.Stdout, "✓ Claude Code hooks installed in ~/.claude/settings.json") + + plistPath, logPath, err := installLaunchAgent(ctx, binary) + if err != nil { + return err + } + fmt.Fprintf(opts.Stdout, "✓ Background agent installed (%s)\n", plistPath) + + if err := probeDaemon(); err != nil { + fmt.Fprintf(opts.Stderr, "warning: the background agent has not come up yet (%v); check `tail -f %s`\n", err, logPath) + } else { + fmt.Fprintln(opts.Stdout, "✓ Background agent is running") + } + + fmt.Fprintf(opts.Stdout, "\nDone. Start a Claude Code session — activity appears in your dashboard within seconds.\n") + return nil +} + +// refuseManagedEnvironments keeps self-serve setup away from machines that +// are (or claim to be) organization-managed: a system config under /Library +// always outranks anything setup could write, so proceeding would only +// produce artifacts the daemon ignores. +func refuseManagedEnvironments() error { + // ANY env override means config resolution is explicitly env-driven — + // even one pointing at the user path. Setup must not write state whose + // activation depends on an environment variable it doesn't control. + if strings.TrimSpace(os.Getenv(managedconfig.EnvPath)) != "" { + return fmt.Errorf("%s is set; unset it before running setup", managedconfig.EnvPath) + } + if _, err := os.Lstat(systemConfigPath); err == nil { + return errors.New("this Mac already has an organization-managed Kontext install (deployed by your IT admin via MDM); self-serve setup is not needed and would be ignored") + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("cannot determine whether this Mac is organization-managed: %w", err) + } + return nil +} + +// acquireToken never mutates the input: a token containing whitespace fails +// loudly instead of being silently trimmed into something the user didn't +// paste — the stored credential must be byte-identical to the dashboard's. +func acquireToken(opts Options) (string, error) { + if opts.Token != "" { + return opts.Token, validateTokenShape(opts.Token) + } + fd := int(os.Stdin.Fd()) + if !isTerminal(fd) { + return "", errors.New("no install token: pass --token in non-interactive environments") + } + fmt.Fprint(opts.Stderr, "Paste your install token (from the Kontext dashboard, shown once at creation): ") + raw, err := readPassword(fd) + fmt.Fprintln(opts.Stderr) + if err != nil { + return "", fmt.Errorf("read token: %w", err) + } + token := string(raw) + if token == "" { + return "", errors.New("no install token entered") + } + return token, validateTokenShape(token) +} + +// validateTokenShape rejects whitespace and control characters — same rule +// the enterprise install script enforces. Besides catching mangled paste +// input early, it guarantees the token can never smuggle a second line into +// the `security -i` command stream. +func validateTokenShape(token string) error { + for _, r := range token { + if r <= ' ' || r == 0x7f { + return errors.New("install token must not contain whitespace or control characters") + } + } + return nil +} + +func validateToken(ctx context.Context, client *http.Client, cloudURL, token string) (pingResponse, error) { + if client == nil { + client = &http.Client{Timeout: 10 * time.Second} + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(cloudURL, "/")+pingPath, nil) + if err != nil { + return pingResponse{}, err + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := client.Do(req) + if err != nil { + return pingResponse{}, fmt.Errorf("cannot reach %s: %w", cloudURL, err) + } + defer resp.Body.Close() + + switch { + case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden: + return pingResponse{}, errors.New("install token was rejected — it may be revoked or mistyped; create a new one in the dashboard (Deployments page)") + case resp.StatusCode < 200 || resp.StatusCode >= 300: + return pingResponse{}, fmt.Errorf("token validation failed: %s returned HTTP %d", cloudURL, resp.StatusCode) + } + + var ping pingResponse + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&ping); err != nil { + return pingResponse{}, fmt.Errorf("parse token validation response: %w", err) + } + if strings.TrimSpace(ping.OrganizationID) == "" { + return pingResponse{}, errors.New("server did not return an organization id for this token") + } + return ping, nil +} + +func deviceLabel(ctx context.Context) string { + if out, err := execCommand(ctx, "", "scutil", "--get", "ComputerName"); err == nil { + if label := strings.TrimSpace(out); label != "" { + return label + } + } + host, err := os.Hostname() + if err != nil { + return "" + } + return host +} + +func writeUserManagedConfig(cloudURL, organizationID, label string) (string, error) { + path := managedconfig.UserPath() + if path == "" { + return "", errors.New("cannot resolve your home directory") + } + + cfg := managedconfig.Config{ + Version: managedconfig.Version, + OrganizationID: organizationID, + CloudURL: cloudURL, + Mode: managedconfig.Mode, + Agent: managedconfig.Agent, + Credentials: managedconfig.Credentials{ + InstallTokenRef: managedconfig.TokenRef{Source: "keychain", Name: KeychainItemName}, + }, + Device: managedconfig.Device{Label: label}, + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return "", err + } + data = append(data, '\n') + // Self-check through the daemon's parser: setup must never write a config + // the daemon will refuse to load. + if _, err := managedconfig.Parse(data); err != nil { + return "", fmt.Errorf("generated managed config is invalid: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", err + } + temp, err := os.CreateTemp(filepath.Dir(path), ".managed-*.tmp") + if err != nil { + return "", err + } + tempPath := temp.Name() + defer os.Remove(tempPath) + if err := temp.Chmod(0o600); err != nil { + temp.Close() + return "", err + } + if _, err := temp.Write(data); err != nil { + temp.Close() + return "", err + } + if err := temp.Sync(); err != nil { + temp.Close() + return "", err + } + if err := temp.Close(); err != nil { + return "", err + } + if err := os.Rename(tempPath, path); err != nil { + return "", err + } + return path, nil +} + +// stableBinaryPath picks the path baked into hooks and the LaunchAgent. The +// brew prefix symlink (/opt/homebrew/bin, /usr/local/bin) survives `brew +// upgrade`; a Cellar path dies with the next version, so prefer a stable +// symlink that resolves to the same binary. +func stableBinaryPath() (string, string) { + exe, err := executablePath() + if err != nil || exe == "" { + return claudemanaged.DefaultKontextBinary, "" + } + if !strings.Contains(exe, "/Cellar/") { + return exe, "" + } + real, err := filepath.EvalSymlinks(exe) + if err != nil { + real = exe + } + for _, candidate := range []string{"/opt/homebrew/bin/kontext", "/usr/local/bin/kontext"} { + resolved, err := filepath.EvalSymlinks(candidate) + if err != nil { + continue + } + if resolved == real { + return candidate, "" + } + } + return exe, "note: using a Homebrew Cellar path for hooks; re-run `kontext setup` after `brew upgrade kontext`" +} + +func installUserHooks(binary string) ([]string, error) { + path, err := claudemanaged.UserSettingsPath() + if err != nil { + return nil, err + } + settings, err := claudemanaged.ReadUserSettings(path) + if err != nil { + return nil, err + } + warnings, err := claudemanaged.MergeManagedHooks(settings, binary) + if err != nil { + return nil, err + } + if err := claudemanaged.BackupUserSettings(path, settingsBackupLabel); err != nil { + return nil, err + } + if err := claudemanaged.WriteUserSettings(path, settings); err != nil { + return nil, err + } + return warnings, nil +} + +func probeDaemon() error { + socket := managedobserve.DefaultSocketPath() + var lastErr error + for attempt := 0; attempt < 6; attempt++ { + if attempt > 0 { + time.Sleep(500 * time.Millisecond) + } + if lastErr = dialSocket(socket, 500*time.Millisecond); lastErr == nil { + return nil + } + } + return lastErr +} diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go new file mode 100644 index 0000000..55442ab --- /dev/null +++ b/internal/setup/setup_test.go @@ -0,0 +1,583 @@ +package setup + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kontext-security/kontext-cli/internal/claudemanaged" + "github.com/kontext-security/kontext-cli/internal/installation" + "github.com/kontext-security/kontext-cli/internal/managedconfig" +) + +type execCall struct { + stdin string + name string + args []string +} + +// harness stubs every seam so Run/Uninstall never touch launchctl, security, +// scutil, a TTY, or the real /Library — and records what they WOULD have run. +type harness struct { + t *testing.T + home string + calls []execCall + keychain map[string]string // service -> token, emulating add/delete/find + out bytes.Buffer + errOut bytes.Buffer +} + +func newHarness(t *testing.T) *harness { + t.Helper() + h := &harness{t: t, home: t.TempDir(), keychain: map[string]string{}} + t.Setenv("HOME", h.home) + t.Setenv(managedconfig.EnvPath, "") + t.Setenv(installation.EnvPath, "") + + overrideVar(t, &goos, "darwin") + overrideVar(t, &systemConfigPath, filepath.Join(h.home, "no-system", "managed.json")) + overrideVar(t, &executablePath, func() (string, error) { return "/opt/homebrew/bin/kontext", nil }) + overrideVar(t, &dialSocket, func(string, time.Duration) error { return nil }) + overrideVar(t, &isTerminal, func(int) bool { return false }) + overrideVar(t, &readPassword, func(int) ([]byte, error) { return nil, errors.New("no tty in tests") }) + overrideVar(t, &resolveToken, func(_ context.Context, ref managedconfig.TokenRef) (string, error) { + token, ok := h.keychain[ref.Name] + if !ok { + return "", errors.New("keychain item not found") + } + return token, nil + }) + overrideVar(t, &execCommand, func(_ context.Context, stdin, name string, args ...string) (string, error) { + h.calls = append(h.calls, execCall{stdin: stdin, name: name, args: args}) + switch name { + case "scutil": + return "Test MacBook\n", nil + case "security": + if len(args) > 0 && args[0] == "delete-generic-password" { + if _, ok := h.keychain[KeychainItemName]; ok { + delete(h.keychain, KeychainItemName) + return "", nil + } + return "security: The specified item could not be found in the keychain.", errors.New("exit status 44") + } + // `security -i` add-generic-password via stdin. + if len(args) > 0 && args[0] == "-i" { + token := parseAddGenericPassword(h.t, stdin) + h.keychain[KeychainItemName] = token + return "", nil + } + return "", nil + case "launchctl": + return "", nil + default: + h.t.Fatalf("unexpected command: %s %v", name, args) + return "", nil + } + }) + return h +} + +func overrideVar[T any](t *testing.T, target *T, value T) { + t.Helper() + previous := *target + *target = value + t.Cleanup(func() { *target = previous }) +} + +func parseAddGenericPassword(t *testing.T, stdin string) string { + t.Helper() + // add-generic-password -U -s -a -w "" + start := strings.Index(stdin, `-w "`) + if start < 0 { + t.Fatalf("unexpected security stdin: %q", stdin) + } + rest := stdin[start+len(`-w "`):] + end := strings.LastIndex(rest, `"`) + if end < 0 { + t.Fatalf("unterminated token quote: %q", stdin) + } + return rest[:end] +} + +func (h *harness) options(token string, server *httptest.Server) Options { + return Options{ + Token: token, + CloudURL: server.URL, + Version: "0.0.0-test", + Stdout: &h.out, + Stderr: &h.errOut, + HTTPClient: server.Client(), + } +} + +func pingServer(t *testing.T, expectToken string) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/authorization-ledger/ping" { + http.NotFound(w, r) + return + } + if r.Header.Get("Authorization") != "Bearer "+expectToken { + w.WriteHeader(http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "organization_id": "org_test", + "organization_name": "Acme", + }) + })) + t.Cleanup(server.Close) + return server +} + +// The httptest server is plain http on a loopback address; the managed.json +// self-check (managedconfig.Parse) only accepts that with the loopback +// escape hatch enabled. +func allowLoopback(t *testing.T) { + t.Setenv(managedconfig.EnvAllowHTTP, "1") +} + +func TestRunFullFlow(t *testing.T) { + h := newHarness(t) + allowLoopback(t) + server := pingServer(t, "tok-123") + + if err := Run(context.Background(), h.options("tok-123", server)); err != nil { + t.Fatalf("Run() error = %v\nstdout:\n%s\nstderr:\n%s", err, h.out.String(), h.errOut.String()) + } + + // Keychain holds the raw token. + if h.keychain[KeychainItemName] != "tok-123" { + t.Fatalf("keychain = %q", h.keychain[KeychainItemName]) + } + + // managed.json at the user path parses through the daemon's loader. + // (Scope resolution itself is covered by managedconfig's own tests — the + // host machine may have a real /Library config that Load() would pick.) + loaded, err := managedconfig.LoadFile(managedconfig.UserPath()) + if err != nil { + t.Fatalf("LoadFile(user path) after setup: %v", err) + } + if loaded.Config.OrganizationID != "org_test" { + t.Fatalf("org = %q", loaded.Config.OrganizationID) + } + if loaded.Config.Credentials.InstallTokenRef.String() != "keychain:"+KeychainItemName { + t.Fatalf("token ref = %q", loaded.Config.Credentials.InstallTokenRef) + } + if loaded.Config.Device.Label != "Test MacBook" { + t.Fatalf("device label = %q", loaded.Config.Device.Label) + } + + // Installation identity created at the user path. + if _, err := installation.LoadFile(installation.UserPath()); err != nil { + t.Fatalf("installation identity: %v", err) + } + + // Hooks merged into ~/.claude/settings.json with the stable binary path. + settingsPath := filepath.Join(h.home, ".claude", "settings.json") + settings, err := claudemanaged.ReadUserSettings(settingsPath) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(map[string]any{"hooks": settings["hooks"]}) + if err := claudemanaged.Validate(raw, "/opt/homebrew/bin/kontext"); err != nil { + t.Fatalf("hooks invalid after setup: %v", err) + } + + // LaunchAgent plist written and lifecycle ordered bootout -> bootstrap -> + // kickstart in the user's GUI domain. + plist := filepath.Join(h.home, "Library", "LaunchAgents", LaunchAgentLabel+".plist") + if _, err := os.Stat(plist); err != nil { + t.Fatalf("plist missing: %v", err) + } + var launchctl [][]string + for _, call := range h.calls { + if call.name == "launchctl" { + launchctl = append(launchctl, call.args) + } + } + if len(launchctl) != 3 || launchctl[0][0] != "bootout" || launchctl[1][0] != "bootstrap" || launchctl[2][0] != "kickstart" { + t.Fatalf("launchctl order = %v", launchctl) + } + + // The raw token never travels in argv — only via `security -i` stdin. + for _, call := range h.calls { + for _, arg := range call.args { + if strings.Contains(arg, "tok-123") { + t.Fatalf("token leaked into argv: %s %v", call.name, call.args) + } + } + } +} + +func TestRunIsIdempotent(t *testing.T) { + h := newHarness(t) + allowLoopback(t) + server := pingServer(t, "tok-123") + + if err := Run(context.Background(), h.options("tok-123", server)); err != nil { + t.Fatal(err) + } + identityBefore, err := installation.LoadFile(installation.UserPath()) + if err != nil { + t.Fatal(err) + } + + if err := Run(context.Background(), h.options("tok-123", server)); err != nil { + t.Fatalf("second Run() error = %v", err) + } + + // Identity survives; hooks not duplicated. + identityAfter, err := installation.LoadFile(installation.UserPath()) + if err != nil { + t.Fatal(err) + } + if identityBefore.InstallationID != identityAfter.InstallationID { + t.Fatal("installation identity changed across re-runs") + } + settings, err := claudemanaged.ReadUserSettings(filepath.Join(h.home, ".claude", "settings.json")) + if err != nil { + t.Fatal(err) + } + groups := settings["hooks"].(map[string]any)["PreToolUse"].([]any) + if len(groups) != 1 { + t.Fatalf("PreToolUse groups after re-run = %d, want 1", len(groups)) + } +} + +func TestRunRejectsRevokedToken(t *testing.T) { + h := newHarness(t) + allowLoopback(t) + server := pingServer(t, "valid-token") + + err := Run(context.Background(), h.options("revoked-token", server)) + if err == nil || !strings.Contains(err.Error(), "rejected") { + t.Fatalf("Run() error = %v, want rejection", err) + } + // Nothing was written before validation failed. + if len(h.keychain) != 0 { + t.Fatal("keychain written despite rejected token") + } + if _, err := os.Stat(managedconfig.UserPath()); !os.IsNotExist(err) { + t.Fatal("managed.json written despite rejected token") + } +} + +func TestRunRefusesMDMManagedMac(t *testing.T) { + h := newHarness(t) + system := filepath.Join(h.home, "system-managed.json") + if err := os.WriteFile(system, []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + overrideVar(t, &systemConfigPath, system) + server := pingServer(t, "tok") + + err := Run(context.Background(), h.options("tok", server)) + if err == nil || !strings.Contains(err.Error(), "organization-managed") { + t.Fatalf("Run() error = %v, want MDM refusal", err) + } +} + +func TestRunRefusesEnvOverride(t *testing.T) { + h := newHarness(t) + t.Setenv(managedconfig.EnvPath, "/somewhere/else.json") + server := pingServer(t, "tok") + + err := Run(context.Background(), h.options("tok", server)) + if err == nil || !strings.Contains(err.Error(), managedconfig.EnvPath) { + t.Fatalf("Run() error = %v, want env refusal", err) + } +} + +func TestRunRefusesEnvOverrideEvenAtUserPath(t *testing.T) { + // Any env override means config resolution is env-driven — including one + // that happens to equal the user path. Setup must refuse them all. + h := newHarness(t) + t.Setenv(managedconfig.EnvPath, managedconfig.UserPath()) + server := pingServer(t, "tok") + + err := Run(context.Background(), h.options("tok", server)) + if err == nil || !strings.Contains(err.Error(), managedconfig.EnvPath) { + t.Fatalf("Run() error = %v, want env refusal", err) + } +} + +func TestRunRequiresTokenWithoutTTY(t *testing.T) { + h := newHarness(t) + allowLoopback(t) + server := pingServer(t, "tok") + + err := Run(context.Background(), h.options("", server)) + if err == nil || !strings.Contains(err.Error(), "--token") { + t.Fatalf("Run() error = %v, want --token guidance", err) + } +} + +func TestRunNonDarwin(t *testing.T) { + h := newHarness(t) + overrideVar(t, &goos, "linux") + server := pingServer(t, "tok") + + err := Run(context.Background(), h.options("tok", server)) + if err == nil || !strings.Contains(err.Error(), "macOS-only") { + t.Fatalf("Run() error = %v, want macOS-only", err) + } +} + +func TestUninstallReversesSetupKeepingIdentity(t *testing.T) { + h := newHarness(t) + allowLoopback(t) + server := pingServer(t, "tok-123") + if err := Run(context.Background(), h.options("tok-123", server)); err != nil { + t.Fatal(err) + } + + // A foreign hook installed after setup must survive uninstall. + settingsPath := filepath.Join(h.home, ".claude", "settings.json") + settings, err := claudemanaged.ReadUserSettings(settingsPath) + if err != nil { + t.Fatal(err) + } + hooks := settings["hooks"].(map[string]any) + hooks["PreToolUse"] = append(hooks["PreToolUse"].([]any), map[string]any{ + "matcher": "Edit", + "hooks": []any{map[string]any{"type": "command", "command": "lint-check"}}, + }) + if err := claudemanaged.WriteUserSettings(settingsPath, settings); err != nil { + t.Fatal(err) + } + + if err := Uninstall(context.Background(), h.options("", pingServer(t, "unused"))); err != nil { + t.Fatalf("Uninstall() error = %v", err) + } + + if len(h.keychain) != 0 { + t.Fatal("keychain item not removed") + } + if _, err := os.Stat(managedconfig.UserPath()); !os.IsNotExist(err) { + t.Fatal("managed.json not removed") + } + plist := filepath.Join(h.home, "Library", "LaunchAgents", LaunchAgentLabel+".plist") + if _, err := os.Stat(plist); !os.IsNotExist(err) { + t.Fatal("plist not removed") + } + // Identity kept for endpoint continuity. + if _, err := installation.LoadFile(installation.UserPath()); err != nil { + t.Fatalf("installation identity removed: %v", err) + } + // Our hooks gone, the foreign one intact. + settings, err = claudemanaged.ReadUserSettings(settingsPath) + if err != nil { + t.Fatal(err) + } + pre := settings["hooks"].(map[string]any)["PreToolUse"].([]any) + if len(pre) != 1 || pre[0].(map[string]any)["matcher"] != "Edit" { + t.Fatalf("foreign hook lost or ours kept: %v", pre) + } + + // Idempotent: a second uninstall is clean. + if err := Uninstall(context.Background(), h.options("", pingServer(t, "unused"))); err != nil { + t.Fatalf("second Uninstall() error = %v", err) + } +} + +func TestRunRejectsTokenWithControlCharacters(t *testing.T) { + // A token must never be able to smuggle a second line into the + // `security -i` command stream. + h := newHarness(t) + allowLoopback(t) + server := pingServer(t, "x") + + // Leading/trailing whitespace must FAIL, never be silently trimmed — + // the stored credential must be byte-identical to what was pasted. + for _, token := range []string{"a\nb", "a b", "a\tb", "a\rb", " abc", "abc\n", "abc "} { + err := Run(context.Background(), h.options(token, server)) + if err == nil || !strings.Contains(err.Error(), "whitespace or control") { + t.Fatalf("Run(token=%q) error = %v, want shape rejection", token, err) + } + } + if len(h.keychain) != 0 { + t.Fatal("keychain written despite malformed token") + } +} + +func TestInstallLaunchAgentRejectsLoadedStaleJob(t *testing.T) { + h := newHarness(t) + overrideVar(t, &execCommand, func(_ context.Context, stdin, name string, args ...string) (string, error) { + h.calls = append(h.calls, execCall{stdin: stdin, name: name, args: args}) + if name == "launchctl" && args[0] == "bootout" { + return "Boot-out failed", errors.New("exit status 5") + } + // `launchctl print` succeeding means the stale service is still loaded. + return "", nil + }) + + _, _, err := installLaunchAgent(context.Background(), "/opt/homebrew/bin/kontext") + if err == nil || !strings.Contains(err.Error(), "launchctl bootout failed") { + t.Fatalf("installLaunchAgent() error = %v, want stale loaded job failure", err) + } +} + +func TestInstallLaunchAgentSurfacesBootstrapFailure(t *testing.T) { + h := newHarness(t) + overrideVar(t, &execCommand, func(_ context.Context, stdin, name string, args ...string) (string, error) { + h.calls = append(h.calls, execCall{stdin: stdin, name: name, args: args}) + if name == "launchctl" && args[0] == "bootstrap" { + return "Bootstrap failed: 17: File exists", errors.New("exit status 17") + } + return "", nil + }) + + _, _, err := installLaunchAgent(context.Background(), "/opt/homebrew/bin/kontext") + if err == nil || !strings.Contains(err.Error(), "launchctl bootstrap failed") { + t.Fatalf("installLaunchAgent() error = %v, want bootstrap failure", err) + } +} + +func TestUninstallWithoutSettingsFileDoesNotCreateOne(t *testing.T) { + // A removal must never CREATE config: uninstall on a machine with no + // ~/.claude/settings.json leaves none behind. + h := newHarness(t) + + if err := Uninstall(context.Background(), h.options("", pingServer(t, "unused"))); err != nil { + t.Fatalf("Uninstall() error = %v", err) + } + settingsPath := filepath.Join(h.home, ".claude", "settings.json") + if _, err := os.Lstat(settingsPath); !os.IsNotExist(err) { + t.Fatalf("uninstall created %s", settingsPath) + } +} + +func TestUninstallSurfacesKeychainFailures(t *testing.T) { + // A locked/denied keychain must fail uninstall loudly — not report the + // token as removed while it still exists. + h := newHarness(t) + overrideVar(t, &execCommand, func(_ context.Context, stdin, name string, args ...string) (string, error) { + h.calls = append(h.calls, execCall{stdin: stdin, name: name, args: args}) + if name == "security" && len(args) > 0 && args[0] == "delete-generic-password" { + return "SecKeychainItemDelete: User interaction is not allowed.", errors.New("exit status 36") + } + return "", nil + }) + + err := Uninstall(context.Background(), h.options("", pingServer(t, "unused"))) + if err == nil || !strings.Contains(err.Error(), "delete keychain item") { + t.Fatalf("Uninstall() error = %v, want keychain failure surfaced", err) + } +} + +func TestDeleteKeychainTokensAcceptsNotFoundVariants(t *testing.T) { + for _, output := range []string{ + "security: The specified item could not be found in the keychain.", + "SECURITY: THE SPECIFIED ITEM COULD NOT BE FOUND IN THE KEYCHAIN.", + "item not found", + } { + t.Run(output, func(t *testing.T) { + overrideVar(t, &execCommand, func(_ context.Context, stdin, name string, args ...string) (string, error) { + return output, errors.New("exit status 44") + }) + if err := deleteKeychainTokens(context.Background()); err != nil { + t.Fatalf("deleteKeychainTokens() error = %v, want nil", err) + } + }) + } +} + +func TestUninstallSurfacesLaunchAgentBootoutFailure(t *testing.T) { + h := newHarness(t) + plistPath := filepath.Join(h.home, "Library", "LaunchAgents", LaunchAgentLabel+".plist") + if err := os.MkdirAll(filepath.Dir(plistPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(plistPath, []byte("plist"), 0o644); err != nil { + t.Fatal(err) + } + overrideVar(t, &execCommand, func(_ context.Context, stdin, name string, args ...string) (string, error) { + h.calls = append(h.calls, execCall{stdin: stdin, name: name, args: args}) + if name == "launchctl" && args[0] == "bootout" { + return "permission denied", errors.New("exit status 5") + } + return "", nil + }) + + err := Uninstall(context.Background(), h.options("", pingServer(t, "unused"))) + if err == nil || !strings.Contains(err.Error(), "launchctl bootout failed") { + t.Fatalf("Uninstall() error = %v, want bootout failure surfaced", err) + } +} + +func TestUninstallSurfacesLoadedLaunchAgentWithoutPlist(t *testing.T) { + h := newHarness(t) + overrideVar(t, &execCommand, func(_ context.Context, stdin, name string, args ...string) (string, error) { + h.calls = append(h.calls, execCall{stdin: stdin, name: name, args: args}) + if name == "launchctl" && args[0] == "bootout" { + return "No such file", errors.New("exit status 5") + } + if name == "launchctl" && args[0] == "print" { + return "service is loaded", nil + } + return "", nil + }) + + err := Uninstall(context.Background(), h.options("", pingServer(t, "unused"))) + if err == nil || !strings.Contains(err.Error(), "still loaded") { + t.Fatalf("Uninstall() error = %v, want loaded service failure", err) + } +} + +func TestUninstallBootsOutByPlistPath(t *testing.T) { + // Bootout must target OUR plist, not the shared label — a label-target + // bootout could unload an MDM agent holding the same label. + h := newHarness(t) + if err := Uninstall(context.Background(), h.options("", pingServer(t, "unused"))); err != nil { + t.Fatal(err) + } + for _, call := range h.calls { + if call.name == "launchctl" && call.args[0] == "bootout" { + if len(call.args) != 3 || !strings.HasSuffix(call.args[2], ".plist") { + t.Fatalf("bootout args = %v, want domain + plist path", call.args) + } + return + } + } + t.Fatal("no bootout call recorded") +} + +func TestSecurityQuote(t *testing.T) { + cases := map[string]string{ + `plain`: `"plain"`, + `with"quote`: `"with\"quote"`, + `back\slash`: `"back\\slash"`, + } + for in, want := range cases { + if got := securityQuote(in); got != want { + t.Errorf("securityQuote(%q) = %s, want %s", in, got, want) + } + } +} + +func TestStableBinaryPathPrefersBrewSymlink(t *testing.T) { + // Non-Cellar paths pass through untouched. + overrideVar(t, &executablePath, func() (string, error) { return "/usr/local/bin/kontext", nil }) + if got, note := stableBinaryPath(); got != "/usr/local/bin/kontext" || note != "" { + t.Fatalf("stableBinaryPath() = %q, %q", got, note) + } + + // Cellar path with no matching stable symlink keeps the Cellar path and + // warns about brew upgrades. + overrideVar(t, &executablePath, func() (string, error) { + return "/usr/local/Cellar/kontext/1.0.0/bin/kontext", nil + }) + got, note := stableBinaryPath() + if got != "/usr/local/Cellar/kontext/1.0.0/bin/kontext" || !strings.Contains(note, "brew upgrade") { + t.Fatalf("stableBinaryPath() = %q, %q", got, note) + } +} diff --git a/internal/setup/uninstall.go b/internal/setup/uninstall.go new file mode 100644 index 0000000..55312c6 --- /dev/null +++ b/internal/setup/uninstall.go @@ -0,0 +1,95 @@ +package setup + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/kontext-security/kontext-cli/internal/claudemanaged" + "github.com/kontext-security/kontext-cli/internal/installation" + "github.com/kontext-security/kontext-cli/internal/managedconfig" +) + +// Uninstall reverses Run in reverse order. Every step tolerates +// already-removed state so a partially-failed uninstall can simply be re-run. +// +// Deliberately KEPT: +// - installation.json — it holds only the random ins_* device identity; a +// later re-setup then reports the same endpoint to the dashboard instead +// of spawning a phantom second device. +// - local data (guard.db, stream state) and logs — they are the user's +// records; locations are printed instead. +// - the binary — brew owns it (`brew uninstall kontext`). +func Uninstall(ctx context.Context, opts Options) error { + if goos != "darwin" { + return errors.New("kontext setup is currently macOS-only") + } + + if _, err := os.Lstat(systemConfigPath); err == nil { + fmt.Fprintln(opts.Stderr, "warning: an organization-managed (MDM) Kontext install remains active on this Mac and is not affected by this command") + } + + plistPath, err := removeLaunchAgent(ctx) + if err != nil { + return err + } + fmt.Fprintf(opts.Stdout, "✓ Background agent removed (%s)\n", plistPath) + + settingsPath, err := userSettingsPathNoCreate() + if err != nil { + return err + } + if _, err := os.Lstat(settingsPath); errors.Is(err, os.ErrNotExist) { + // A removal must never CREATE settings: on a machine without Claude + // settings (or after the user deleted them) there is nothing to do. + fmt.Fprintln(opts.Stdout, "· No Claude Code settings file — no hooks to remove") + } else if err != nil { + return err + } else { + settings, err := claudemanaged.ReadUserSettings(settingsPath) + if err != nil { + return err + } + if err := claudemanaged.BackupUserSettings(settingsPath, settingsBackupLabel); err != nil { + return err + } + if err := claudemanaged.RemoveManagedHooks(settings); err != nil { + return err + } + if err := claudemanaged.WriteUserSettings(settingsPath, settings); err != nil { + return err + } + fmt.Fprintln(opts.Stdout, "✓ Claude Code hooks removed from ~/.claude/settings.json") + } + + if err := deleteKeychainTokens(ctx); err != nil { + return err + } + fmt.Fprintf(opts.Stdout, "✓ Install token removed from your keychain (%s)\n", KeychainItemName) + + if path := managedconfig.UserPath(); path != "" { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + fmt.Fprintf(opts.Stdout, "✓ Managed config removed (%s)\n", path) + } + + if identity := installation.UserPath(); identity != "" { + if _, err := os.Lstat(identity); err == nil { + fmt.Fprintf(opts.Stdout, "· Kept installation identity %s (re-running setup reuses the same endpoint)\n", identity) + } + } + fmt.Fprintln(opts.Stdout, "· Kept local observe data and logs under ~/Library/Application Support/Kontext and ~/Library/Logs/Kontext") + fmt.Fprintln(opts.Stdout, "· The kontext binary is managed by Homebrew (`brew uninstall kontext` to remove)") + return nil +} + +func userSettingsPathNoCreate() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".claude", "settings.json"), nil +}