diff --git a/docs/AI.md b/docs/AI.md index 968c0b092..520ac9525 100644 --- a/docs/AI.md +++ b/docs/AI.md @@ -420,6 +420,23 @@ does not forward API-key environment variables such as `OPENAI_API_KEY` or `ANTHROPIC_API_KEY`, Pulse secrets, cloud credentials, or unrelated tokens, preventing an installed API key from silently changing the billing route. +On a standard Linux systemd install, Pulse runs as the `pulse` account with +`HOME=/opt/pulse`. Install the selected CLI for that account (its supported +user-local binary directory is `/opt/pulse/.local/bin`) and complete the login +as that account; a CLI installed or authenticated only as `root` is +intentionally unavailable to Pulse. For Claude, verify the service identity +with: + +```bash +sudo -u pulse env HOME=/opt/pulse PATH=/opt/pulse/.local/bin:/usr/local/bin:/usr/bin:/bin claude auth status --json +``` + +If it is not logged in, run `claude auth login` with the same `sudo -u pulse` +environment, restart `pulse.service`, and retry the provider test. Pulse also +accepts an absolute executable override through `PULSE_CLAUDE_CLI_PATH` or +`PULSE_CODEX_CLI_PATH` in `/etc/pulse/.env`; the target must remain executable +by the `pulse` account and its credentials must still belong to that account. + The child CLI is not given infrastructure authority. Each invocation runs in a new temporary directory, with user extensions disabled, no Pulse MCP server, no approval capability, and a structured output schema. It returns one proposed diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index a4c05db08..6761a4abf 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -431,6 +431,14 @@ refresh, export, or forward that login, and its child environment is an allowlist that excludes API keys, Pulse secrets, cloud credentials, and unrelated tokens. It must never fall back to an API-key provider. +CLI discovery is scoped to the Pulse service identity: the service `PATH`, +that identity's `.local/bin`, `bin`, and `.npm-global/bin`, or an explicit +absolute `PULSE_CLAUDE_CLI_PATH` / `PULSE_CODEX_CLI_PATH` override. It must not +search or expose another user's home. Missing executables and missing +first-party login are typed local-setup failures, not provider reachability +failures, and provider-test plus Patrol diagnostics must tell the operator to +install and authenticate the CLI as the account running Pulse (#1742). + Each subscription-agent call is a single structured provider turn in a fresh temporary working directory. Codex runs ephemeral with user configuration ignored and a read-only sandbox. Claude runs without session persistence or diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index e265408c1..20a80708a 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -3823,6 +3823,11 @@ the authoritative analysis outcome. listing must accept that keyless custom route without emitting an empty Authorization header. Custom model-list results retain every non-empty opaque ID and carry server-authored `provider: "openai"` identity. + Local subscription test and Patrol failures must distinguish missing CLI + installation or login for the Pulse service identity from network + reachability: the response keeps the existing bounded diagnostic envelope, + classifies the route as not configured, and returns service-account setup + remediation without raw CLI output (#1742). `remove_providers` is the complete provider lifecycle mutation: it removes provider-owned secrets, endpoints and runtime options, clears selected models for that provider, invalidates model inventory, and disables Pulse diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 385dbba06..6f6587fa6 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -2778,6 +2778,14 @@ not exist at all: the installer always writes the unit file itself, even where systemctl cannot run, so a missing unit is a broken installation rather than an unprivileged-container quirk and must fail the run loudly. +The generated `pulse.service` unit also owns the local subscription-agent +execution identity. It pins `HOME` to the Pulse install directory and prepends +that identity's `.local/bin` plus the install `bin` directory to `PATH`, while +retaining `User=pulse` and `ProtectHome=true`. This makes a Claude or Codex CLI +installed and authenticated for the Pulse account discoverable without +exposing a root or interactive user's home and lets updates migrate the unit +contract onto existing systemd installations (#1742). + Changes to the generated units must be able to reach already-deployed boxes. A box installed before the sandbox was widened runs the installer from a `pulse-update.service` whose `ReadWritePaths` excludes the helper and unit diff --git a/frontend-modern/public/docs/AI.md b/frontend-modern/public/docs/AI.md index 968c0b092..520ac9525 100644 --- a/frontend-modern/public/docs/AI.md +++ b/frontend-modern/public/docs/AI.md @@ -420,6 +420,23 @@ does not forward API-key environment variables such as `OPENAI_API_KEY` or `ANTHROPIC_API_KEY`, Pulse secrets, cloud credentials, or unrelated tokens, preventing an installed API key from silently changing the billing route. +On a standard Linux systemd install, Pulse runs as the `pulse` account with +`HOME=/opt/pulse`. Install the selected CLI for that account (its supported +user-local binary directory is `/opt/pulse/.local/bin`) and complete the login +as that account; a CLI installed or authenticated only as `root` is +intentionally unavailable to Pulse. For Claude, verify the service identity +with: + +```bash +sudo -u pulse env HOME=/opt/pulse PATH=/opt/pulse/.local/bin:/usr/local/bin:/usr/bin:/bin claude auth status --json +``` + +If it is not logged in, run `claude auth login` with the same `sudo -u pulse` +environment, restart `pulse.service`, and retry the provider test. Pulse also +accepts an absolute executable override through `PULSE_CLAUDE_CLI_PATH` or +`PULSE_CODEX_CLI_PATH` in `/etc/pulse/.env`; the target must remain executable +by the `pulse` account and its credentials must still belong to that account. + The child CLI is not given infrastructure authority. Each invocation runs in a new temporary directory, with user extensions disabled, no Pulse MCP server, no approval capability, and a structured output schema. It returns one proposed diff --git a/install.sh b/install.sh index cef87d6f2..2a15f5e3c 100755 --- a/install.sh +++ b/install.sh @@ -4513,7 +4513,8 @@ Restart=always RestartSec=3 StandardOutput=journal StandardError=journal -Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +Environment="HOME=$INSTALL_DIR" +Environment="PATH=$INSTALL_DIR/.local/bin:$INSTALL_DIR/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" Environment="PULSE_DATA_DIR=$CONFIG_DIR" Environment="PULSE_DEPLOYMENT_METHOD=systemd" EnvironmentFile=-$CONFIG_DIR/.env diff --git a/internal/ai/patrol_runtime_failure.go b/internal/ai/patrol_runtime_failure.go index 5dad572a0..eb9e0a909 100644 --- a/internal/ai/patrol_runtime_failure.go +++ b/internal/ai/patrol_runtime_failure.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" "github.com/rs/zerolog/log" ) @@ -153,6 +154,15 @@ func ClassifyPatrolRuntimeFailure(err error) PatrolRuntimeFailureDiagnostic { } func ClassifyProviderConnectionFailure(err error) PatrolRuntimeFailureDiagnostic { + if setup, ok := subscriptionAgentSetupFailure(err); ok { + return PatrolRuntimeFailureDiagnostic{ + Title: "Local " + setup.displayName + " CLI not ready", + Summary: "Local " + setup.displayName + " CLI not ready", + Cause: PatrolFailureCauseProviderNotConfigured, + Description: "Pulse cannot use the local " + setup.displayName + " subscription because its CLI executable or login is unavailable to the operating-system account running Pulse.", + Recommendation: setup.recommendation, + } + } failure := patrolRuntimeFailureFromError(err) diagnostic := PatrolRuntimeFailureDiagnostic{ Title: "Provider connection issue", @@ -223,6 +233,35 @@ func ClassifyProviderConnectionFailure(err error) PatrolRuntimeFailureDiagnostic return diagnostic } +type subscriptionAgentSetupCopy struct { + displayName string + recommendation string +} + +func subscriptionAgentSetupFailure(err error) (subscriptionAgentSetupCopy, bool) { + var setupErr *providers.SubscriptionAgentSetupError + if !errors.As(err, &setupErr) { + return subscriptionAgentSetupCopy{}, false + } + switch setupErr.Agent { + case providers.SubscriptionAgentClaude: + return subscriptionAgentSetupCopy{ + displayName: "Claude", + recommendation: "Install the Claude CLI and run `claude auth login` as the same account that runs Pulse. On standard systemd installs, use the `pulse` account with home `/opt/pulse`; restart Pulse, then retry.", + }, true + case providers.SubscriptionAgentCodex: + return subscriptionAgentSetupCopy{ + displayName: "Codex", + recommendation: "Install the Codex CLI and run `codex login` as the same account that runs Pulse. On standard systemd installs, use the `pulse` account with home `/opt/pulse`; restart Pulse, then retry.", + }, true + default: + return subscriptionAgentSetupCopy{ + displayName: "subscription", + recommendation: "Install and sign in to the local subscription CLI as the same operating-system account that runs Pulse, restart Pulse, then retry.", + }, true + } +} + // patrolRuntimeFailureFromError classifies an error with no knowledge of the // run context. Cancellation is then recognised only when the error actually // wraps context.Canceled. Callers that hold the run's context should use @@ -251,6 +290,7 @@ func patrolRuntimeFailureFromErrorCtx(ctx context.Context, err error) patrolRunt Detail: detail, } + setup, setupFailure := subscriptionAgentSetupFailure(err) switch { case cancelled: failure.Title = "Pulse Patrol: Analysis interrupted" @@ -258,6 +298,12 @@ func patrolRuntimeFailureFromErrorCtx(ctx context.Context, err error) patrolRunt failure.Cause = PatrolFailureCauseInterrupted failure.Description = "The Patrol run was cancelled before the provider finished, either by an operator cancel or because the client connection closed mid-analysis. An interrupted run is not evidence about the provider or model." failure.Recommendation = "Run the analysis again when you are ready. If you did not cancel it, check for reverse proxies or load balancers that close long-running requests." + case setupFailure: + failure.Title = "Pulse Patrol: Local " + setup.displayName + " CLI not ready" + failure.Summary = "Local " + setup.displayName + " CLI not ready" + failure.Cause = PatrolFailureCauseProviderNotConfigured + failure.Description = "Pulse Patrol cannot use the local " + setup.displayName + " subscription because its CLI executable or login is unavailable to the operating-system account running Pulse." + failure.Recommendation = setup.recommendation case patrolMalformedToolHistory(lower): failure.Title = "Pulse Patrol: Malformed tool-call conversation history" failure.Summary = "Malformed tool-call conversation history" diff --git a/internal/ai/patrol_runtime_failure_test.go b/internal/ai/patrol_runtime_failure_test.go index 30f2f8cb2..0980e7545 100644 --- a/internal/ai/patrol_runtime_failure_test.go +++ b/internal/ai/patrol_runtime_failure_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" "github.com/rcourtman/pulse-go-rewrite/internal/ai/tools" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" @@ -213,6 +214,28 @@ func TestClassifyProviderConnectionFailureUsesNeutralProviderCopy(t *testing.T) } } +func TestSubscriptionAgentSetupFailureUsesLocalServiceAccountRemediation(t *testing.T) { + err := &providers.SubscriptionAgentSetupError{ + Agent: providers.SubscriptionAgentClaude, + Issue: providers.SubscriptionAgentExecutableMissing, + } + diagnostic := ClassifyProviderConnectionFailure(err) + if diagnostic.Summary != "Local Claude CLI not ready" || diagnostic.Cause != PatrolFailureCauseProviderNotConfigured { + t.Fatalf("connection diagnostic = %#v", diagnostic) + } + if !strings.Contains(diagnostic.Recommendation, "`pulse` account") || !strings.Contains(diagnostic.Recommendation, "`claude auth login`") { + t.Fatalf("connection recommendation lacks service-account remediation: %q", diagnostic.Recommendation) + } + + failure := patrolRuntimeFailureFromError(err) + if failure.Summary != "Local Claude CLI not ready" || failure.Cause != PatrolFailureCauseProviderNotConfigured { + t.Fatalf("Patrol failure = %#v", failure) + } + if strings.Contains(failure.Description, "/root") || strings.Contains(failure.Recommendation, "/root") { + t.Fatalf("subscription setup copy encouraged sharing a privileged home: %#v", failure) + } +} + func TestClassifyProviderConnectionFailureKeepsSafeModelUnavailableCopy(t *testing.T) { diagnostic := ClassifyProviderConnectionFailure(errors.New(`connected to Ollama but model "qwen3.5:2b" is not available; found: qwen3.5:4b`)) diff --git a/internal/ai/providers/subscription_agent.go b/internal/ai/providers/subscription_agent.go index 576eac2fe..bea122398 100644 --- a/internal/ai/providers/subscription_agent.go +++ b/internal/ai/providers/subscription_agent.go @@ -22,6 +22,29 @@ import ( // continues to own and execute every infrastructure tool call. type SubscriptionAgent string +type SubscriptionAgentSetupIssue string + +const ( + SubscriptionAgentExecutableMissing SubscriptionAgentSetupIssue = "executable_missing" + SubscriptionAgentLoginMissing SubscriptionAgentSetupIssue = "login_missing" +) + +// SubscriptionAgentSetupError distinguishes local CLI installation/login +// faults from network provider failures. Callers can give operators the +// service-account remediation without exposing raw CLI output. +type SubscriptionAgentSetupError struct { + Agent SubscriptionAgent + Issue SubscriptionAgentSetupIssue +} + +func (e *SubscriptionAgentSetupError) Error() string { + name := subscriptionAgentCommandName(e.Agent) + if e.Issue == SubscriptionAgentLoginMissing { + return fmt.Sprintf("%s CLI is not signed in for the account running Pulse", name) + } + return fmt.Sprintf("%s CLI is not installed for the account running Pulse or is not on its service PATH", name) +} + const ( SubscriptionAgentCodex SubscriptionAgent = "codex-subscription" SubscriptionAgentClaude SubscriptionAgent = "claude-subscription" @@ -197,7 +220,7 @@ func (c *SubscriptionAgentClient) TestConnection(ctx context.Context) error { } if c.agent == SubscriptionAgentCodex { if !strings.Contains(strings.ToLower(string(out)), "logged in using chatgpt") { - return errors.New("Codex CLI is not signed in with ChatGPT") + return &SubscriptionAgentSetupError{Agent: SubscriptionAgentCodex, Issue: SubscriptionAgentLoginMissing} } return nil } @@ -209,7 +232,7 @@ func (c *SubscriptionAgentClient) TestConnection(ctx context.Context) error { return fmt.Errorf("decode Claude authentication status: %w", err) } if !status.LoggedIn || status.AuthMethod != "claude.ai" { - return errors.New("Claude CLI is not signed in with a Claude plan") + return &SubscriptionAgentSetupError{Agent: SubscriptionAgentClaude, Issue: SubscriptionAgentLoginMissing} } return nil } @@ -418,9 +441,9 @@ func (c *SubscriptionAgentClient) run(ctx context.Context, name string, args []s ctx, cancel = context.WithTimeout(ctx, c.timeout) defer cancel() } - path, err := exec.LookPath(name) + path, err := resolveSubscriptionAgentCommand(c.agent, name) if err != nil { - return nil, fmt.Errorf("%s CLI is not installed or not on PATH", name) + return nil, err } cmd := exec.CommandContext(ctx, path, args...) cmd.Env = subscriptionAgentEnvironment(os.Environ()) @@ -460,6 +483,64 @@ func (c *SubscriptionAgentClient) run(ctx context.Context, name string, args []s return stdout.buffer.Bytes(), nil } +func subscriptionAgentCommandName(agent SubscriptionAgent) string { + switch agent { + case SubscriptionAgentCodex: + return "codex" + case SubscriptionAgentClaude: + return "claude" + default: + return string(agent) + } +} + +func subscriptionAgentCommandOverride(agent SubscriptionAgent) string { + switch agent { + case SubscriptionAgentCodex: + return strings.TrimSpace(os.Getenv("PULSE_CODEX_CLI_PATH")) + case SubscriptionAgentClaude: + return strings.TrimSpace(os.Getenv("PULSE_CLAUDE_CLI_PATH")) + default: + return "" + } +} + +func resolveSubscriptionAgentCommand(agent SubscriptionAgent, name string) (string, error) { + if override := subscriptionAgentCommandOverride(agent); override != "" { + if path, ok := executableSubscriptionAgentPath(override); ok { + return path, nil + } + return "", &SubscriptionAgentSetupError{Agent: agent, Issue: SubscriptionAgentExecutableMissing} + } + if path, err := exec.LookPath(name); err == nil { + return path, nil + } + + home, _ := os.UserHomeDir() + for _, relative := range []string{ + filepath.Join(".local", "bin", name), + filepath.Join("bin", name), + filepath.Join(".npm-global", "bin", name), + } { + if path, ok := executableSubscriptionAgentPath(filepath.Join(home, relative)); ok { + return path, nil + } + } + return "", &SubscriptionAgentSetupError{Agent: agent, Issue: SubscriptionAgentExecutableMissing} +} + +func executableSubscriptionAgentPath(path string) (string, bool) { + path = strings.TrimSpace(path) + if path == "" || !filepath.IsAbs(path) { + return "", false + } + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0111 == 0 { + return "", false + } + return path, true +} + func subscriptionAgentEnvironment(environment []string) []string { allowed := map[string]bool{"HOME": true, "USER": true, "LOGNAME": true, "PATH": true, "TMPDIR": true, "SHELL": true, "LANG": true, "LC_ALL": true, "TERM": true, "CODEX_HOME": true, "CLAUDE_CONFIG_DIR": true, "XDG_CONFIG_HOME": true, "XDG_CACHE_HOME": true, "SSL_CERT_FILE": true, "SSL_CERT_DIR": true, "NO_PROXY": true, "no_proxy": true} out := make([]string, 0, len(allowed)) diff --git a/internal/ai/providers/subscription_agent_test.go b/internal/ai/providers/subscription_agent_test.go index 35e4e69b9..a978b1cdb 100644 --- a/internal/ai/providers/subscription_agent_test.go +++ b/internal/ai/providers/subscription_agent_test.go @@ -32,6 +32,52 @@ func TestSubscriptionAgentEnvironmentDoesNotForwardSecrets(t *testing.T) { } } +func TestResolveSubscriptionAgentCommandFindsServiceHomeLocalBin(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("service-home executable discovery uses POSIX executable bits") + } + home := t.TempDir() + binDir := filepath.Join(home, ".local", "bin") + if err := os.MkdirAll(binDir, 0700); err != nil { + t.Fatal(err) + } + claudePath := filepath.Join(binDir, "claude") + writeExecutable(t, claudePath, "#!/bin/sh\nexit 0\n") + t.Setenv("HOME", home) + t.Setenv("PATH", t.TempDir()) + + got, err := resolveSubscriptionAgentCommand(SubscriptionAgentClaude, "claude") + if err != nil || got != claudePath { + t.Fatalf("service-home Claude command = %q, %v; want %q", got, err, claudePath) + } +} + +func TestSubscriptionAgentConnectionReportsServiceAccountSetupFailures(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake subscription CLI uses a POSIX shell script") + } + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("PATH", t.TempDir()) + client := NewSubscriptionAgentClient(SubscriptionAgentClaude, "sonnet", subscriptionAgentTestDeadline(time.Second)) + + err := client.TestConnection(context.Background()) + var setupErr *SubscriptionAgentSetupError + if !errors.As(err, &setupErr) || setupErr.Agent != SubscriptionAgentClaude || setupErr.Issue != SubscriptionAgentExecutableMissing { + t.Fatalf("missing executable error = %#v, want Claude executable setup error", err) + } + + binDir := filepath.Join(home, ".local", "bin") + if err := os.MkdirAll(binDir, 0700); err != nil { + t.Fatal(err) + } + writeExecutable(t, filepath.Join(binDir, "claude"), "#!/bin/sh\nprintf '%s' '{\"loggedIn\":false,\"authMethod\":\"none\"}'\n") + err = client.TestConnection(context.Background()) + if !errors.As(err, &setupErr) || setupErr.Agent != SubscriptionAgentClaude || setupErr.Issue != SubscriptionAgentLoginMissing { + t.Fatalf("missing login error = %#v, want Claude login setup error", err) + } +} + func TestCappedBufferBoundsChildOutput(t *testing.T) { buffer := cappedBuffer{maxBytes: 4} if n, err := buffer.Write([]byte("abcdef")); err != nil || n != 6 { diff --git a/scripts/installtests/root_install_sh_test.go b/scripts/installtests/root_install_sh_test.go index 8c6aec813..fa4eedd4d 100644 --- a/scripts/installtests/root_install_sh_test.go +++ b/scripts/installtests/root_install_sh_test.go @@ -89,6 +89,23 @@ func TestRootInstallScriptArchiveSupportContract(t *testing.T) { } } +func TestRootInstallSystemdServiceExposesOnlyPulseOwnedSubscriptionCLIHome(t *testing.T) { + body := extractRootInstallShellFunction(t, "install_systemd_service") + for _, required := range []string{ + `Environment="HOME=$INSTALL_DIR"`, + `Environment="PATH=$INSTALL_DIR/.local/bin:$INSTALL_DIR/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"`, + `User=pulse`, + `ProtectHome=true`, + } { + if !strings.Contains(body, required) { + t.Fatalf("systemd service missing subscription CLI boundary %q", required) + } + } + if strings.Contains(body, "/root/") { + t.Fatal("systemd service must not expose a privileged home to local subscription CLIs") + } +} + func TestRootInstallScriptStagesUpdateBeforeStoppingService(t *testing.T) { downloadPulse := extractRootInstallShellFunction(t, "download_pulse") orderedSteps := []string{ diff --git a/scripts/release_control/ai_runtime_docs_policy_test.py b/scripts/release_control/ai_runtime_docs_policy_test.py index d44e25e6f..9634648f5 100644 --- a/scripts/release_control/ai_runtime_docs_policy_test.py +++ b/scripts/release_control/ai_runtime_docs_policy_test.py @@ -181,6 +181,11 @@ class AIRuntimeDocsPolicyTest(unittest.TestCase): self.assertIn("inference_route=coding_plan_allowance", content) self.assertIn("per-run monetary cost unknown", normalized_content) self.assertIn("standard Z.ai `/api/paas/` endpoint remains a `metered_api` route", normalized_content) + self.assertIn("Pulse runs as the `pulse` account with `HOME=/opt/pulse`", normalized_content) + self.assertIn("a CLI installed or authenticated only as `root` is intentionally unavailable", normalized_content) + self.assertIn("claude auth status --json", content) + self.assertIn("PULSE_CLAUDE_CLI_PATH", content) + self.assertIn("PULSE_CODEX_CLI_PATH", content) def test_public_ai_privacy_copy_discloses_outbound_usage_telemetry(self) -> None: content = read_repo_text("docs/AI.md")