From b51e43bf48a192cdac6b3bbaa1ce53d69e38b50a Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 13 Oct 2026 11:48:11 +0100 Subject: [PATCH] Fix setup diagnostics for Codex, Qwen and DeepSeek --- README.md | 16 + cmd/pitot/deepseek_wiring.go | 23 +- cmd/pitot/doctor_codex.go | 207 ++++++++++++ cmd/pitot/doctor_host.go | 6 + cmd/pitot/doctor_paths_unix.go | 5 + cmd/pitot/doctor_paths_windows.go | 17 + cmd/pitot/doctor_qwen.go | 507 +++++++++++++++++++++++++++++ cmd/pitot/doctor_readiness.go | 114 +++++++ cmd/pitot/doctor_readiness_test.go | 467 ++++++++++++++++++++++++++ cmd/pitot/main.go | 9 +- integrations/qwen/bridge.go | 14 + runtime/local_health_test.go | 83 +++++ runtime/transport.go | 28 ++ 13 files changed, 1485 insertions(+), 11 deletions(-) create mode 100644 cmd/pitot/doctor_codex.go create mode 100644 cmd/pitot/doctor_paths_unix.go create mode 100644 cmd/pitot/doctor_paths_windows.go create mode 100644 cmd/pitot/doctor_qwen.go create mode 100644 cmd/pitot/doctor_readiness.go create mode 100644 cmd/pitot/doctor_readiness_test.go create mode 100644 integrations/qwen/bridge.go create mode 100644 runtime/local_health_test.go diff --git a/README.md b/README.md index b660528..f95952d 100644 --- a/README.md +++ b/README.md @@ -468,6 +468,22 @@ FOUND, MISSING, or DRIFTED against that witness; `pitot doctor --host HOST --fix` restores a drifted entry (Pitot's own entries only — that is the single mutation doctor ever performs, and only on request). +For Codex, Qwen and DeepSeek, doctor also checks the executable and the +integration's static setup on macOS, Linux and Windows. It reports independent +problems together. A `static setup: PASS` is scoped to the inspected files and +dependencies; `live enforcement: UNVERIFIED` means no agent action or Controller +decision was exercised. Codex session trust and normalized hook hashes still +require inspection in `/hooks`; stored trust that doctor cannot compare returns +a nonzero result. Unsupported command forms, conflicting Qwen hook definitions +and additional Codex configuration layers also require native inspection. + +With `PITOT_RUNTIME` unset, hooks remain observe-only. With it set, doctor checks +the selected runtime's identity through its local health endpoint. It does not +follow redirects, use an HTTP proxy, contact remote endpoints or submit a test +action. A healthy runtime does not establish that a Controller is available. +Plain doctor does not modify settings or grant trust; `--fix` performs the +supported wiring step, then runs the diagnostic checks separately. + Hosts configured at **user level** (Kimi, Copilot, Qwen) or via **plugin files** (OpenCode, Pi) keep the one-time manual edit below — Pitot does not edit files outside the repository; `pitot init --host kimi` prints the exact diff --git a/cmd/pitot/deepseek_wiring.go b/cmd/pitot/deepseek_wiring.go index 6d31c41..076277c 100644 --- a/cmd/pitot/deepseek_wiring.go +++ b/cmd/pitot/deepseek_wiring.go @@ -10,7 +10,6 @@ import ( "fmt" "io" "os" - "os/exec" "path/filepath" "strings" @@ -112,20 +111,24 @@ func checkDeepSeekCommandVersion(ctx context.Context, executable string, argumen } func doctorDeepSeek(stdout io.Writer) error { - executable, err := exec.LookPath("dsh") - if err != nil { - return errors.New("pitot doctor: dsh is not on PATH") - } - if err := checkDeepSeekVersion(context.Background(), executable); err != nil { - return err + report := &readinessReport{out: stdout} + if executable := report.binary("dsh"); executable != "" { + if err := checkDeepSeekVersion(context.Background(), executable); err != nil { + report.result("DeepSeek version", "UNVERIFIED", "bounded version check failed or the version is unsupported; install @deepseek-ai/dsh@"+deepseek.Version, true) + } else { + report.result("DeepSeek version", "PASS", deepseek.Version, false) + } } root, err := config.FindRoot(".") if err != nil { root = "." } if err := checkDeepSeekIntegration(root); err != nil { - return err + report.result("native shell plugin", "INVALID", "owned integration files are missing or changed; run pitot init --host deepseek after reviewing existing files", true) + } else { + report.result("native shell plugin", "FOUND", "owned integration files match this release", false) } - fmt.Fprintf(stdout, " DeepSeek Harness %s: FOUND\n native shell plugin: FOUND\n live readiness: checked before each prompt by pitot dev\n", deepseek.Version) - return nil + report.selectedRuntime() + fmt.Fprintln(stdout, " live readiness: checked before each prompt by pitot dev") + return report.finish() } diff --git a/cmd/pitot/doctor_codex.go b/cmd/pitot/doctor_codex.go new file mode 100644 index 0000000..cdacee9 --- /dev/null +++ b/cmd/pitot/doctor_codex.go @@ -0,0 +1,207 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + + "github.com/operatorstack/pitot/config" + "github.com/operatorstack/pitot/hydrate" + "github.com/operatorstack/pitot/wiring" + "github.com/pelletier/go-toml/v2" +) + +var doctorCodexSystemDirectory = codexSystemDirectory + +func (r *readinessReport) codexSettings(path string) map[string]any { + data, err := readDoctorFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + var settings map[string]any + if err != nil || toml.Unmarshal(data, &settings) != nil { + r.result("Codex settings", "INVALID", "a TOML configuration file is unreadable or malformed", true) + return nil + } + r.result("Codex settings", "READ", path, false) + return settings +} + +func doctorCodex(stdout io.Writer) error { + r := &readinessReport{out: stdout} + r.binary("codex") + root := "." + if found, err := config.FindRoot("."); err == nil { + root = found + } + root, err := filepath.Abs(root) + if err != nil { + return err + } + if err := printWiringStatus(root, "codex", stdout); err != nil { + r.issues++ + } + shim, expected := filepath.Join(root, shimDir, "pitot"), shimPOSIX + if runtime.GOOS == "windows" { + shim, expected = filepath.Join(root, shimDir, "pitot.ps1"), shimPowerShell + r.binary("powershell") + } + if r.file("repository shim", shim, runtime.GOOS != "windows") { + data, err := readDoctorFile(shim) + if err != nil || !bytes.Equal(data, []byte(expected)) { + r.result("repository shim", "UNVERIFIED", "shim differs from the generated script; review before restoring with init", true) + } + } + if _, err := hydrate.Pin(root); err != nil { + r.result("repository pin", "INVALID", "a valid .pitot/version is required", true) + } else { + r.result("repository pin", "FOUND", "release availability and hydration are not checked", false) + } + home := os.Getenv("CODEX_HOME") + if home == "" { + userHome, err := os.UserHomeDir() + if err != nil { + r.result("Codex home", "UNVERIFIED", "home directory cannot be resolved", true) + return r.finish() + } + home = filepath.Join(userHome, ".codex") + } + systemDir := doctorCodexSystemDirectory() + system := r.codexSettings(filepath.Join(systemDir, "config.toml")) + user := r.codexSettings(filepath.Join(home, "config.toml")) + project := r.codexSettings(filepath.Join(root, ".codex", "config.toml")) + policy := r.codexSettings(filepath.Join(systemDir, "requirements.toml")) + if runtime.GOOS != "windows" { + if legacy := r.codexSettings(filepath.Join(systemDir, "managed_config.toml")); len(legacy) != 0 { + r.result("managed configuration", "UNVERIFIED", "legacy managed settings require native inspection", true) + } + } + trusted := false + projects, _ := user["projects"].(map[string]any) + for path, raw := range projects { + entry, _ := raw.(map[string]any) + if sameDoctorPath(path, root) && entry["trust_level"] == "trusted" { + trusted = true + } + } + layers := []map[string]any{system, user} + // Ancestor and invocation-directory layers can change the effective flags. + // Do not silently treat the repository-root file as the entire tree. + cwd, _ := os.Getwd() + projectRoot := cwd + for dir := cwd; dir != ""; dir = filepath.Dir(dir) { + if info, err := os.Stat(filepath.Join(dir, ".git")); err == nil { + valid := true + if info.IsDir() { + if _, err := os.Stat(filepath.Join(dir, ".git", "HEAD")); err != nil { + valid = false + } + } + if valid { + projectRoot = dir + break + } + } + if filepath.Dir(dir) == dir { + break + } + } + for _, layer := range []map[string]any{system, user} { + if _, exists := layer["project_root_markers"]; exists { + r.result("Codex project root", "UNVERIFIED", "custom project root markers require native discovery inspection", true) + } + } + for dir := cwd; dir != ""; dir = filepath.Dir(dir) { + if !sameDoctorPath(dir, root) && !sameDoctorPath(filepath.Join(dir, ".codex"), home) { + path := filepath.Join(dir, ".codex", "config.toml") + if _, err := os.Stat(path); err == nil || !errors.Is(err, os.ErrNotExist) { + r.result("Codex configuration layers", "UNVERIFIED", "additional directory configuration requires native precedence inspection", true) + } + } + if sameDoctorPath(dir, projectRoot) || filepath.Dir(dir) == dir { + break + } + } + if trusted { + layers = append(layers, project) + } else { + r.result("project trust", "UNVERIFIED", "confirm this project's trust and the exact hook in Codex /hooks", false) + } + enabled := true + features := make(map[string]any) + for _, layer := range layers { + if profile, exists := layer["profile"]; exists && profile != nil { + r.result("Codex profile", "UNVERIFIED", "selected profile requires native configuration inspection", true) + } + for _, key := range []string{"hooks", "codex_hooks"} { + if value := doctorSetting(layer, "features", key); value != nil { + features[key] = value + } + } + } + value := features["hooks"] + if value == nil { + value = features["codex_hooks"] + } + if value != nil { + if flag, ok := value.(bool); ok { + enabled = flag + } else { + r.result("hook feature", "INVALID", "hook feature flag must be a boolean", true) + } + } + if doctorSetting(policy, "features", "hooks") == false || doctorSetting(policy, "features", "codex_hooks") == false { + enabled = false + } + if !enabled || policy["allow_managed_hooks_only"] == true { + r.result("hook activation", "DISABLED", "inspected settings exclude the repository hook", true) + } + hooksPath := filepath.Join(root, wiring.ConfigPath("codex")) + data, readErr := readDoctorFile(hooksPath) + var hooks map[string]any + if readErr != nil || json.Unmarshal(data, &hooks) != nil { + r.result("hook declaration", "INVALID", "hook configuration is unreadable or malformed", true) + } + if readErr == nil && json.Unmarshal(data, &hooks) == nil { + groups, _ := doctorSetting(hooks, "hooks", "PreToolUse").([]any) + canonical, _ := json.Marshal(wiring.CanonicalEntry("codex")) + matched := false + for index, raw := range groups { + entry, _ := json.Marshal(raw) + if !bytes.Equal(entry, canonical) { + continue + } + matched = true + key := fmt.Sprintf("%s:pre_tool_use:%d:0", hooksPath, index) + state, _ := doctorSetting(user, "hooks", "state", key).(map[string]any) + if flag, exists := state["enabled"]; exists { + if _, ok := flag.(bool); !ok { + r.result("hook state", "INVALID", "enabled must be a boolean", true) + } + } + if state["enabled"] == false { + r.result("hook state", "DISABLED", "user hook state disables this exact repository entry", true) + } + if state["trusted_hash"] == nil { + r.result("hook trust", "UNTRUSTED", "no stored user trust for this entry; review it in Codex /hooks", false) + } else { + if _, ok := state["trusted_hash"].(string); !ok { + r.result("hook trust", "INVALID", "stored trust hash must be a string", true) + } else { + r.result("hook trust", "UNVERIFIED", "stored trust cannot be compared with the native normalized definition; verify in /hooks", true) + } + } + } + if !matched { + r.result("hook declaration", "UNVERIFIED", "no current canonical entry found; a fragment match alone cannot establish the command", true) + } + } + r.result("effective session configuration", "UNVERIFIED", "profiles, ancestor layers, managed/cloud policy and invocation flags require native inspection", false) + r.selectedRuntime() + return r.finish() +} diff --git a/cmd/pitot/doctor_host.go b/cmd/pitot/doctor_host.go index 5b88221..3481907 100644 --- a/cmd/pitot/doctor_host.go +++ b/cmd/pitot/doctor_host.go @@ -109,6 +109,12 @@ func doctorHost(host adapters.Host, stdout, stderr io.Writer) error { if host == adapters.DeepSeek { return doctorDeepSeek(stdout) } + if host == adapters.Qwen { + return doctorQwen(stdout) + } + if host == adapters.Codex { + return doctorCodex(stdout) + } // Repo-wireable hosts report their wiring state against the fragment // witness (drift-is-named-not-silently-fixed). diff --git a/cmd/pitot/doctor_paths_unix.go b/cmd/pitot/doctor_paths_unix.go new file mode 100644 index 0000000..5a86029 --- /dev/null +++ b/cmd/pitot/doctor_paths_unix.go @@ -0,0 +1,5 @@ +//go:build !windows + +package main + +func codexSystemDirectory() string { return "/etc/codex" } diff --git a/cmd/pitot/doctor_paths_windows.go b/cmd/pitot/doctor_paths_windows.go new file mode 100644 index 0000000..ea9d85b --- /dev/null +++ b/cmd/pitot/doctor_paths_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package main + +import ( + "path/filepath" + + "golang.org/x/sys/windows" +) + +func codexSystemDirectory() string { + root, err := windows.KnownFolderPath(windows.FOLDERID_ProgramData, 0) + if err != nil { + root = `C:\ProgramData` + } + return filepath.Join(root, "OpenAI", "Codex") +} diff --git a/cmd/pitot/doctor_qwen.go b/cmd/pitot/doctor_qwen.go new file mode 100644 index 0000000..0622001 --- /dev/null +++ b/cmd/pitot/doctor_qwen.go @@ -0,0 +1,507 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + qwen "github.com/operatorstack/pitot/integrations/qwen" +) + +// Parse the JSON-with-comments syntax supported by Qwen without invoking its +// loader: the native loader can migrate and rewrite configuration files. +func doctorJSON(data []byte) (map[string]any, error) { + data = bytes.TrimPrefix(data, []byte{0xef, 0xbb, 0xbf}) + clean := append([]byte(nil), data...) + quoted, escaped := false, false + for i := 0; i < len(clean); i++ { + if quoted { + if escaped { + escaped = false + } else if clean[i] == '\\' { + escaped = true + } else if clean[i] == '"' { + quoted = false + } + continue + } + if clean[i] == '"' { + quoted = true + } else if clean[i] == '/' && i+1 < len(clean) && clean[i+1] == '/' { + for i < len(clean) && clean[i] != '\n' { + clean[i] = ' ' + i++ + } + } else if clean[i] == '/' && i+1 < len(clean) && clean[i+1] == '*' { + clean[i], clean[i+1] = ' ', ' ' + i += 2 + for i+1 < len(clean) && !(clean[i] == '*' && clean[i+1] == '/') { + if clean[i] != '\n' && clean[i] != '\r' { + clean[i] = ' ' + } + i++ + } + if i+1 >= len(clean) { + return nil, errors.New("unterminated comment") + } + clean[i], clean[i+1] = ' ', ' ' + i++ + } + } + var settings map[string]any + if err := json.Unmarshal(clean, &settings); err != nil || settings == nil { + return nil, errors.New("settings must be a JSON object") + } + return settings, nil +} + +func doctorSetting(m map[string]any, keys ...string) any { + var value any = m + for _, key := range keys { + object, ok := value.(map[string]any) + if !ok { + return nil + } + value = object[key] + } + return value +} + +func (r *readinessReport) qwenSettings(path string) map[string]any { + data, err := readDoctorFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + r.result("settings", "INVALID", "a configuration file is unreadable or too large", true) + return nil + } + settings, err := doctorJSON(data) + if err != nil { + r.result("settings", "INVALID", "a configuration file is not a valid JSON object with optional comments", true) + return nil + } + r.result("settings", "READ", path, false) + return settings +} + +func qwenSystemPath(goos string) string { + if path := os.Getenv("QWEN_CODE_SYSTEM_SETTINGS_PATH"); path != "" { + return path + } + switch goos { + case "windows": + return `C:\ProgramData\qwen-code\settings.json` + case "darwin": + return "/Library/Application Support/QwenCode/settings.json" + default: + return "/etc/qwen-code/settings.json" + } +} + +func sameDoctorPath(a, b string) bool { + a, _ = filepath.Abs(a) + b, _ = filepath.Abs(b) + if real, err := filepath.EvalSymlinks(a); err == nil { + a = real + } + if real, err := filepath.EvalSymlinks(b); err == nil { + b = real + } + if runtime.GOOS == "windows" { + return strings.EqualFold(a, b) + } + return a == b +} + +func qwenTrustDecision(project string, rules map[string]any) (bool, bool) { + project, _ = filepath.Abs(project) + if real, err := filepath.EvalSymlinks(project); err == nil { + project = real + } + depth, trusted := -1, false + for path, value := range rules { + level, ok := value.(string) + if !ok || !filepath.IsAbs(path) || (level != "TRUST_FOLDER" && level != "TRUST_PARENT" && level != "DO_NOT_TRUST") { + return false, false + } + if real, err := filepath.EvalSymlinks(path); err == nil { + path = real + } + if level == "TRUST_PARENT" { + path = filepath.Dir(path) + } + if runtime.GOOS == "windows" { + path, project = strings.ToLower(path), strings.ToLower(project) + } + rel, err := filepath.Rel(path, project) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + continue + } + n := len(strings.Split(filepath.Clean(path), string(filepath.Separator))) + allow := level != "DO_NOT_TRUST" + if n > depth || n == depth && !allow { + depth, trusted = n, allow + } + } + return trusted, true +} + +// Deliberately recognize the documented simple matchers. JavaScript regular +// expressions have different semantics from Go's; do not silently substitute. +func qwenShellMatcher(value any) (match, known bool) { + if value == nil { + return true, true + } + pattern, ok := value.(string) + if !ok { + return false, false + } + pattern = strings.TrimSpace(pattern) + switch pattern { + case "", "*", ".*", "run_shell_command", "^run_shell_command$", "Bash": + return true, true + case "^Bash$", "read_file", "write_file", "^read_file$", "^write_file$": + return false, true + } + if strings.Contains(pattern, "|") && !strings.HasPrefix(pattern, "^") && !strings.HasPrefix(pattern, "(") { + known = true + for _, part := range strings.Split(pattern, "|") { + if strings.TrimSpace(part) == "" { + continue + } + m, k := qwenShellMatcher(part) + match, known = match || m, known && k + } + return match, known + } + return false, false +} + +// Only tokenize literal executable arguments. Shell substitutions, pipelines +// and environment expansion need native inspection rather than execution here. +func doctorLiteralWords(command string) ([]string, bool) { + if strings.ContainsAny(command, "$`;&|<>\n\r%!^") { + return nil, false + } + var words []string + var word strings.Builder + var quote rune + started := false + for _, c := range command { + if quote != 0 { + if c == quote { + quote = 0 + } else { + word.WriteRune(c) + } + } else if c == '"' || c == '\'' { + if c == '\'' && runtime.GOOS == "windows" { + return nil, false + } + quote = c + started = true + } else if c == ' ' || c == '\t' { + if started { + words = append(words, word.String()) + word.Reset() + started = false + } + } else { + if strings.ContainsRune("*?[]{}()#~", c) || c == '\\' && runtime.GOOS != "windows" { + return nil, false + } + word.WriteRune(c) + started = true + } + } + if quote != 0 { + return nil, false + } + if started { + words = append(words, word.String()) + } + return words, len(words) != 0 +} + +func (r *readinessReport) qwenCommand(command string, shell any) bool { + words, ok := doctorLiteralWords(command) + if !ok || len(words) > 3 { + r.result("hook command", "UNVERIFIED", "use a literal path to the shipped Pitot bridge; dynamic commands are not executed by doctor", true) + return false + } + if shell != nil && shell != "bash" && shell != "powershell" { + r.result("hook shell", "UNVERIFIED", "unsupported shell selection", true) + return false + } + shellBinary := "bash" + if runtime.GOOS == "windows" { + shellBinary = os.Getenv("ComSpec") + if shellBinary == "" { + shellBinary = "cmd.exe" + } + term := strings.ToLower(os.Getenv("TERM")) + if os.Getenv("MSYSTEM") != "" || strings.Contains(strings.ToLower(os.Getenv("SHELL")), "bash") || strings.Contains(term, "msys") || strings.Contains(term, "cygwin") { + r.result("hook shell", "UNVERIFIED", "inspect the effective MSYS/Git Bash shell in Qwen", true) + return false + } + } + if shell == "bash" { + shellBinary = "bash" + } else if shell == "powershell" { + shellBinary = "powershell" + } + // These shells interpret executable quoting and backslashes differently. + // Leave ambiguous forms for native inspection instead of guessing argv. + selectedShell := strings.ToLower(filepath.Base(shellBinary)) + if strings.Contains(selectedShell, "powershell") || strings.Contains(selectedShell, "pwsh") { + if strings.HasPrefix(strings.TrimSpace(command), "\"") || strings.HasPrefix(strings.TrimSpace(command), "'") { + r.result("hook shell", "UNVERIFIED", "quoted PowerShell executable requires native invocation inspection", true) + return false + } + } + if selectedShell == "bash" && strings.Contains(command, `\`) { + r.result("hook shell", "UNVERIFIED", "bash escape sequences require native inspection", true) + return false + } + if r.binary(shellBinary) == "" { + return false + } + bridge, expected, executable := words[0], qwen.BashBridge, true + index := 1 + if len(words) >= 2 && (filepath.Base(words[0]) == "node" || filepath.Base(words[0]) == "node.exe" || filepath.Base(words[0]) == "bash") { + if r.binary(words[0]) == "" { + return false + } + bridge, executable, index = words[1], false, 2 + if filepath.Base(words[0]) != "bash" { + expected = qwen.NodeBridge + } + } + if len(words) > index+1 || !filepath.IsAbs(bridge) { + r.result("hook command", "UNVERIFIED", "use an absolute bridge path and at most one literal Pitot executable argument", true) + return false + } + data, err := readDoctorFile(bridge) + if err != nil { + r.result("hook bridge", "MISSING", "the configured bridge cannot be read", true) + return false + } + if !bytes.Equal(data, []byte(expected)) { + r.result("hook bridge", "UNVERIFIED", "script differs from the shipped Pitot bridge; review it independently", true) + return false + } + if runtime.GOOS == "windows" && executable && shell != "bash" { + r.result("hook shell", "UNVERIFIED", "use node with PreToolUse.cjs on Windows, or explicitly select bash", true) + return false + } + if !r.file("hook bridge", bridge, executable) { + return false + } + if expected == qwen.BashBridge { + if r.binary("bash") == "" || r.binary("python3") == "" { + return false + } + } + pitot := os.Getenv("PITOT_BIN") + if len(words) == index+1 { + pitot = words[index] + } + if pitot == "" { + pitot = "pitot" + } + return r.lookup("Pitot executable", pitot) != "" +} + +func doctorQwen(stdout io.Writer) error { + r := &readinessReport{out: stdout} + r.binary("qwen") + home, err := os.UserHomeDir() + if err != nil { + r.result("settings", "UNVERIFIED", "home directory cannot be resolved", true) + return r.finish() + } + project, err := os.Getwd() + if err != nil { + return err + } + qwenHome := os.Getenv("QWEN_HOME") + if qwenHome == "" { + qwenHome = filepath.Join(home, ".qwen") + } else if qwenHome == "~" { + qwenHome = home + } else if strings.HasPrefix(qwenHome, "~/") || strings.HasPrefix(qwenHome, `~\`) { + qwenHome = filepath.Join(home, qwenHome[2:]) + } + systemPath := qwenSystemPath(runtime.GOOS) + defaultsPath := os.Getenv("QWEN_CODE_SYSTEM_DEFAULTS_PATH") + if defaultsPath == "" { + defaultsPath = filepath.Join(filepath.Dir(systemPath), "system-defaults.json") + } + defaults := r.qwenSettings(defaultsPath) + user := r.qwenSettings(filepath.Join(qwenHome, "settings.json")) + system := r.qwenSettings(systemPath) + trustEnabled := false + for _, layer := range []map[string]any{defaults, user, system} { + if value := doctorSetting(layer, "security", "folderTrust", "enabled"); value != nil { + if enabled, ok := value.(bool); ok { + trustEnabled = enabled + } else { + r.result("folder trust", "UNVERIFIED", "unsupported setting type", true) + } + } + } + trusted := !trustEnabled + if trustEnabled { + trustPath := os.Getenv("QWEN_CODE_TRUSTED_FOLDERS_PATH") + if trustPath == "" { + trustPath = filepath.Join(qwenHome, "trustedFolders.json") + } + var known bool + trusted, known = qwenTrustDecision(project, r.qwenSettings(trustPath)) + if !known { + r.result("folder trust", "UNVERIFIED", "unsupported trust rule", true) + } + } + var workspace map[string]any + if trusted && !sameDoctorPath(project, home) { + workspace = r.qwenSettings(filepath.Join(project, ".qwen", "settings.json")) + } else { + r.result("project settings", "EXCLUDED", "workspace is untrusted or equals the home directory", false) + } + layers := []map[string]any{defaults, user, workspace, system} + disabled := false + var sandbox any + for _, layer := range []map[string]any{defaults, user, system} { + if value := doctorSetting(layer, "tools", "executionSandbox"); value != nil { + sandbox = value + } + } + for _, layer := range layers { + if value, exists := layer["disableAllHooks"]; exists { + if flag, ok := value.(bool); ok { + disabled = flag + } else { + r.result("hook activation", "UNVERIFIED", "unsupported disableAllHooks value", true) + } + } + } + if sandbox != nil && sandbox != false { + r.result("execution sandbox", "UNVERIFIED", "configured sandbox can disable native hooks; verify the effective session mode", true) + } + if disabled { + r.result("hook activation", "DISABLED", "effective disableAllHooks is true", true) + } + found := false + var deferred bytes.Buffer + hookReport := &readinessReport{out: &deferred} + definitions := make(map[string][]byte) + for _, layer := range layers { + value := doctorSetting(layer, "hooks", "PreToolUse") + if value == nil { + continue + } + groups, ok := value.([]any) + if !ok { + r.result("PreToolUse", "INVALID", "hook groups must be an array", true) + continue + } + for _, raw := range groups { + group, ok := raw.(map[string]any) + if !ok { + r.result("PreToolUse", "INVALID", "hook group must be an object", true) + continue + } + match, known := qwenShellMatcher(group["matcher"]) + if !known { + hookReport.result("shell matcher", "UNVERIFIED", "inspect this JavaScript matcher in Qwen /hooks", true) + } + if known && !match { + continue + } + hooks, ok := group["hooks"].([]any) + if !ok { + r.result("PreToolUse", "INVALID", "handlers must be an array", true) + continue + } + for _, value := range hooks { + hook, ok := value.(map[string]any) + if !ok || hook["type"] != "command" { + continue + } + // Native deduplication ignores options such as async and env. + // Conflicting definitions cannot establish a blocking route here. + command, ok := hook["command"].(string) + if !ok { + r.result("hook command", "INVALID", "expected a command string", true) + continue + } + name, _ := hook["name"].(string) + if value := hook["name"]; value != nil { + if _, ok := value.(string); !ok { + r.result("hook name", "INVALID", "expected a string", true) + continue + } + } + identity := command + if name != "" { + identity = name + ":" + command + } + // Native treats an absent, null or empty name identically. + if name == "" { + delete(hook, "name") + } + definition, _ := json.Marshal(hook) + if previous, exists := definitions[identity]; exists && !bytes.Equal(previous, definition) { + r.result("hook precedence", "UNVERIFIED", "duplicate hook identity has conflicting options; inspect the effective handler in Qwen", true) + } + definitions[identity] = definition + if !known { + continue + } + if value := hook["async"]; value != nil { + if _, ok := value.(bool); !ok { + r.result("shell hook", "INVALID", "async must be a boolean", true) + continue + } + } + if hook["async"] == true { + r.result("shell hook", "ASYNCHRONOUS", "an asynchronous hook cannot block a shell action", false) + continue + } + var detail bytes.Buffer + candidate := &readinessReport{out: &detail} + valid := candidate.qwenCommand(command, hook["shell"]) + if env, exists := hook["env"]; exists { + values, ok := env.(map[string]any) + if !ok || len(values) != 0 { + candidate.result("hook environment", "UNVERIFIED", "per-hook overrides may change the command or runtime; inspect them in Qwen", true) + valid = false + } + } + if valid { + _, _ = r.out.Write(detail.Bytes()) + found = true + } else { + _, _ = deferred.Write(detail.Bytes()) + hookReport.issues += candidate.issues + } + } + } + } + if !found { + _, _ = r.out.Write(deferred.Bytes()) + r.issues += hookReport.issues + r.result("shell hook", "MISSING", "no inspected synchronous Pitot shell bridge; run pitot init --host qwen for setup", true) + } else if deferred.Len() != 0 { + r.result("additional hooks", "UNVERIFIED", "other hooks were not classified; the inspected Pitot route is present", false) + } + r.result("session activation", "UNVERIFIED", "inspect /hooks in Qwen; IDE trust, .env bootstrap, safe/bare modes, sandbox and session toggles can change activation", false) + r.selectedRuntime() + return r.finish() +} diff --git a/cmd/pitot/doctor_readiness.go b/cmd/pitot/doctor_readiness.go new file mode 100644 index 0000000..ea0bbd8 --- /dev/null +++ b/cmd/pitot/doctor_readiness.go @@ -0,0 +1,114 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "time" + + pitotruntime "github.com/operatorstack/pitot/runtime" +) + +// A component can pass its static check without proving that an agent session +// actually called a Controller. Keep those two results separate in the report. +type readinessReport struct { + out io.Writer + issues int +} + +func (r *readinessReport) result(component, status, detail string, issue bool) { + fmt.Fprintf(r.out, " %s: %s — %s\n", component, status, detail) + if issue { + r.issues++ + } +} + +func (r *readinessReport) binary(name string) string { + return r.lookup("binary "+name, name) +} + +func (r *readinessReport) lookup(component, name string) string { + path, err := doctorLookPath(name) + if err != nil { + r.result(component, "MISSING", "install the executable or add it to PATH", true) + return "" + } + r.result(component, "FOUND", path, false) + return path +} + +func (r *readinessReport) file(component, path string, executable bool) bool { + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + r.result(component, "MISSING", "a readable regular file is required", true) + return false + } + if executable && runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { + r.result(component, "UNUSABLE", "the file is not executable", true) + return false + } + r.result(component, "FOUND", filepath.Clean(path), false) + return true +} + +func (r *readinessReport) selectedRuntime() { + path := os.Getenv("PITOT_RUNTIME") + if path == "" { + r.result("runtime", "NOT SELECTED", "hooks observe only until a runtime is selected; use pitot run or pitot dev", false) + return + } + client, err := pitotruntime.OpenClient(path) + if err != nil { + // Descriptor parse errors can contain configuration values. Report the + // category without printing descriptor bytes or capability tokens. + r.result("runtime", "INVALID", "selected descriptor is missing, unreadable, insecure or malformed", true) + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := client.LocalHealth(ctx); err != nil { + r.result("runtime", "UNAVAILABLE", "local identity check failed; the descriptor may be stale or its endpoint invalid", true) + return + } + r.result("runtime", "HEALTHY", "local runtime identity matched; Controller availability was not checked", false) +} + +func (r *readinessReport) finish() error { + r.result("live enforcement", "UNVERIFIED", "static inspection does not execute agent hooks or Controller actions", false) + if r.issues != 0 { + return fmt.Errorf("pitot doctor: static setup check found %d issue(s)", r.issues) + } + r.result("static setup", "PASS", "the inspected configuration and dependencies passed", false) + return nil +} + +// readDoctorFile caps configuration reads and avoids including parser errors or +// raw settings in user-facing diagnostics. +func readDoctorFile(path string) ([]byte, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, errors.New("configuration must be a regular file") + } + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + info, err = file.Stat() + if err != nil || !info.Mode().IsRegular() { + return nil, errors.New("configuration must be a regular file") + } + const limit = 1 << 20 + data, err := io.ReadAll(io.LimitReader(file, limit+1)) + if err != nil || len(data) > limit { + return nil, errors.New("configuration could not be read within the size limit") + } + return data, nil +} diff --git a/cmd/pitot/doctor_readiness_test.go b/cmd/pitot/doctor_readiness_test.go new file mode 100644 index 0000000..e956814 --- /dev/null +++ b/cmd/pitot/doctor_readiness_test.go @@ -0,0 +1,467 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + deepseek "github.com/operatorstack/pitot/integrations/deepseek" + qwen "github.com/operatorstack/pitot/integrations/qwen" + "github.com/operatorstack/pitot/wiring" +) + +func priorityDoctorFixture(t *testing.T) (home, project string) { + t.Helper() + root := t.TempDir() + home, project = filepath.Join(root, "home"), filepath.Join(root, "project") + for _, p := range []string{home, project} { + if err := os.MkdirAll(p, 0o700); err != nil { + t.Fatal(err) + } + } + t.Chdir(project) + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("CODEX_HOME", filepath.Join(home, ".codex")) + t.Setenv("QWEN_HOME", filepath.Join(home, ".qwen")) + t.Setenv("QWEN_CODE_SYSTEM_SETTINGS_PATH", filepath.Join(root, "system", "settings.json")) + t.Setenv("QWEN_CODE_SYSTEM_DEFAULTS_PATH", filepath.Join(root, "system", "system-defaults.json")) + t.Setenv("QWEN_CODE_TRUSTED_FOLDERS_PATH", filepath.Join(home, ".qwen", "trustedFolders.json")) + t.Setenv("PITOT_RUNTIME", "") + t.Setenv("PITOT_BIN", "") + t.Setenv("MSYSTEM", "") + t.Setenv("SHELL", "") + originalLookup, originalSystem := doctorLookPath, doctorCodexSystemDirectory + doctorLookPath = func(string) (string, error) { return os.Args[0], nil } + doctorCodexSystemDirectory = func() string { return filepath.Join(root, "codex-system") } + t.Cleanup(func() { doctorLookPath, doctorCodexSystemDirectory = originalLookup, originalSystem }) + return home, project +} + +func writeDoctorFixture(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } +} + +func writeDoctorJSON(t *testing.T, path string, value any) { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + writeDoctorFixture(t, path, data) +} + +func qwenFixtureSettings(t *testing.T, home string) map[string]any { + t.Helper() + bridge := filepath.Join(home, "hook scripts", "PreToolUse.cjs") + writeDoctorFixture(t, bridge, []byte(qwen.NodeBridge)) + return map[string]any{"hooks": map[string]any{"PreToolUse": []any{map[string]any{ + "matcher": "^run_shell_command$", "hooks": []any{map[string]any{"type": "command", "command": `node "` + bridge + `"`}}, + }}}} +} + +func TestDoctorQwenStaticCases(t *testing.T) { + for _, scenario := range []string{"configured", "absent", "malformed", "disabled", "reserved-enabled", "async", "wrong-matcher", "foreign-hook", "env-override", "sandbox", "missing-node", "dynamic-command", "missing-runtime"} { + t.Run(scenario, func(t *testing.T) { + home, _ := priorityDoctorFixture(t) + settings := qwenFixtureSettings(t, home) + hooks := settings["hooks"].(map[string]any) + group := hooks["PreToolUse"].([]any)[0].(map[string]any) + hook := group["hooks"].([]any)[0].(map[string]any) + wantPass := scenario == "configured" || scenario == "reserved-enabled" || scenario == "foreign-hook" + switch scenario { + case "disabled": + settings["disableAllHooks"] = true + case "reserved-enabled": + hooks["enabled"] = false + case "async": + hook["async"] = true + case "wrong-matcher": + group["matcher"] = "read_file|" + case "foreign-hook": + group["hooks"] = append(group["hooks"].([]any), map[string]any{"type": "command", "command": "echo audit"}) + case "env-override": + hook["env"] = map[string]any{"PITOT_BIN": "private-sentinel-secret"} + case "sandbox": + settings["tools"] = map[string]any{"executionSandbox": map[string]any{"enabled": true}} + case "missing-node": + doctorLookPath = func(name string) (string, error) { + if name == "node" { + return "", exec.ErrNotFound + } + return os.Args[0], nil + } + case "dynamic-command": + hook["command"] = `echo "private-sentinel-secret"; pitot hook qwen` + case "missing-runtime": + t.Setenv("PITOT_RUNTIME", filepath.Join(home, "absent-runtime.json")) + } + path := filepath.Join(home, ".qwen", "settings.json") + if scenario != "absent" { + writeDoctorJSON(t, path, settings) + } + if scenario == "malformed" { + writeDoctorFixture(t, path, []byte(`{"private-sentinel-secret":`)) + } + before, _ := os.ReadFile(path) + var out bytes.Buffer + err := doctorQwen(&out) + if (err == nil) != wantPass { + t.Fatalf("error=%v output=%s", err, out.String()) + } + after, _ := os.ReadFile(path) + if !bytes.Equal(before, after) || strings.Contains(out.String(), "private-sentinel-secret") || strings.Contains(out.String(), "READY") { + t.Fatalf("mutation, secret leak or false readiness: %s", out.String()) + } + if !strings.Contains(out.String(), "live enforcement: UNVERIFIED") { + t.Fatal("missing live enforcement boundary") + } + }) + } +} + +func TestDoctorQwenSettingsPrecedenceAndTrust(t *testing.T) { + for _, scenario := range []string{"system-disable", "workspace-enable", "untrusted", "deeper-deny", "home-root"} { + t.Run(scenario, func(t *testing.T) { + home, project := priorityDoctorFixture(t) + settings := qwenFixtureSettings(t, home) + settings["disableAllHooks"] = false + writeDoctorJSON(t, filepath.Join(project, ".qwen", "settings.json"), settings) + user := map[string]any{"disableAllHooks": true} + if scenario == "untrusted" || scenario == "deeper-deny" { + user["security"] = map[string]any{"folderTrust": map[string]any{"enabled": true}} + } + writeDoctorJSON(t, filepath.Join(home, ".qwen", "settings.json"), user) + if scenario == "system-disable" { + writeDoctorJSON(t, os.Getenv("QWEN_CODE_SYSTEM_SETTINGS_PATH"), map[string]any{"disableAllHooks": true}) + } + if scenario == "deeper-deny" { + writeDoctorJSON(t, os.Getenv("QWEN_CODE_TRUSTED_FOLDERS_PATH"), map[string]any{filepath.Dir(project): "TRUST_FOLDER", project: "DO_NOT_TRUST"}) + } + if scenario == "home-root" { + t.Chdir(home) + t.Setenv("QWEN_HOME", filepath.Join(home, "different-qwen-home")) + writeDoctorJSON(t, filepath.Join(home, ".qwen", "settings.json"), settings) + } + var out bytes.Buffer + err := doctorQwen(&out) + if (err == nil) != (scenario == "workspace-enable") { + t.Fatalf("error=%v output=%s", err, out.String()) + } + }) + } +} + +func TestDoctorJSONAndMatcherBoundaries(t *testing.T) { + for _, data := range []string{"\ufeff{/* comment */\"value\":\"https://host/*literal*/\"// comment\n}", "{\"value\":\"escaped\\\"//literal\"}"} { + if _, err := doctorJSON([]byte(data)); err != nil { + t.Fatal(err) + } + } + for _, data := range []string{"null", "[]", "{/* unfinished", "{\"x\":1,}"} { + if _, err := doctorJSON([]byte(data)); err == nil { + t.Fatalf("invalid JSON accepted: %s", data) + } + } + for _, pattern := range []string{"read_file|", "^Bash$", "read_file"} { + if match, _ := qwenShellMatcher(pattern); match { + t.Fatalf("unrelated matcher accepted: %s", pattern) + } + } + for _, pattern := range []string{" ^run_shell_command$ ", "Bash", "read_file|run_shell_command", "*"} { + if match, known := qwenShellMatcher(pattern); !match || !known { + t.Fatalf("supported matcher rejected: %s", pattern) + } + } +} + +func codexFixture(t *testing.T, project string) { + t.Helper() + fragment, err := wiring.Fragment("codex") + if err != nil { + t.Fatal(err) + } + writeDoctorFixture(t, filepath.Join(project, wiring.FragmentPath("codex")), fragment) + writeDoctorJSON(t, filepath.Join(project, wiring.ConfigPath("codex")), map[string]any{"hooks": map[string]any{"PreToolUse": []any{wiring.CanonicalEntry("codex")}}}) + writeDoctorFixture(t, filepath.Join(project, ".pitot", "version"), []byte("0.1.0\n")) + path, content := filepath.Join(project, shimDir, "pitot"), shimPOSIX + if runtime.GOOS == "windows" { + path, content = filepath.Join(project, shimDir, "pitot.ps1"), shimPowerShell + } + writeDoctorFixture(t, path, []byte(content)) + if err := os.Chmod(path, 0o700); err != nil { + t.Fatal(err) + } +} + +func TestDoctorCodexStaticCases(t *testing.T) { + for _, scenario := range []string{"configured", "missing-binary", "missing-shim", "disabled", "managed-only", "malformed", "hook-disabled", "profile", "alias-disable", "ancestor", "stored-trust", "invalid-state"} { + t.Run(scenario, func(t *testing.T) { + home, project := priorityDoctorFixture(t) + codexFixture(t, project) + user := "" + switch scenario { + case "missing-binary": + doctorLookPath = func(string) (string, error) { return "", exec.ErrNotFound } + case "missing-shim": + if err := os.RemoveAll(filepath.Join(project, shimDir)); err != nil { + t.Fatal(err) + } + case "disabled": + user = "[features]\nhooks=false\n" + case "managed-only": + writeDoctorFixture(t, filepath.Join(doctorCodexSystemDirectory(), "requirements.toml"), []byte("allow_managed_hooks_only=true\n")) + case "malformed": + user = "private-sentinel-secret = [" + case "hook-disabled": + key := filepath.Join(project, wiring.ConfigPath("codex")) + ":pre_tool_use:0:0" + user = "[hooks.state.'" + key + "']\nenabled=false\n" + case "alias-disable": + writeDoctorFixture(t, filepath.Join(doctorCodexSystemDirectory(), "config.toml"), []byte("[features]\nhooks=false\n")) + user = "[features]\ncodex_hooks=true\n" + case "ancestor": + writeDoctorFixture(t, filepath.Join(filepath.Dir(project), ".git"), nil) + writeDoctorFixture(t, filepath.Join(filepath.Dir(project), ".codex", "config.toml"), []byte("[features]\nhooks=false\n")) + case "stored-trust", "invalid-state": + key := filepath.Join(project, wiring.ConfigPath("codex")) + ":pre_tool_use:0:0" + user = "[hooks.state.'" + key + "']\ntrusted_hash='stale'\n" + if scenario == "invalid-state" { + user += "enabled='false'\n" + } + case "profile": + user = "profile='custom'\n" + } + path := filepath.Join(home, ".codex", "config.toml") + writeDoctorFixture(t, path, []byte(user)) + var out bytes.Buffer + err := doctorCodex(&out) + if (err == nil) != (scenario == "configured") { + t.Fatalf("error=%v output=%s", err, out.String()) + } + after, _ := os.ReadFile(path) + if string(after) != user || strings.Contains(out.String(), "private-sentinel-secret") || strings.Contains(out.String(), "READY") { + t.Fatalf("mutation, secret leak or false readiness: %s", out.String()) + } + }) + } +} + +func TestDoctorDeepSeekCollectsIndependentFailures(t *testing.T) { + priorityDoctorFixture(t) + doctorLookPath = func(string) (string, error) { return "", exec.ErrNotFound } + var out bytes.Buffer + if err := doctorDeepSeek(&out); err == nil || !strings.Contains(out.String(), "binary dsh: MISSING") || !strings.Contains(out.String(), "native shell plugin: INVALID") { + t.Fatalf("independent failures missing: error=%v output=%s", err, out.String()) + } +} + +func TestDoctorQwenConflictingHookDefinitions(t *testing.T) { + for _, scenario := range []string{"async", "env", "sandbox", "empty-name", "unknown-matcher"} { + t.Run(scenario, func(t *testing.T) { + home, project := priorityDoctorFixture(t) + user := qwenFixtureSettings(t, home) + workspace := qwenFixtureSettings(t, home) + hook := workspace["hooks"].(map[string]any)["PreToolUse"].([]any)[0].(map[string]any)["hooks"].([]any)[0].(map[string]any) + switch scenario { + case "async": + hook["async"] = true + case "empty-name": + hook["async"] = true + hook["name"] = "" + case "unknown-matcher": + hook["async"] = true + workspace["hooks"].(map[string]any)["PreToolUse"].([]any)[0].(map[string]any)["matcher"] = "^run_.*$" + case "env": + hook["env"] = map[string]any{"PITOT_BIN": "unavailable"} + case "sandbox": + user["tools"] = map[string]any{"executionSandbox": true} + workspace["tools"] = map[string]any{"executionSandbox": false} + } + writeDoctorJSON(t, filepath.Join(home, ".qwen", "settings.json"), user) + writeDoctorJSON(t, filepath.Join(project, ".qwen", "settings.json"), workspace) + var out bytes.Buffer + if err := doctorQwen(&out); err == nil { + t.Fatalf("false static pass: %s", out.String()) + } + }) + } +} + +func TestDoctorSelectedRuntime(t *testing.T) { + for _, scenario := range []string{"unset", "healthy", "malformed"} { + t.Run(scenario, func(t *testing.T) { + home, _ := priorityDoctorFixture(t) + switch scenario { + case "healthy": + path, _, _ := readinessFixture(t, false) + t.Setenv("PITOT_RUNTIME", path) + case "malformed": + path := filepath.Join(home, "runtime.json") + writeDoctorFixture(t, path, []byte(`{"token":"private-sentinel-secret"`)) + t.Setenv("PITOT_RUNTIME", path) + } + var out bytes.Buffer + report := &readinessReport{out: &out} + report.selectedRuntime() + if (report.issues != 0) != (scenario == "malformed") { + t.Fatalf("unexpected issues: %s", out.String()) + } + if strings.Contains(out.String(), "private-sentinel-secret") { + t.Fatal("secret disclosed") + } + if scenario == "healthy" && !strings.Contains(out.String(), "Controller availability was not checked") { + t.Fatalf("missing scope: %s", out.String()) + } + }) + } +} + +func TestDoctorCodexExcludesConfigurationAboveRepository(t *testing.T) { + home, project := priorityDoctorFixture(t) + codexFixture(t, project) + writeDoctorFixture(t, filepath.Join(project, ".git"), nil) + writeDoctorFixture(t, filepath.Join(filepath.Dir(project), ".codex", "config.toml"), []byte("[features]\nhooks=false\n")) + writeDoctorFixture(t, filepath.Join(home, ".codex", "config.toml"), []byte("[features]\nhooks=true\n")) + var out bytes.Buffer + if err := doctorCodex(&out); err != nil { + t.Fatalf("unexpected failure: %v\n%s", err, out.String()) + } +} + +func doctorTree(t *testing.T, root string) map[string]string { + t.Helper() + result := map[string]string{} + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + result[path] = string(data) + return nil + }) + if err != nil { + t.Fatal(err) + } + return result +} + +func TestDoctorReadOnlyAndExplicitFix(t *testing.T) { + home, project := priorityDoctorFixture(t) + codexFixture(t, project) + settings := qwenFixtureSettings(t, home) + sentinel := filepath.Join(project, "hook-was-executed") + group := settings["hooks"].(map[string]any)["PreToolUse"].([]any)[0].(map[string]any) + group["hooks"] = append(group["hooks"].([]any), map[string]any{"type": "command", "command": `echo secret > "` + sentinel + `"`}) + writeDoctorJSON(t, filepath.Join(home, ".qwen", "settings.json"), settings) + before := doctorTree(t, filepath.Dir(project)) + var out bytes.Buffer + if err := doctorQwen(&out); err != nil { + t.Fatalf("Qwen: %v\n%s", err, out.String()) + } + if err := doctorCodex(&out); err != nil { + t.Fatalf("Codex: %v\n%s", err, out.String()) + } + if !reflect.DeepEqual(before, doctorTree(t, filepath.Dir(project))) { + t.Fatal("plain doctor modified configuration or executed a hook") + } + hooksPath := filepath.Join(project, wiring.ConfigPath("codex")) + writeDoctorJSON(t, hooksPath, map[string]any{"theme": "foreign-setting", "hooks": map[string]any{"PreToolUse": []any{map[string]any{"hooks": []any{map[string]any{"type": "command", "command": "foreign-command"}}}}}}) + doctorLookPath = func(string) (string, error) { return "", exec.ErrNotFound } + if err := doctor([]string{"--host", "codex", "--fix"}, &out, &out); err == nil { + t.Fatal("repair masked missing executable") + } + state, err := wiring.Check(project, "codex") + if err != nil || state != wiring.StateFound { + t.Fatalf("repair did not restore wiring: %s %v", state, err) + } + data, err := os.ReadFile(hooksPath) + if err != nil || !bytes.Contains(data, []byte("foreign-command")) || !bytes.Contains(data, []byte("foreign-setting")) { + t.Fatal("repair removed foreign settings") + } + if !strings.Contains(out.String(), "checking static setup separately") { + t.Fatal("repair was not distinguished from verification") + } +} + +func TestDoctorDeepSeekVersionAndPlugin(t *testing.T) { + // This executable is a local metadata fixture, not a real agent or model. + root := t.TempDir() + source := filepath.Join(root, "version.go") + body := fmt.Sprintf(`package main +import("fmt";"os";"strings") +func main(){if len(os.Args)!=2 || os.Args[1]!="--version" {os.Exit(3)}; if strings.Contains(os.Args[0],"bad-version") {fmt.Fprintln(os.Stderr,"private-sentinel-secret");os.Exit(2)};fmt.Println(%q)} +`, deepseek.Version) + writeDoctorFixture(t, source, []byte(body)) + program := filepath.Join(root, "dsh-fixture") + if runtime.GOOS == "windows" { + program += ".exe" + } + cmd := exec.Command("go", "build", "-o", program, source) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build metadata fixture: %v\n%s", err, output) + } + for _, bad := range []bool{false, true} { + t.Run(fmt.Sprint(bad), func(t *testing.T) { + _, project := priorityDoctorFixture(t) + selected := program + if bad { + selected = filepath.Join(root, "bad-version"+filepath.Ext(program)) + data, err := os.ReadFile(program) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(selected, data, 0o700); err != nil { + t.Fatal(err) + } + } else { + for path, data := range deepSeekOwnedFiles() { + writeDoctorFixture(t, filepath.Join(project, filepath.FromSlash(path)), data) + } + } + doctorLookPath = func(string) (string, error) { return selected, nil } + before := doctorTree(t, filepath.Dir(project)) + var out bytes.Buffer + err := doctorDeepSeek(&out) + if (err != nil) != bad { + t.Fatalf("unexpected diagnostic: %v\n%s", err, out.String()) + } + if bad && (!strings.Contains(out.String(), "DeepSeek version: UNVERIFIED") || !strings.Contains(out.String(), "native shell plugin: INVALID")) { + t.Fatal("independent failures hidden") + } + if strings.Contains(out.String(), "private-sentinel-secret") || !reflect.DeepEqual(before, doctorTree(t, filepath.Dir(project))) { + t.Fatal("secret disclosure or profile mutation") + } + }) + } +} + +func TestDoctorQwenRetainsHookLayers(t *testing.T) { + home, _ := priorityDoctorFixture(t) + defaults := qwenFixtureSettings(t, home) + writeDoctorJSON(t, os.Getenv("QWEN_CODE_SYSTEM_DEFAULTS_PATH"), defaults) + writeDoctorJSON(t, os.Getenv("QWEN_CODE_SYSTEM_SETTINGS_PATH"), map[string]any{"hooks": map[string]any{"PreToolUse": []any{map[string]any{"matcher": "read_file", "hooks": []any{map[string]any{"type": "command", "command": "foreign"}}}}}}) + var out bytes.Buffer + if err := doctorQwen(&out); err != nil { + t.Fatalf("system hooks replaced default route: %v\n%s", err, out.String()) + } +} diff --git a/cmd/pitot/main.go b/cmd/pitot/main.go index 5041a7f..f538297 100644 --- a/cmd/pitot/main.go +++ b/cmd/pitot/main.go @@ -246,7 +246,14 @@ func doctor(args []string, stdout, stderr io.Writer) error { if fix { // The only mutation doctor ever performs, and only on request: // restore Pitot's own marked entries (foreign config untouched). - return runWireHost(host, true, stdout) + if err := runWireHost(host, true, stdout); err != nil { + return err + } + if host == "codex" || host == "qwen" || host == "deepseek" { + fmt.Fprintln(stdout, "Setup step finished; checking static setup separately:") + return doctorHost(adapters.Host(host), stdout, stderr) + } + return nil } return doctorHost(adapters.Host(host), stdout, stderr) } diff --git a/integrations/qwen/bridge.go b/integrations/qwen/bridge.go new file mode 100644 index 0000000..3899d59 --- /dev/null +++ b/integrations/qwen/bridge.go @@ -0,0 +1,14 @@ +// Package qwen exposes the shipped hook scripts for read-only setup checks. +package qwen + +import _ "embed" + +// BashBridge is the shell hook installed on macOS and Linux. +// +//go:embed PreToolUse +var BashBridge string + +// NodeBridge is the portable hook installed with Node.js. +// +//go:embed PreToolUse.cjs +var NodeBridge string diff --git a/runtime/local_health_test.go b/runtime/local_health_test.go new file mode 100644 index 0000000..acd0d09 --- /dev/null +++ b/runtime/local_health_test.go @@ -0,0 +1,83 @@ +package runtime + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +func TestLocalHealthRejectsNonlocalDescriptorsBeforeContact(t *testing.T) { + for _, endpoint := range []string{ + "http://example.invalid:80", "http://localhost:80", "https://127.0.0.1:80", + "http://user:secret@127.0.0.1:80", "http://127.0.0.1:80/path", + "http://127.0.0.1:80?secret=value", "http://127.0.0.1:80?", + "http://127.0.0.1:80#fragment", "http://[::1]:80", "http://127.0.0.1:0", + "http://127.0.0.1:65536", "http://127.0.0.1:http", + } { + t.Run(endpoint, func(t *testing.T) { + client := &Client{descriptor: Descriptor{Endpoint: endpoint, Token: "private"}} + if err := client.LocalHealth(context.Background()); err == nil { + t.Fatal("unsafe endpoint accepted") + } + }) + } +} + +func TestLocalHealthRejectsRedirectAndBypassesProxy(t *testing.T) { + var contacts atomic.Int32 + trap := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + contacts.Add(1) + w.WriteHeader(http.StatusOK) + })) + defer trap.Close() + t.Setenv("HTTP_PROXY", trap.URL) + t.Setenv("HTTPS_PROXY", trap.URL) + t.Setenv("ALL_PROXY", trap.URL) + t.Setenv("NO_PROXY", "") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, trap.URL, http.StatusFound) + })) + defer server.Close() + client := &Client{descriptor: Descriptor{Endpoint: server.URL, Token: "private"}} + if err := client.LocalHealth(context.Background()); err == nil { + t.Fatal("redirect accepted") + } + if contacts.Load() != 0 { + t.Fatal("probe contacted a redirect or proxy destination") + } +} + +func TestLocalHealthRequiresIdentityAndHonorsDeadline(t *testing.T) { + for _, scenario := range []string{"healthy", "wrong-identity", "timeout"} { + t.Run(scenario, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/health" || r.Method != http.MethodGet || r.Header.Get("Authorization") != "Bearer private" { + t.Error("unexpected health request") + w.WriteHeader(http.StatusUnauthorized) + return + } + if scenario == "timeout" { + <-r.Context().Done() + return + } + identity := "expected" + if scenario == "wrong-identity" { + identity = "another-runtime" + } + _ = json.NewEncoder(w).Encode(map[string]any{"schema_version": 1, "instance_id": identity}) + })) + defer server.Close() + client := &Client{descriptor: Descriptor{Endpoint: server.URL, Token: "private", InstanceID: "expected"}} + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + err := client.LocalHealth(ctx) + if (err == nil) != (scenario == "healthy") { + t.Fatalf("unexpected health result: %v", err) + } + }) + } +} diff --git a/runtime/transport.go b/runtime/transport.go index abfb636..554e6d8 100644 --- a/runtime/transport.go +++ b/runtime/transport.go @@ -16,6 +16,7 @@ import ( "net/url" "os" "path/filepath" + "strconv" "time" "github.com/operatorstack/pitot/bridge" @@ -248,6 +249,33 @@ func (c *Client) Health(ctx context.Context) error { return nil } +// LocalHealth checks runtime identity without allowing a descriptor to send its +// capability to a remote address, proxy, or redirect. It does not check whether +// a Controller is registered or whether a host hook reaches that Controller. +func (c *Client) LocalHealth(ctx context.Context) error { + endpoint, err := url.Parse(c.descriptor.Endpoint) + if err != nil || endpoint.Scheme != "http" || endpoint.User != nil || endpoint.Path != "" || endpoint.RawQuery != "" || endpoint.ForceQuery || endpoint.Fragment != "" || endpoint.Opaque != "" { + return errors.New("pitot: runtime health requires a literal loopback endpoint") + } + host, port, err := net.SplitHostPort(endpoint.Host) + if err != nil || host != "127.0.0.1" || port == "" { + return errors.New("pitot: runtime health requires a literal loopback endpoint") + } + portNumber, err := strconv.Atoi(port) + if err != nil || portNumber < 1 || portNumber > 65535 { + return errors.New("pitot: runtime health requires a valid port") + } + transport := &http.Transport{} + defer transport.CloseIdleConnections() + probe := &Client{descriptor: c.descriptor, http: &http.Client{ + Transport: transport, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }} + return probe.Health(ctx) +} + // DeliverEvent returns nil when no Controller is registered for the action kind. func (c *Client) DeliverEvent(ctx context.Context, event schema.Event) (*schema.ControlResponse, error) { return c.deliverEvent(ctx, event, false)