feat: add fluid connect command and TUI connect wizard#76
Conversation
- Add `fluid connect <address>` CLI command: tests gRPC connection, runs doctor checks, and saves daemon to config - Add TUI /connect wizard with multi-step flow (address input, connecting, doctor checks, done) - Add Cancel() to AgentRunner interface and ESC key support to abort running agent - Add SetSandboxService() to hot-swap daemon connection after /connect - Extend readonly prepare/validate/shell in both cli and daemon - Update web docs: daemon-setup-steps, quickstart-steps, cli-reference Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ect.go Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… in connect.go Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…t.go Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR Review: feat: add fluid connect command and TUI connect wizardGood overall PR - the feature is well-scoped and the tests are updated correctly. A few issues worth addressing before merge: 🔴 Security:
|
There was a problem hiding this comment.
Pull request overview
Adds new “connect” flows (CLI subcommand + TUI wizard) to link the Fluid CLI to a running daemon, plus supporting docs/UX updates and some read-only/TLS-related enhancements.
Changes:
- Add
fluid connect <address>command to health-check a daemon, run doctor checks, and optionally persist daemon config. - Add a TUI
/connectwizard modal for guided connection + saving config, and improve live output/tool display. - Expand read-only tooling for TLS diagnostics (allow
opensslwith restricted subcommands) and improve host preparation (journal/log group access).
Reviewed changes
Copilot reviewed 23 out of 23 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| web/src/routes/docs/cli-reference.tsx | Documents new CLI entry points and TUI slash commands. |
| web/src/routes/_public/index.tsx | Updates public FAQ copy (adds “What is Fluid…” explanation). |
| web/src/routes/_public.tsx | Footer layout tweaks for better wrapping/responsiveness. |
| web/src/components/docs/quickstart-steps.tsx | Adds quickstart step describing fluid connect and /connect. |
| web/src/components/docs/daemon-setup-steps.tsx | Adds daemon setup step for connecting + --no-save tip. |
| scripts/haproxy-ssl-debug.sh | Adds a remote demo script for HAProxy SSL cert/key mismatch debugging. |
| lefthook.yaml | Adjusts gofumpt invocation path for pre-commit formatting. |
| fluid-daemon/internal/readonly/validate.go | Allowlists openssl and adds restricted subcommands. |
| fluid-daemon/internal/readonly/shell.go | Blocks additional dangerous openssl subcommands in restricted shell. |
| fluid-daemon/internal/readonly/prepare.go | Adds best-effort journal/log group membership for fluid-readonly user. |
| fluid-cli/internal/tui/onboarding.go | Updates onboarding completion copy to mention /connect. |
| fluid-cli/internal/tui/model.go | Adds connect wizard mode, ESC agent-cancel, live-output header improvements, and extra tool formatting. |
| fluid-cli/internal/tui/messages.go | Adds new message types (agent cancelled, sensitive redaction, connect close). |
| fluid-cli/internal/tui/connect.go | Implements the /connect multi-step wizard modal. |
| fluid-cli/internal/tui/agent_test.go | Adds tests for PEM private-key redaction behavior. |
| fluid-cli/internal/tui/agent.go | Adds ESC cancellation support, auto read-only “sticky” transitions, private key redaction, and live output for source reads. |
| fluid-cli/internal/readonly/validate_test.go | Adds tests for openssl allow/block behavior. |
| fluid-cli/internal/readonly/validate.go | Allowlists openssl and adds restricted subcommands. |
| fluid-cli/internal/readonly/shell.go | Blocks additional dangerous openssl subcommands in restricted shell script. |
| fluid-cli/internal/readonly/prepare_test.go | Updates prepare tests to account for new journal/log group step. |
| fluid-cli/internal/readonly/prepare.go | Adds best-effort journal/log group membership for fluid-readonly user. |
| fluid-cli/cmd/fluid/main.go | Adds fluid connect cobra subcommand and implementation. |
| fluid-cli/AGENTS.md | Updates dev docs for new /connect and CLI subcommand docs. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| question: 'What is Fluid and how does it work?', | ||
| answer: | ||
| 'Not unrestricted SSH access. Fluid creates a dedicated fluid-readonly user with a restricted login shell. A client-side allowlist validates every command against ~50 permitted read-only commands (cat, ls, grep, ps, journalctl, etc.) before it is even sent. Server-side, the restricted shell blocks 50+ destructive patterns - sudo, rm, mv, chmod, wget, python, bash - at the OS level. Command substitution ($(...), backticks), output redirection, and subshells are all blocked. Even if the AI constructs something creative, the shell will not execute it.', | ||
| 'Fluid is an AI agent built for working on Linux servers. It uses tools you already know like ssh, login shells, and Ansible playbooks to investigate Linux servers. Fluid creates a dedicated fluid-readonly user with a restricted login shell. A client-side allowlist validates every command against ~50 permitted read-only commands (cat, ls, grep, ps, journalctl, etc.) before it is even sent. Server-side, the restricted shell blocks 50+ destructive patterns - sudo, rm, mv, chmod, wget, python, bash - at the OS level. Command substitution ($(...), backticks), output redirection, and subshells are all blocked. Even if the AI constructs something creative, the shell will not execute it. If a sandbox host is configured and a possible fix can be constructed, Fluid will create a sandbox of the server to test changes and updates. Finally, Fluid will create an ansible playbook that can be applied to production to fix the issue.', |
| labels := []string{" Address:", " Name: ", " Insecure:"} | ||
| for i := range connectFieldCount { | ||
| prefix := " " | ||
| if connectField(i) == m.focused { | ||
| prefix = "> " | ||
| } | ||
| b.WriteString(fmt.Sprintf("%s%s %s\n", prefix, labels[i], m.inputs[i].View())) | ||
| } |
| @@ -121,17 +125,38 @@ func (a *FluidAgent) SetReadOnly(ro bool) { | |||
| a.readOnly = ro | |||
| } | |||
|
|
|||
| // SetSandboxService hot-swaps the sandbox service (e.g. after /connect). | |||
| func (a *FluidAgent) SetSandboxService(svc sandbox.Service) { | |||
| if a.service != nil { | |||
| _ = a.service.Close() | |||
| } | |||
| a.service = svc | |||
| } | |||
|
|
|||
| // sendStatus sends a status message through the callback if set | |||
| func (a *FluidAgent) sendStatus(msg tea.Msg) { | |||
| if a.statusCallback != nil { | |||
| a.statusCallback(msg) | |||
| } | |||
| } | |||
|
|
|||
| // Cancel stops the currently running agent loop | |||
| func (a *FluidAgent) Cancel() { | |||
| if a.cancelFunc != nil { | |||
| a.cancelFunc() | |||
| a.cancelFunc = nil | |||
| } | |||
| } | |||
| // Sticky mode transitions: only change mode when tool category changes | ||
| switch tc.Function.Name { | ||
| case "run_source_command", "read_source_file": | ||
| if !a.readOnly { | ||
| a.autoReadOnly = true | ||
| a.readOnly = true | ||
| a.sendStatus(AutoReadOnlyMsg{SourceVM: "", Enabled: true}) | ||
| } | ||
| case "create_sandbox", "destroy_sandbox", "run_command", "start_sandbox", | ||
| "stop_sandbox", "create_snapshot", "edit_file", "read_file", | ||
| "create_playbook", "add_playbook_task": | ||
| if a.autoReadOnly { | ||
| a.autoReadOnly = false | ||
| a.readOnly = false | ||
| a.currentSourceVM = "" | ||
| a.sendStatus(AutoReadOnlyMsg{SourceVM: "", Enabled: false}) | ||
| } | ||
| } |
| ## Makefile Targets | ||
|
|
||
| | Target | Description | | ||
| |--------|-------------| | ||
| ### CLI Subcommands | ||
|
|
||
| | Command | Description | | ||
| |---------|-------------| | ||
| | `fluid` | Launch the interactive TUI agent (default) | | ||
| | `fluid connect <address>` | Connect to a fluid daemon and save config | | ||
| | `fluid mcp` | Start MCP server on stdio | | ||
| | `fluid doctor` | Check daemon setup on a host | | ||
| | `fluid source prepare <host>` | Prepare a host for read-only access | | ||
| | `fluid source list` | List configured source hosts | | ||
| | `fluid update` | Self-update to the latest release | | ||
|
|
||
| ## Makefile Targets |
| "list": true, | ||
| }, | ||
| "openssl": { | ||
| "x509": true, | ||
| "verify": true, | ||
| "s_client": true, | ||
| "crl": true, | ||
| "version": true, | ||
| "ciphers": true, | ||
| }, |
| svc, err := sandbox.NewRemoteService(addr, cpCfg, loadedCfg.Hosts) | ||
| if err != nil { | ||
| fmt.Printf(" %s Failed to dial: %v\n", red("[error]"), err) | ||
| return err | ||
| } | ||
| defer func() { | ||
| if skipSave { | ||
| _ = svc.Close() | ||
| } | ||
| }() |
| case "esc": | ||
| if m.state == StateThinking { | ||
| if m.agentRunner != nil { | ||
| m.agentRunner.Cancel() | ||
| } | ||
| m.addSystemMessage("Agent stopped.") | ||
| m.state = StateIdle | ||
| m.thinking = false | ||
| m.updateViewportContent(false) | ||
| m.textarea.Focus() | ||
| return m, nil |
| # ./demo/haproxy-ssl-debug.sh <ssh-host> # Setup the broken scenario | ||
| # ./demo/haproxy-ssl-debug.sh <ssh-host> --cleanup # Tear everything down |
| "x509": true, | ||
| "verify": true, | ||
| "s_client": true, | ||
| "crl": true, | ||
| "version": true, | ||
| "ciphers": true, |
Code ReviewOverall this is a solid PR — the connect wizard is well-structured, private key redaction is a great security improvement, and ESC-to-cancel is a welcome UX addition. A few issues worth addressing: BugsDouble-close in
defer func() { _ = svc.Close() }() // line ~139
if err := svc.Health(ctx); err != nil {
_ = svc.Close() // double close — defer will also run
return err
}
// same pattern for GetHostInfo failureRemove the explicit Doctor checks not cancellable during connect wizard ( When the user hits ESC during Race condition on The auto-read-only logic was moved into UX / Design Issues
Using a free-text field for a boolean (accepting ESC cancellation double-reset ( When ESC is pressed during Code QualityDuplicate TLS system prompt injection ( The TLS debugging guidance string is constructed identically in two places:
Consider extracting it to a constant or package-level Duplicate localhost detection ( The check Color helpers duplicated in The green/red/dim ANSI helpers are defined inline in Security
Private key redaction ( The Tests
Minor
🤖 Generated with Claude Code |
Code ReviewOverall this is a solid, well-scoped PR. The connect wizard, ESC cancellation, and private-key redaction are all useful additions. Tests for Bug: Service leak when ESC is pressed during a connection attempt
// model.go – ConnectCloseMsg handler
if svc := m.connectModel.GetService(); svc != nil { // nil here if dial still in-flight
_ = svc.Close()
}One safe fix: store a case ConnectHealthResultMsg:
if !m.inConnect {
if msg.Service != nil {
_ = msg.Service.Close()
}
return m, nil
}
// ... forward to connect modelMissing tests for
|
Code ReviewOverall this is a solid PR with well-structured code, good test coverage, and clean separation between the CLI command and TUI wizard. A few issues worth addressing: Bugs / Correctness1. Race condition in
Consider requiring func (a *FluidAgent) SetSandboxService(svc sandbox.Service) {
a.cancelMu.Lock() // reuse existing mu, or add a dedicated one
defer a.cancelMu.Unlock()
if a.service != nil {
_ = a.service.Close()
}
a.service = svc
}Or document that callers must ensure no active agent run before calling this. 2.
Consider adding 3. ESC-cancel state reset is optimistic (model.go:917–926) When ESC is pressed during A simple guard: only apply those message handlers when Code Quality4. Duplicated auto-read-only logic in The identical 15-line block for setting/restoring func (a *FluidAgent) withAutoReadOnly(sourceVM string, fn func() (any, error)) (any, error) {
a.currentSourceVM = sourceVM
wasAutoReadOnly := a.autoReadOnly
if !a.readOnly {
a.autoReadOnly = true
a.readOnly = true
a.sendStatus(AutoReadOnlyMsg{SourceVM: sourceVM, Enabled: true})
}
defer func() {
a.currentSourceVM = ""
if a.autoReadOnly && !wasAutoReadOnly {
a.autoReadOnly = false
a.readOnly = false
a.sendStatus(AutoReadOnlyMsg{Enabled: false})
}
}()
return fn()
}5. If Minor / Nits6. After constructing 7. Context timeout created once but used for two calls (main.go:170–184) In 8.
Test CoverageThe new One gap: |
|
test |
Security Modelfluid.sh uses defense-in-depth to isolate AI agent workloads in VM sandboxes. This document describes the security architecture across the CLI, daemon, and API control plane. OverviewSecurity is enforced across multiple layers:
SSH Certificate AuthorityThe SSH CA signs short-lived certificates for all sandbox and source VM access. No persistent SSH keys are stored on VMs. Key generation: Ed25519 CA key pair generated via Certificate identity format: Certificate properties:
Permission validation: the CA enforces that private key files have mode 0600 or 0400 (no group/world access) before signing. Source: Sandbox CredentialsEach sandbox gets ephemeral Ed25519 key pairs, generated on demand and cached until expiry.
Pre-flight permission checks run before every SSH connection: the runner verifies the private key file has no group/world permissions ( Source: Source VM Read-Only ModeSource (golden) VMs are accessible only for inspection, never modification. The CLI connects directly to source hosts via SSH for read-only operations (not through the daemon). Three enforcement layers ensure safety. Layer 1: Client-side allowlist
Allowed categories:
Subcommand restrictions (first argument must match allowlist):
Metacharacter blocking:
Source: Layer 2: Server-side restricted shellA bash script installed at
Blocked command categories (regex patterns on each pipeline segment):
Source: Layer 3: SSH principal separationSource VM credentials use the
Only certificates with the Source VM preparation (
Source: VM Isolation
Source: Secrets RedactionBoth the CLI and daemon include identical redaction packages that strip sensitive data from all outgoing LLM messages and restore tokens in responses before tool execution. Built-in detectors:
Token format: Configurability: custom regex patterns and allowlists can be added. Source: Human Approval WorkflowThe TUI enforces human-in-the-loop confirmation for potentially dangerous operations via blocking dialogs. All dialogs default to "No"; Escape maps to "No". Network access: blocking dialog before commands using Resource limits: warning dialog when sandbox creation exceeds available memory, CPU, or storage. Default: deny. Source VM preparation: confirmation before running Source: Hash-Chained Audit LogAppend-only JSONL audit log at Hash chain: each entry contains a SHA-256 hash computed from the previous entry's hash plus the current entry. The genesis entry uses an all-zeros hash. Logged events:
Integrity verification: Size protection: configurable max file size; events are dropped when the limit is reached. Source: MCP Input ValidationAll MCP tool inputs are validated before execution. Shell argument validation: rejects empty strings, arguments over 32 KB, null bytes, and control characters. Shell escaping: POSIX single-quote wrapping for all shell arguments. File path validation: paths must be absolute, must not contain File size limit: 10 MB maximum for file operations. Source: Config File SecurityPermission checking: warns if config files are group- or world-readable (should be 0600). Secret detection: flags insecure permissions when API keys or tokens are present in the config. File creation: config files are saved with 0600 permissions. Source: Telemetry PrivacyTelemetry is enabled by default (opt-out). Disable via
Source: API AuthenticationPassword authentication: bcrypt with cost factor 12, minimum 8-character passwords, generic error messages to prevent user enumeration. Session tokens: 32 cryptographically random bytes; only the SHA-256 hash is stored server-side. Cookies are OAuth (GitHub, Google): CSRF state parameter uses 32 random bytes with constant-time comparison. OAuth tokens are encrypted at rest. Host tokens: SHA-256 hashed in the database with expiry enforced at lookup time. Source: API Authorization (RBAC)Three roles with numeric levels: owner (3), admin (2), member (1).
Source: API Transport SecurityCORS: origin locked to configured frontend URL (not wildcard), credentials allowed. Rate limiting: per-IP token bucket. Auth routes have custom limits:
Proxy IP resolution: Source: Encryption at RestAES-256-GCM with random nonce for OAuth tokens and Proxmox credentials. Sensitive fields are excluded from JSON serialization: Source: gRPC Security (Control Plane)Daemon-to-API: optional mTLS with client certificate and custom CA pool. Defaults to insecure for backwards compatibility. API-to-daemon: host token authentication via stream interceptor. Optional TLS (warns if disabled). Concurrency limiting: max 64 concurrent command handlers. Source: Network Isolation (Daemon)
Source: Sandbox Lifecycle (Janitor)Background TTL enforcement for automatic sandbox cleanup.
Source: Command Execution Security
Source: Path Traversal PreventionVM names used in filesystem paths are sanitized via This prevents Source: File Permissions Summary
Timeouts
|
|
PLACEHOLDER |
Security Modelfluid.sh uses defense-in-depth to isolate AI agent workloads in VM sandboxes. This document describes the security architecture across the CLI, daemon, and API control plane. OverviewSecurity is enforced across multiple layers:
SSH Certificate AuthorityThe SSH CA signs short-lived certificates for all sandbox and source VM access. No persistent SSH keys are stored on VMs. Key generation: Ed25519 CA key pair generated via Certificate identity format: Certificate properties:
Permission validation: the CA enforces that private key files have mode 0600 or 0400 (no group/world access) before signing. Source: Sandbox CredentialsEach sandbox gets ephemeral Ed25519 key pairs, generated on demand and cached until expiry.
Pre-flight permission checks run before every SSH connection: the runner verifies the private key file has no group/world permissions ( Source: Source VM Read-Only ModeSource (golden) VMs are accessible only for inspection, never modification. The CLI connects directly to source hosts via SSH for read-only operations (not through the daemon). Three enforcement layers ensure safety. Layer 1: Client-side allowlist
Allowed categories:
Subcommand restrictions (first argument must match allowlist):
Metacharacter blocking:
Source: Layer 2: Server-side restricted shellA bash script installed at
Blocked command categories (regex patterns on each pipeline segment):
Source: Layer 3: SSH principal separationSource VM credentials use the
Only certificates with the Source VM preparation (
Source: VM Isolation
Source: Secrets RedactionBoth the CLI and daemon include identical redaction packages that strip sensitive data from all outgoing LLM messages and restore tokens in responses before tool execution. Built-in detectors:
Token format: Configurability: custom regex patterns and allowlists can be added. Source: Human Approval WorkflowThe TUI enforces human-in-the-loop confirmation for potentially dangerous operations via blocking dialogs. All dialogs default to "No"; Escape maps to "No". Network access: blocking dialog before commands using Resource limits: warning dialog when sandbox creation exceeds available memory, CPU, or storage. Default: deny. Source VM preparation: confirmation before running Source: Hash-Chained Audit LogAppend-only JSONL audit log at Hash chain: each entry contains a SHA-256 hash computed from the previous entry's hash plus the current entry. The genesis entry uses an all-zeros hash. Logged events:
Integrity verification: Size protection: configurable max file size; events are dropped when the limit is reached. Source: MCP Input ValidationAll MCP tool inputs are validated before execution. Shell argument validation: rejects empty strings, arguments over 32 KB, null bytes, and control characters. Shell escaping: POSIX single-quote wrapping for all shell arguments. File path validation: paths must be absolute, must not contain File size limit: 10 MB maximum for file operations. Source: Config File SecurityPermission checking: warns if config files are group- or world-readable (should be 0600). Secret detection: flags insecure permissions when API keys or tokens are present in the config. File creation: config files are saved with 0600 permissions. Source: Telemetry PrivacyTelemetry is enabled by default (opt-out). Disable via
Source: API AuthenticationPassword authentication: bcrypt with cost factor 12, minimum 8-character passwords, generic error messages to prevent user enumeration. Session tokens: 32 cryptographically random bytes; only the SHA-256 hash is stored server-side. Cookies are OAuth (GitHub, Google): CSRF state parameter uses 32 random bytes with constant-time comparison. OAuth tokens are encrypted at rest. Host tokens: SHA-256 hashed in the database with expiry enforced at lookup time. Source: API Authorization (RBAC)Three roles with numeric levels: owner (3), admin (2), member (1).
Source: API Transport SecurityCORS: origin locked to configured frontend URL (not wildcard), credentials allowed. Rate limiting: per-IP token bucket. Auth routes have custom limits:
Proxy IP resolution: Source: Encryption at RestAES-256-GCM with random nonce for OAuth tokens and Proxmox credentials. Sensitive fields are excluded from JSON serialization: Source: gRPC Security (Control Plane)Daemon-to-API: optional mTLS with client certificate and custom CA pool. Defaults to insecure for backwards compatibility. API-to-daemon: host token authentication via stream interceptor. Optional TLS (warns if disabled). Concurrency limiting: max 64 concurrent command handlers. Source: Network Isolation (Daemon)
Source: Sandbox Lifecycle (Janitor)Background TTL enforcement for automatic sandbox cleanup.
Source: Command Execution Security
Source: Path Traversal PreventionVM names used in filesystem paths are sanitized via This prevents Source: File Permissions Summary
Timeouts
|
Code ReviewOverall this is a solid, well-structured PR with good test coverage. The wizard UX, ESC cancellation, and private key redaction are all well done. A few issues worth addressing: Bug: openssl req is blocked in shell but allowed in validate.go Files: fluid-cli/internal/readonly/shell.go and validate.go (same in fluid-daemon) shell.go adds the pattern to BLOCKED_PATTERNS that blocks all openssl req subcommands at the shell level. But validate.go allows openssl req with only -new/-signkey/-x509 blocked. The comment in validate.go says req is allowed for read-only inspection but the shell pattern blocks all of them including: openssl req -text -noout The TestValidateCommand_Allowed test for that command passes at the Go level but the command will be rejected at runtime by the shell. Fix: Either tighten the shell patterns to block only the specific dangerous flags, or remove req from subcommandRestrictions and rely entirely on the shell block. The two layers currently contradict each other. Security: run_source_command output bypasses private key redaction File: fluid-cli/internal/tui/agent.go redactPrivateKeys() is applied to read_source_file and read_file. But an LLM could request 'cat /etc/ssl/private/server.key' via run_source_command, and that stdout would reach the LLM unredacted. The same redaction should be applied to command stdout/stderr before returning the result map. Minor: Duplicate step numbering in runConnect File: fluid-cli/cmd/fluid/main.go Both the Connect-and-health-check block and the health timeout block are labeled // 1. The second should be // 2. Minor: withAutoReadOnly closure captures cmdErr redundantly File: fluid-cli/internal/tui/agent.go In the run_source_command branch, cmdErr is assigned inside the closure (by capture) and then again from the return value of withAutoReadOnly. Both end up with the same value so there is no bug, but using a local variable inside the closure would make the flow easier to follow. Concern: SetSandboxService caller contract is unenforced File: fluid-cli/internal/tui/agent.go The doc comment says 'Must be called after Cancel() to avoid race conditions,' but there is no runtime protection if a caller skips Cancel() first. If the agent is mid-execution when SetSandboxService closes the old service, this could cause a crash. A lightweight protection would be asserting a.cancelFunc == nil at the start of SetSandboxService. Missing test: PrepareWithKey command count File: fluid-cli/internal/readonly/prepare_test.go The journal group step was added to both Prepare and PrepareWithKey, and the Prepare tests are correctly updated (10 to 11 commands). There is no command-count assertion for PrepareWithKey. Worth adding to catch future regressions. Also: fluid-daemon/internal/readonly/prepare.go gets the same change - are there daemon-side tests that need updating? Positive highlights
|
|
PR Review: feat: add fluid connect command and TUI connect wizard Overall this is a solid, well-structured PR with good test coverage for the new netutil, ConnectModel, and redactPrivateKeys code. A few issues worth addressing before merge. SECURITY
CORRECTNESS
CODE QUALITY
MINOR
Generated with Claude Code |
PR ReviewOverall this is a solid, well-structured PR with good test coverage. The TUI wizard is cleanly implemented using the Bubbletea model pattern and the private key redaction is an important security feature. A few issues worth addressing. |
PR Review: feat: add fluid connect command and TUI connect wizardOverall this is a solid, well-structured PR with good test coverage. The TUI wizard is cleanly implemented using the Bubbletea model pattern and the private key redaction is an important security feature. A few issues worth addressing. Bug / Inconsistency: fluid-cli vs fluid-daemon group membership mismatch -- fluid-cli/internal/readonly/prepare.go adds only systemd-journal (adm omitted as overly broad per comment), but fluid-daemon/internal/readonly/prepare.go adds systemd-journal,adm. Pick one behavior consistently. The adm group provides broad read access to /var/log and is more permissive. Race Condition: withAutoReadOnly accesses unprotected shared state -- withAutoReadOnly modifies a.currentSourceVM, a.autoReadOnly, and a.readOnly without any lock, but FluidAgent.Run executes as a goroutine (tea.Cmd). cancelMu only protects cancelFunc. If Cancel() is called while withAutoReadOnly is mid-execution, these fields could be read/written from two goroutines simultaneously. Security Note: openssl s_client can reach arbitrary hosts -- The allowlist in validate.go permits openssl s_client -connect arbitrary-host:port. In a read-only shell this lets an agent initiate outbound TCP to any host:port. Consider whether this fits your threat model or restrict to localhost. Minor Security: insecure flag help text -- The --insecure flag on connectCmd should include a stronger warning, e.g. appending (INSECURE: use only for local/dev daemons) so it is visible in --help output. Missing Test Coverage: runConnect has no unit tests -- The core CLI flow in runConnect (address normalization, duplicate-host detection by name OR address, config save logic, fallback naming) is untested. The TUI model tests are thorough but this function contains meaningful logic that could use table-driven tests similar to TestResolveAddress. Minor: Misleading comment in fluid-daemon/validate.go -- The comment for openssl req in subcommandRestrictions says only the shell-level blocklist blocks dangerous operations, omitting that validateOpenSSLArgs also blocks them at the Go level. The fluid-cli version correctly mentions both layers. What's Good: Private key redaction is well-implemented with comprehensive test coverage across key types (RSA, EC, PKCS8, CRLF). netutil.IsLocalHost correctly handles all address forms - all tested. Agent ESC cancellation cleanly threads through cancelMu and drops in-flight results after cancel. colorFunc refactor is a clean deduplication. ConnectModel tests cover tab navigation, toggle, escape, and address resolution edge cases. OpenSSL validation with two-layer defense (Go-level validateOpenSSLArgs plus shell-level blocklist) is the right approach. CommandOutputStartMsg cleanly pre-initializes the live output box before chunks arrive. |
PR Review: feat: add fluid connect command and TUI connect wizardGood overall implementation. The code is well-structured, thread-safety has been improved, and test coverage is solid. A few issues worth addressing: Bugs / Correctness1. Potential service leak on ESC-during-connect race (medium) In A guard in case ConnectHealthResultMsg:
if !m.inConnect {
if msg.Service != nil { _ = msg.Service.Close() }
return m, nil
}2.
Code Quality3. Duplicate upsert logic in
m.cfg.SandboxHosts = upsertSandboxHost(m.cfg.SandboxHosts, closeMsg.Config)4. This is fine for avoiding deadlock, but the pattern of unlocking mid-function (two unlock call sites) is fragile. Consider restructuring to determine whether to send status while holding the lock, then send after release — same behavior but easier to reason about. Security5.
6. Private key redaction is client-side only The Tests7. The assertion block (lines ~356–363) uses wantIndex int // add to test struct
// then: result[tc.wantIndex].Name != tc.wantNameMinor
Overall the connect wizard UX is clean, the mutex refactor on |
fluid.shClaude Code for Working on Linux Servers - with human approval. What This Isfluid.sh lets AI agents do infrastructure work in isolated VM sandboxes. Agent works autonomously. Human approves before production. Project StructureTesting RequiredEvery code change needs tests. See project-specific AGENTS.md files for details. Quick Referencemprocs # Start all services for dev
cd fluid-cli && make test # Test CLI
cd fluid-daemon && make test # Test daemon
cd api && make test # Test API
cd web && bun run build # Build webProject Docs
|
PR ReviewOverall this is a well-structured PR with good test coverage. The ESC-to-cancel, thread-safety refactor, and TUI wizard are solid additions. A few things worth addressing: Bugs / Correctness1. AgentCancelledMsg can interrupt a newly-started run In model.go, pressing ESC during StateThinking immediately sets m.state = StateIdle and calls Cancel(). However, Cancel() only sends a cancellation signal - the goroutine continues running asynchronously. If the user immediately submits a new message before AgentCancelledMsg arrives, the model transitions to StateThinking again. When the delayed AgentCancelledMsg from the old run then arrives, m.state != StateIdle is true (new run in progress), so it adds a spurious "Agent stopped." and resets UI state mid-run. Consider tagging AgentCancelledMsg with a generation counter or run ID so stale cancellations can be ignored. 2. SetSandboxService race window after Cancel() The doc comment says "Must be called after Cancel()", but Cancel() only signals - it does not wait for the goroutine to exit. After Cancel() returns, the goroutine may still be mid-tool-call holding a.service. SetSandboxService then closes and replaces that service while the goroutine may still hold a reference. The cancelFunc != nil guard protects against concurrent starts, but not against the running goroutine's tail. Consider adding a sync.WaitGroup or done channel to Run so callers can wait for actual completion before swapping the service. 3. Double error output in runConnect Several error paths in runConnect (main.go) both print the error and return it. For example: This produces duplicate output in the terminal. Either set connectCmd.SilenceErrors = true / SilenceUsage = true, or only propagate via Cobra's error mechanism without the manual fmt.Printf. Design / Maintainability4. redactSensitiveKeys in agent.go duplicates internal/redact The PR adds base64PEMDetector and k8sSecretDetector to internal/redact/patterns.go (Layer 2), and also adds privateKeyRe, sensitiveBase64PEMRe, sensitiveK8sSecretRe with a standalone redactSensitiveKeys function in agent.go (Layer 1). Two implementations to maintain that already diverge slightly. Layer 1 could just call the existing Redactor from internal/redact instead of reimplementing the same patterns. 5. tls.crt should not be treated as a sensitive key Both k8sSecretRe in agent.go and k8sSecretDetector in patterns.go include "tls.crt" in the redaction list. tls.crt is a public certificate - it contains no secret material. Redacting it actively harms TLS debugging (the agent cannot see the cert chain, SANs, expiry, etc.), which is exactly the use-case the new tlsDebuggingGuidance system prompt is trying to support. Remove tls.crt from the sensitive key list. 6. openssl s_client remote restriction is Go-only validateOpenSSLArgs correctly blocks openssl s_client -connect remote:port, but shell.go's BLOCKED_PATTERNS array has no corresponding entry. The two-layer defense described in AGENTS.md implies the shell should be the backstop. Since validate.go runs client-side and the shell runs on the server, consider adding a shell-level pattern for s_client as a belt-and-suspenders measure. Minor
Positives
|
|
Code Review Great PR overall. The /connect wizard, inline redaction, openssl allowlisting, and ESC-cancel are all well-structured and tested. A few issues worth addressing before merge. Race condition in SetSandboxService agent.go SetSandboxService checks cancelFunc == nil under the lock, releases the lock, waits on doneCh, then re-acquires the lock to swap the service. Between the release and re-acquire a new agent run can start (setting cancelFunc), so the "agent is not running" guarantee is violated. The fix is to re-check cancelFunc after re-acquiring the lock before swapping the service, and return an error if a new run has started. openssl s_client is blocked at shell level but allowed at Go level shell.go adds the pattern "^openssl s_client" to the shell-level blocklist, blocking ALL openssl s_client invocations. But validate.go explicitly allows s_client (restricted to localhost). The two layers are contradictory: Go validation passes "openssl s_client -connect localhost:443" but the shell script will then reject it at runtime. If the intent is defence-in-depth with both layers restricting to localhost, the shell pattern needs to be more precise. If s_client should be blocked entirely, the Go allowlist entry and its test cases should be removed to avoid confusion. SensitiveContentRedactedMsg message text is too narrow The Redactor catches connection strings, API keys, AWS credentials, IPs, and more -- not just private keys. The TUI message in model.go hardcodes "Private key detected" in both branches. Suggest changing to "Sensitive content detected" (or passing the detector category through the message struct) so the message stays accurate as the redactor scope grows. Minor nits runConnect double-prints errors: each error branch calls fmt.Printf and then returns err. Cobra RunE will print the returned error a second time. Either print and return nil, or just return fmt.Errorf without the manual print. extractLiveOutputCommand comment placement: the doc comment appears above formatLiveOutput rather than directly above extractLiveOutputCommand, so go doc will not associate it correctly. IPv6 bracket edge case in validateOpenSSLArgs: the s_client host extraction uses strings.LastIndex(hostPort, ":") which works for [::1]:443 but gives a wrong result for a bare [::1] without a port. Unlikely in practice, but a test case would make the boundary explicit. What is good Test coverage is comprehensive (connect wizard, redact patterns, UpsertSandboxHost, netutil, validate, prepare command count). withAutoReadOnly consolidation removes duplicated defer logic cleanly. AgentCancelledMsg.RunID stale-cancellation guard is a nice touch. colorFunc refactor reduces duplication. Proper mutex protection on all new shared fields. k8sSecretDetector correctly uses regexp.QuoteMeta so the dot in tls.key is escaped. Generated with Claude Code |
PR Review:
|
Code ReviewOverall this is a solid, well-structured PR with good test coverage. |
Code ReviewOverall this is a solid, well-structured PR with good test coverage. The fluid connect CLI command, TUI wizard, ESC cancellation, and redaction improvements are all well-thought-out. A few issues worth addressing below. Bugs / Correctness 1. SetSandboxService blocks the Bubbletea event loop (significant) In model.go, SetSandboxService is called synchronously from Update(). But SetSandboxService can block for up to 2 seconds waiting on doneCh. Bubbletea Update runs on the main goroutine — blocking here freezes the entire TUI. This should be dispatched as a tea.Cmd (goroutine) instead. 2. AgentRunner interface leak via type assertion The ConnectCloseMsg handler type-asserts to *FluidAgent. This couples Model tightly to a concrete type, bypassing the AgentRunner interface. SetSandboxService should either be added to the AgentRunner interface, or the service swap should be handled through a dedicated message dispatched from outside the model. Code Quality 3. Duplicated validateOpenSSLArgs between fluid-cli and fluid-daemon The function is copy-pasted verbatim in both packages (the comment even says keep this s_client block in sync). Since both packages import from the same module, this could live in a shared utility. Copy-paste with a keep-in-sync note is a maintenance trap — especially for security-critical validation logic. 4. UpsertSandboxHost — in-place slice re-use is non-obvious This is the standard Go filter-in-place idiom and is correct here (write index <= read index always holds). But since this is config-mutation code with a deduplication side-effect, a brief comment explaining why it is safe would help future readers. As written it looks like an accidental mutation of the input slice. 5. TestSetSandboxService_TimesOut adds 2s to every test run The test waits for the hard-coded 2-second timeout in SetSandboxService. Consider making the timeout configurable so tests can use a short timeout. Security 6. Doctor checks fail silently when SSH is not configured If the user has no SSH alias or key for the host, doctor checks will silently time out or produce confusing errors. A clear early message like Doctor checks require SSH access to the host — skipping would be more user-friendly and avoid the 30-second wait. 7. --insecure flag persisted without re-confirmation The Insecure: true field is written to config and silently reused on subsequent connects. Consider logging a warning at connection time when loading a host with Insecure: true, so users are not surprised later. Minor / Nits
What is well done
Generated with Claude Code |
Summary
fluid connect <address>CLI subcommand to connect to a fluid daemon and save config/connectslash command) with SSH key auth, doctor checks, and step-by-step progress displayWriteString(fmt.Sprintf(...))withfmt.Fprintfthroughout connect.goTest plan
fluid connect <daemon-address>and verify config is saved/connectand step through the wizardmake lintin fluid-cli and confirm no staticcheck violations🤖 Generated with Claude Code