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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,10 @@ data/
*.sqlite
*.sqlite3
models/generated/

# Local agent/browser artifacts — never commit
.playwright-mcp/
$CODEX_HOME/
engineering-*.png
engineering-snapshot.md
linear-*.png
1 change: 1 addition & 0 deletions cmd/kontext/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ func doctorCmd() *cobra.Command {
Short: "Inspect local Kontext CLI setup",
RunE: func(cmd *cobra.Command, args []string) error {
guardcli.PrintHookStatus(cmd.OutOrStdout())
managedobserve.PrintStatus(cmd.OutOrStdout())
return nil
},
}
Expand Down
5 changes: 4 additions & 1 deletion internal/guard/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,10 @@ func PrintHookStatus(out io.Writer) {
case isGuardHookCommand(command):
guard = true
fmt.Fprintf(out, "Claude Code Guard hook: %s\n", command)
case strings.Contains(command, "kontext hook"):
// Managed-observe hooks are quoted ('<bin>' hook '<alias>'); use the
// shared predicate so wrapper commands containing "kontext" are not
// misclassified as hosted hooks.
case claudemanaged.IsManagedHookCommand(command):
hosted = true
fmt.Fprintf(out, "Claude Code hosted hook: %s\n", command)
}
Expand Down
8 changes: 6 additions & 2 deletions internal/guard/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,8 @@ func TestPrintHookStatusReportsGuardAndHostedConflict(t *testing.T) {
"PostToolUse": [
{
"hooks": [
{"type": "command", "command": "/usr/local/bin/kontext hook --agent claude"}
{"type": "command", "command": "'/usr/local/bin/kontext' hook 'post-tool-use'"},
{"type": "command", "command": "/usr/local/bin/not-kontext hook post-tool-use"}
]
}
]
Expand All @@ -232,9 +233,12 @@ func TestPrintHookStatusReportsGuardAndHostedConflict(t *testing.T) {
if !strings.Contains(got, "Claude Code Guard hook: /usr/local/bin/kontext hook --agent claude --mode observe") {
t.Fatalf("PrintHookStatus() = %q, want guard hook line", got)
}
if !strings.Contains(got, "Claude Code hosted hook: /usr/local/bin/kontext hook --agent claude") {
if !strings.Contains(got, "Claude Code hosted hook: '/usr/local/bin/kontext' hook 'post-tool-use'") {
t.Fatalf("PrintHookStatus() = %q, want hosted hook line", got)
}
if strings.Contains(got, "not-kontext") {
t.Fatalf("PrintHookStatus() = %q, want foreign hook ignored", got)
}
if !strings.Contains(got, "Claude Code hook mode: conflict (hosted and Guard hooks are both installed)") {
t.Fatalf("PrintHookStatus() = %q, want conflict mode", got)
}
Expand Down
76 changes: 76 additions & 0 deletions internal/managedobserve/autherr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package managedobserve

import (
"encoding/json"
"errors"
"io/fs"
"os"
"path/filepath"
"time"
)

// AuthError is the on-disk breadcrumb a daemon leaves when it cannot
// authenticate: kind "auth" for hosted-ledger rejections (revoked token),
// kind "startup" when the install token could not even be resolved (locked
// keychain, missing item). The daemon's stderr goes to a log file nobody
// watches, so `kontext doctor` reads this file to give the user the actual
// next step.
type AuthError struct {
Kind string `json:"kind"`
Status int `json:"status,omitempty"`
Message string `json:"message,omitempty"`
At string `json:"at"`
}

const authErrorKindCorrupt = "corrupt"

// AuthErrorPath puts the breadcrumb next to the observe database — the one
// directory both the daemon and doctor can always derive.
func AuthErrorPath(dbPath string) string {
return filepath.Join(filepath.Dir(dbPath), "last-auth-error.json")
}

func WriteAuthError(dbPath string, status int) error {
return writeBreadcrumb(dbPath, AuthError{Kind: "auth", Status: status})
}

// WriteStartupError records that the daemon exited before streaming — e.g.
// the keychain item was unreadable under launchd. Without it, doctor can only
// say "daemon: not running" with no cause.
func WriteStartupError(dbPath string, message string) error {
return writeBreadcrumb(dbPath, AuthError{Kind: "startup", Message: message})
}

func writeBreadcrumb(dbPath string, breadcrumb AuthError) error {
breadcrumb.At = time.Now().UTC().Format(time.RFC3339)
data, err := json.Marshal(breadcrumb)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(AuthErrorPath(dbPath)), 0o755); err != nil {
return err
}
return os.WriteFile(AuthErrorPath(dbPath), append(data, '\n'), 0o600)
}

func ClearAuthError(dbPath string) {
_ = os.Remove(AuthErrorPath(dbPath))
}

// LoadAuthError returns the breadcrumb, or nil when none exists. Unreadable or
// corrupt files are returned as a distinct diagnostic kind so doctor never
// turns a local breadcrumb problem into a false revoked-token warning.
func LoadAuthError(dbPath string) *AuthError {
data, err := os.ReadFile(AuthErrorPath(dbPath))
if err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return &AuthError{Kind: authErrorKindCorrupt, Message: err.Error()}
}
return nil
}
var authErr AuthError
if err := json.Unmarshal(data, &authErr); err != nil {
return &AuthError{Kind: authErrorKindCorrupt, Message: err.Error()}
}
return &authErr
}
60 changes: 60 additions & 0 deletions internal/managedobserve/autherr_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package managedobserve

import (
"net/http"
"os"
"path/filepath"
"testing"
)

func TestAuthErrorRoundTrip(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "guard.db")

if got := LoadAuthError(dbPath); got != nil {
t.Fatalf("LoadAuthError before write = %v, want nil", got)
}

if err := WriteAuthError(dbPath, http.StatusUnauthorized); err != nil {
t.Fatal(err)
}
got := LoadAuthError(dbPath)
if got == nil || got.Status != http.StatusUnauthorized || got.Kind != "auth" || got.At == "" {
t.Fatalf("LoadAuthError = %+v", got)
}

ClearAuthError(dbPath)
if got := LoadAuthError(dbPath); got != nil {
t.Fatalf("LoadAuthError after clear = %v, want nil", got)
}
// Clearing again is a no-op.
ClearAuthError(dbPath)
}

func TestStartupErrorRoundTrip(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "nested", "guard.db") // dir created on demand

if err := WriteStartupError(dbPath, "resolve install token: keychain locked"); err != nil {
t.Fatal(err)
}
got := LoadAuthError(dbPath)
if got == nil || got.Kind != "startup" || got.Message == "" || got.At == "" {
t.Fatalf("LoadAuthError = %+v", got)
}

ClearAuthError(dbPath)
if LoadAuthError(dbPath) != nil {
t.Fatal("startup breadcrumb not cleared")
}
}

func TestLoadAuthErrorToleratesCorruptBreadcrumb(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "guard.db")
if err := os.WriteFile(AuthErrorPath(dbPath), []byte("{corrupt"), 0o600); err != nil {
t.Fatal(err)
}
// Doctor reporting must never fail on a corrupt file, and must not turn a
// local breadcrumb problem into a false revoked-token warning.
if got := LoadAuthError(dbPath); got == nil || got.Kind != authErrorKindCorrupt || got.Message == "" {
t.Fatalf("LoadAuthError(corrupt) = %+v", got)
}
}
34 changes: 29 additions & 5 deletions internal/managedobserve/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,26 @@ func RunDaemon(ctx context.Context, opts DaemonOptions) error {
if err != nil {
return fmt.Errorf("ensure installation identity: %w", err)
}

dbPath := opts.DBPath
if dbPath == "" {
dbPath = DefaultDBPath()
}

installToken, err := managedconfig.ResolveInstallToken(ctx, loadedConfig.Config.Credentials.InstallTokenRef)
if err != nil {
// Leave a breadcrumb: under launchd this exit is otherwise invisible
// (doctor would only see "daemon: not running" with no cause). A
// locked login keychain at boot is the typical trigger.
if breadcrumbErr := WriteStartupError(dbPath, err.Error()); breadcrumbErr != nil {
opts.Diagnostic.Printf("write startup-error breadcrumb: %v\n", breadcrumbErr)
}
return fmt.Errorf("resolve install token: %w", err)
}
// Token resolved — clear any stale startup breadcrumb from a prior boot.
if previous := LoadAuthError(dbPath); previous != nil && previous.Kind == "startup" {
ClearAuthError(dbPath)
}

socketPath := opts.SocketPath
if socketPath == "" {
Expand All @@ -66,10 +82,6 @@ func RunDaemon(ctx context.Context, opts DaemonOptions) error {
if err := EnsureSocketDir(socketPath); err != nil {
return fmt.Errorf("prepare managed observe socket dir: %w", err)
}
dbPath := opts.DBPath
if dbPath == "" {
dbPath = DefaultDBPath()
}
if err := cleanupStaleSessions(ctx, dbPath, idleTimeoutOrDefault(opts.IdleTimeout)); err != nil {
opts.Diagnostic.Printf("managed observe cleanup: %v\n", err)
}
Expand All @@ -96,14 +108,26 @@ func RunDaemon(ctx context.Context, opts DaemonOptions) error {
DBPath: dbPath,
StatePath: opts.StreamStatePath,
CloudURL: loadedConfig.Config.CloudURL,
OrganizationID: loadedConfig.Config.OrganizationID,
InstallationID: installationState.InstallationID,
InstallToken: installToken,
DeviceLabel: loadedConfig.Config.Device.Label,
DeploymentVersion: deploymentVersionWithFallback(opts.FallbackDeploymentVersion),
Interval: opts.StreamInterval,
HTTPClient: opts.StreamHTTPClient,
Diagnostic: opts.Diagnostic,
OnAuthFailure: func(status int) {
// Unconditional stderr (Diagnostic is env-gated and would be
// silent under launchd) plus a breadcrumb for `kontext doctor`.
fmt.Fprintf(os.Stderr,
"Kontext install token rejected by %s (HTTP %d). It may have been revoked — run `kontext setup` with a new token from the dashboard.\n",
loadedConfig.Config.CloudURL, status)
if err := WriteAuthError(dbPath, status); err != nil {
opts.Diagnostic.Printf("write auth-error breadcrumb: %v\n", err)
}
},
OnFlushSuccess: func() {
ClearAuthError(dbPath)
},
})
}()

Expand Down
4 changes: 2 additions & 2 deletions internal/managedobserve/daemon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,8 @@ func TestDaemonStreamsLedgerBatches(t *testing.T) {

select {
case body := <-requests:
if body.OrganizationID != "org_123" {
t.Fatalf("organization_id = %q", body.OrganizationID)
if body.OrganizationID != "" {
t.Fatalf("organization_id sent = %q, want omitted (token binds the org)", body.OrganizationID)
}
if body.InstallationID != "ins_0123456789abcdefghijklmnopqrstuv" {
t.Fatalf("installation_id = %q", body.InstallationID)
Expand Down
105 changes: 105 additions & 0 deletions internal/managedobserve/doctor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package managedobserve

import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"time"

"github.com/kontext-security/kontext-cli/internal/installation"
"github.com/kontext-security/kontext-cli/internal/managedconfig"
)

// PrintStatus reports the managed-observe state for `kontext doctor`:
// which managed config (if any) this machine resolves, the installation
// identity, whether the daemon is reachable, the self-serve LaunchAgent, and
// any token-rejection breadcrumb the daemon left behind.
func PrintStatus(out io.Writer) {
fmt.Fprintln(out, "Managed observe:")

loaded, err := managedconfig.Load()
if errors.Is(err, managedconfig.ErrNotManaged) {
fmt.Fprintln(out, " config: not configured (run `kontext setup` to connect this Mac to a workspace)")
return
}
if err != nil {
fmt.Fprintf(out, " config: ERROR %v\n", err)
return
}

fmt.Fprintf(out, " config: %s (%s)\n", loaded.Path, describeScope(loaded.Scope))
fmt.Fprintf(out, " organization: %s\n", loaded.Config.OrganizationID)

identityPath := installationPathForScope(loaded.Scope)
if state, err := installation.LoadFile(identityPath); err == nil {
fmt.Fprintf(out, " installation: %s\n", state.InstallationID)
} else {
fmt.Fprintf(out, " installation: not created yet (%s)\n", identityPath)
}

// Resolve the token through the daemon's exact read path: a locked or
// missing keychain item is THE silent killer under launchd, and "daemon:
// not running" alone points the user in the wrong direction.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := managedconfig.ResolveInstallToken(ctx, loaded.Config.Credentials.InstallTokenRef); err == nil {
fmt.Fprintf(out, " install token: readable (%s)\n", loaded.Config.Credentials.InstallTokenRef)
} else {
fmt.Fprintf(out, " WARNING: install token is not readable (%v) — the agent cannot stream; re-run `kontext setup` or unlock your login keychain\n", err)
}

if conn, err := net.DialTimeout("unix", DefaultSocketPath(), 500*time.Millisecond); err == nil {
conn.Close()
fmt.Fprintln(out, " daemon: running")
} else {
fmt.Fprintln(out, " daemon: not running (it starts with your next Claude Code session)")
}

// Self-serve installs have a user LaunchAgent; MDM installs manage theirs
// under /Library. Having BOTH scopes on one Mac deserves a callout — the
// system config wins and the user agent should be removed.
if home, err := os.UserHomeDir(); err == nil && home != "" {
userPlist := filepath.Join(home, "Library", "LaunchAgents", DefaultLaunchdLabel+".plist")
if _, err := os.Lstat(userPlist); err == nil {
fmt.Fprintf(out, " launch agent: %s\n", userPlist)
if loaded.Scope == managedconfig.ScopeSystem {
fmt.Fprintln(out, " WARNING: this Mac is organization-managed but a self-serve agent is also installed; run `kontext setup --uninstall` to remove it")
}
}
}

// The LaunchAgent runs the daemon without --db, so the breadcrumb always
// sits next to the default database. A custom --db (dev-only hidden flag)
// is invisible here — acceptable for a diagnostics readout.
if authErr := LoadAuthError(DefaultDBPath()); authErr != nil {
switch authErr.Kind {
case "startup":
fmt.Fprintf(out, " WARNING: the agent failed to start — %s (%s)\n", authErr.Message, authErr.At)
case authErrorKindCorrupt:
fmt.Fprintf(out, " WARNING: auth breadcrumb is unreadable — %s\n", authErr.Message)
default:
detail := ""
if authErr.Status > 0 {
detail = fmt.Sprintf(" (HTTP %d, %s)", authErr.Status, authErr.At)
}
fmt.Fprintf(out, " WARNING: hosted ingest is failing — install token rejected%s; run `kontext setup` with a new token from the dashboard\n", detail)
}
Comment thread
michiosw marked this conversation as resolved.
}
}

func describeScope(scope managedconfig.Scope) string {
switch scope {
case managedconfig.ScopeSystem:
return "system, managed by your organization"
case managedconfig.ScopeUser:
return "user, installed by kontext setup"
case managedconfig.ScopeEnv:
return "env override"
default:
return string(scope)
}
}
Loading
Loading