Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions cmd/kontext/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func main() {
}

root.AddCommand(startCmd())
root.AddCommand(setupCmd())
root.AddCommand(loginCmd())
root.AddCommand(logoutCmd())
root.AddCommand(hookCmd())
Expand Down
44 changes: 44 additions & 0 deletions cmd/kontext/setup.go
Original file line number Diff line number Diff line change
@@ -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
}
44 changes: 44 additions & 0 deletions cmd/kontext/setup_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
8 changes: 8 additions & 0 deletions internal/managedconfig/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
11 changes: 11 additions & 0 deletions internal/managedobserve/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions internal/managedobserve/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
34 changes: 34 additions & 0 deletions internal/managedobserve/scope_test.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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")
Expand Down
73 changes: 73 additions & 0 deletions internal/setup/keychain.go
Original file line number Diff line number Diff line change
@@ -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 <service> -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:<encoded>" 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 + `"`
}
Loading
Loading