Fix local subscription CLI service setup

This commit is contained in:
rcourtman
2026-08-23 09:28:44 +01:00
parent 3a9dffa850
commit 66eb537522
12 changed files with 279 additions and 5 deletions
+17
View File
@@ -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
@@ -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
@@ -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
@@ -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
+17
View File
@@ -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
+2 -1
View File
@@ -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
+46
View File
@@ -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"
@@ -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`))
+85 -4
View File
@@ -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))
@@ -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 {
@@ -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{
@@ -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")