diff --git a/Directory.Packages.props b/Directory.Packages.props index f2f79942f..19876bd98 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,6 +16,7 @@ + diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index f8fba1926..e67b5fcf5 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -200,9 +200,9 @@ Full task breakdowns: > **Gateway note:** Netclaw.App was temporarily changed from `Microsoft.NET.Sdk.Web` > to `Microsoft.NET.Sdk` (plain console host) for the proof-of-concept console -> adapter. The web gateway (health endpoints, future API surface) must be -> restored before Slack adapter or any multi-client scenario. Track as part of -> Task 1.11 (CLI scaffold) or earlier if needed. +> adapter. Task 1.11 restores `Microsoft.NET.Sdk.Web` for daemon modes per +> SPEC-011. Single-process architecture validated by research in +> `docs/research/agent-gateway-architecture.md`. ### Task 1.1: Framework protocol and persistence-safe message envelopes (DONE) @@ -364,28 +364,36 @@ Done when: - [ ] CI tests pass without live provider credentials. - [ ] Tests for provider switching, fallback activation, tool calling round-trip. -### Task 1.11: CLI scaffold with Cocona +### Task 1.11: Daemon architecture scaffold with mode routing **PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md` (CLI-010, CLI-012) +**Spec:** `docs/spec/SPEC-011-daemon-architecture.md` **OpenSpec:** `openspec/specs/netclaw-cli/spec.md` **OpenSpec Changes:** `openspec/changes/add-tui-adapter-and-config-hot-reload/` (Section 1) -**Surface area:** CLI framework +**Surface area:** CLI framework + gateway **Verification:** L1 **Previously:** Task 1.13 -Replaces the bare console loop with a proper CLI framework. Restore web gateway -hosting (`Microsoft.NET.Sdk.Web`) if needed for daemon mode. +Replaces the bare console loop with mode-selected startup per SPEC-011. +Daemon modes use `WebApplication.CreateBuilder()` (ASP.NET host for SignalR +and health endpoints). Lightweight modes use `Host.CreateApplicationBuilder()`. +Cocona is archived (Dec 2025) — replaced with simple arg routing. Done when: -- [ ] Cocona and Termina package references added to `Directory.Packages.props` and `Netclaw.App.csproj`. -- [ ] `Program.cs` rewritten as Cocona entry point with DI registration. -- [ ] `RunCommand.cs` created for daemon mode (`netclaw run`). -- [ ] Termina wired as hosted service for TUI commands. +- [ ] Termina and System.Reactive package references added to `Directory.Packages.props`. +- [ ] `Netclaw.App.csproj` SDK changed to `Microsoft.NET.Sdk.Web`, Termina added, Hosting removed. +- [ ] `Program.cs` rewritten with mode routing: `run`/`chat`/headless use `WebApplication`, `init`/`doctor` use `Host`. +- [ ] Shared config services extracted to `ConfigureConfigServices()` method. +- [ ] Daemon-only services extracted to `ConfigureDaemonServices()` method. +- [ ] SignalR hub mapped at `/hub/session` (stub — Phase 1 placeholder). +- [ ] Health probe mapped at `/api/health/ready`. +- [ ] `ConsoleChannel.cs` deleted (replaced by Termina TUI). - [ ] `dotnet build` passes with new dependencies. ### Task 1.12: TUI chat adapter (`netclaw chat`) **PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md` (CLI-011), `docs/prd/PRD-009-input-adapters-and-unified-input.md` (INPUT-005) +**Spec:** `docs/spec/SPEC-011-daemon-architecture.md` **OpenSpec:** `openspec/specs/netclaw-input-adapters/spec.md`, `openspec/specs/netclaw-cli/spec.md` **OpenSpec Changes:** `openspec/changes/add-tui-adapter-and-config-hot-reload/` (Section 2) **Surface area:** TUI + adapter @@ -393,14 +401,15 @@ Done when: **Wireframe:** `docs/ui/TUI-001-command-wireframes.md` (netclaw chat) **Previously:** Task 1.14 +The TUI uses `SessionPipeline` directly — no SignalR indirection. Same +in-process pattern as the current ConsoleChannel/HeadlessChannel. + Done when: -- [ ] `TuiInputAdapter` implementing adapter contract (`SendUserMessage` with entity key `tui/{sessionId}`). -- [ ] `ChatCommand.cs` hosts actor system in-process and launches TUI. - [ ] `ChatPage.cs` with `StreamingTextNode` (scrollable history) and `TextInputNode` (multi-line input). -- [ ] `ChatViewModel.cs` with session lifecycle and broadcast subscription. +- [ ] `ChatViewModel.cs` uses `SessionPipeline` directly via DI — calls `CreateAsync()`, pushes `ChannelInput`, subscribes to `SessionOutput`. - [ ] Inline tool activity panel (completed with duration, in-progress with spinner). - [ ] MCP status indicator in status bar (green/yellow/red). -- [ ] E2E: user types → `SendUserMessage` → session actor → LLM → streaming response in TUI. +- [ ] E2E: user types → `ChannelInput` → `SessionPipeline` → session actor → LLM → streaming response in TUI. --- @@ -703,3 +712,6 @@ the linked research documents. - `docs/research/actor-llm-optimization-patterns.md` — Prompt caching, safety circuit breakers, parallel execution, streaming, retry, and sub-agent isolation patterns for actor-based LLM systems +- `docs/research/agent-gateway-architecture.md` — Single-process vs + multi-process architecture analysis (OpenClaw, IronClaw, ZeroClaw). + Validates single-process model for homelab/personal agent use. diff --git a/docs/prd/PRD-001-netclaw-mvp.md b/docs/prd/PRD-001-netclaw-mvp.md index 2296ee666..e9a2391a3 100644 --- a/docs/prd/PRD-001-netclaw-mvp.md +++ b/docs/prd/PRD-001-netclaw-mvp.md @@ -55,9 +55,37 @@ irrelevant — the differentiator is the instructions attached to the context. 10. Agent can modify its own configuration through conversation. 11. MCP integration provides external memory (Memorizer) and tool capabilities. +## Daemon Architecture + +Netclaw runs as a single OS process. All components — Akka actor system, +persistence, gateway endpoints, TUI, and tool execution — share one process +boundary. This is validated by architecture research across comparable projects +(see `docs/research/agent-gateway-architecture.md`). + +The executable supports multiple modes selected by command-line arguments: + +| Mode | Command | Description | +|------|---------|-------------| +| Daemon | `netclaw run` | Full service stack. Slack, schedules, SignalR hub, health endpoint. | +| Chat | `netclaw chat` | Full service stack + Termina TUI. Interactive chat via `SessionPipeline`. | +| Headless | `netclaw -p "..."` | Full service stack + headless client. Single turn, exits on completion. | +| Init | `netclaw init` | Lightweight. Config services only, no Akka/persistence. Reentrant TUI wizard. | +| Doctor | `netclaw doctor` | Lightweight. Config services only. Health checks and diagnostics. | + +In-process channels (TUI, headless) use `SessionPipeline` directly — no network +hop. The SignalR hub exists for future remote clients (Blazor ops console) and +uses the same `SessionPipeline` internally. + +Tools execute on the host process. This is the only model that works for Slack +(no client to delegate to), scheduled tasks (autonomous), and Docker deployment +(tools need access to the host's Docker socket). + +See `SPEC-011-daemon-architecture.md` for full specification. + ## Non-Goals (MVP) -- Multi-process gateway/agent split +- Multi-process gateway/agent split (validated by architecture research — + single-process is the correct model for homelab/personal agent use) - Ambient channel monitoring with per-channel instructions - Webhook ingress (Tailscale Serve / Cloudflare Tunnel) - Sub-agent model routing (cheaper models for high-token tasks) @@ -269,3 +297,5 @@ schedule changes → timer reconfiguration). - Agent Personality and Memory: PRD-007 - Scheduling: PRD-008 - Input Adapters: PRD-009 (post-MVP) +- Daemon Architecture: SPEC-011 +- Architecture Research: `docs/research/agent-gateway-architecture.md` diff --git a/docs/prd/PRD-004-cli-onboarding-and-config.md b/docs/prd/PRD-004-cli-onboarding-and-config.md index a7575c57d..626f7f6ed 100644 --- a/docs/prd/PRD-004-cli-onboarding-and-config.md +++ b/docs/prd/PRD-004-cli-onboarding-and-config.md @@ -23,19 +23,34 @@ diagnostics using CLI commands and guided output. ## CLI Framework -- **Cocona** for command routing (lightweight, convention-based, DI integration) +- **Simple arg routing** in `Program.cs` for mode selection (Cocona is archived + as of Dec 2025 — replaced with direct `args[0]` routing) - **Termina 0.5.1** for interactive TUI commands (`netclaw init`, `netclaw chat`) -- All other commands use plain console output via Cocona +- All other commands use plain console output - `netclaw run` is the explicit daemon entry point (Slack + timers + health endpoints, no TUI) +- All CLI modes are in-process — no REST client in Phase 1 +- Configuration is privileged local file I/O, never exposed over the wire + (contains API keys/secrets) ## Two-Phase Onboarding ### Phase 1: CLI Wizard (`netclaw init`) -Technical setup, no LLM required: +Technical setup, no LLM required. `netclaw init` runs as a **lightweight mode** +— no Akka actor system, no persistence, no SignalR. Only config services are +booted. Provider testing uses direct DI service calls (`ChatClientFactory`), +not REST endpoints. -1. LLM provider configuration (OpenRouter API key, model selection) +The wizard is **reentrant** — re-running `netclaw init` detects existing config +and shows a section dashboard with status per section. Each section is +independently enterable for modification. First-run guides linearly through +all steps. + +Steps: + +1. LLM provider configuration (endpoint URL, API key, model selection, + connectivity test via direct HTTP to provider) 2. Slack app setup (bot token, app token for Socket Mode) 3. PostgreSQL connection string 4. ACL bootstrap (owner identity, initial channel rules) @@ -175,7 +190,7 @@ Results are persisted to the environment inventory file. `netclaw init` and `netclaw chat` SHALL use Termina 0.5.1 for interactive TUI rendering. All other commands SHALL use plain console output. TUI commands SHALL -launch Termina as a hosted service within the Cocona command handler. +launch Termina as a hosted service within the mode-selected host builder. ### CLI-011 Local Chat Adapter @@ -219,3 +234,5 @@ rendering. This is the primary production entry point. - MCP setup: PRD-006 - Memory and personality: PRD-007 - Scheduling: PRD-008 +- Daemon architecture: SPEC-011 +- TUI wireframes: TUI-001 diff --git a/docs/prd/PRD-009-input-adapters-and-unified-input.md b/docs/prd/PRD-009-input-adapters-and-unified-input.md index 388095dff..22661aa3a 100644 --- a/docs/prd/PRD-009-input-adapters-and-unified-input.md +++ b/docs/prd/PRD-009-input-adapters-and-unified-input.md @@ -37,10 +37,11 @@ child session actor. ### MVP Input Adapters **Local TUI Adapter** (Phase 1): -- Hosted in-process by `netclaw chat` command +- Hosted in-process by `netclaw chat` command (daemon mode — full service stack) +- Uses `SessionPipeline` directly — no SignalR indirection for in-process channels - Receives keyboard input via Termina TextInputNode -- Produces `SendUserMessage` commands with entity key `tui/{sessionId}` -- Subscribes to session broadcasts for reply delivery +- Pushes `ChannelInput` to `SessionPipeline` input sink +- Subscribes to `SessionOutput` source for rendering - Renders responses as streaming text via StreamingTextNode - Displays tool invocation status inline (name, duration, spinner) - Shows MCP server connectivity in status bar @@ -75,9 +76,12 @@ child session actor. - Each webhook hit creates a new session with source-specific instructions **Web UI Adapter** (Phase 5): -- WebSocket connection from Blazor Server ops console +- Connects via SignalR hub (`/hub/session`) — the gateway surface documented in + SPEC-011 - Provides interactive session control and real-time updates -- Same pub/sub broadcast pattern as Slack adapter +- Same `SessionPipeline` abstraction as all other channels +- SignalR hub is mapped from Phase 1 (for future remote clients) but not used + by TUI or headless modes ## Adapter Contract @@ -174,3 +178,5 @@ SHALL display tool invocation status inline between user message and response. - Security (ACL per source): PRD-002 - Scheduling (timer source): PRD-008 - Ops console (web UI source): PRD-003 +- Daemon architecture and gateway surface: SPEC-011 +- SessionPipeline and channel abstraction: `src/Netclaw.Actors/Channels/ChannelPipeline.cs` diff --git a/docs/research/credential-storage-patterns.md b/docs/research/credential-storage-patterns.md new file mode 100644 index 000000000..b2371efeb --- /dev/null +++ b/docs/research/credential-storage-patterns.md @@ -0,0 +1,885 @@ +# Credential Storage Patterns in Self-Hosted AI Agents and Assistant Frameworks + +Research date: 2026-02-23 + +## Executive Summary + +This document surveys how real-world self-hosted AI agents, assistant frameworks, +and homelab platforms store and manage credentials (API keys, OAuth tokens, +integration secrets). The findings reveal a spectrum from plaintext-with-permissions +to encrypted-at-rest-with-vault-integration, with most projects landing somewhere +in the middle. + +**Key patterns observed:** + +1. **Plaintext JSON with filesystem permissions** -- the most common pattern for + dev-oriented CLI tools (dotnet user-secrets, OpenCode, Home Assistant secrets.yaml) +2. **OS keychain integration** -- used by Electron apps and polished CLI tools + (Claude Code on macOS, VS Code, Cursor, Git Credential Manager) +3. **Application-level AES encryption with a user-provided key** -- used by + multi-user web apps (n8n, LibreChat, Activepieces) +4. **Environment variable injection into subprocesses** -- universal pattern for + MCP servers, Docker containers, workflow tools +5. **External vault delegation** -- emerging pattern for enterprise deployments + (n8n External Secrets, Rasa + Vault, Docker MCP Gateway) + +--- + +## 1. Claude Code + +**Type:** CLI tool (Node.js/TypeScript) + +### Where credentials are stored + +| Platform | Storage Location | Encryption | +|----------|-----------------|------------| +| macOS | macOS Keychain (service: `Claude Code-credentials`) | OS-managed keychain encryption | +| Linux | `~/.claude/.credentials.json` (mode 0600) | **None** -- plaintext JSON | +| Windows | `~/.claude/.credentials.json` (mode 0600) | **None** -- plaintext JSON | + +### Credential format (Linux/Windows `.credentials.json`) + +```json +{ + "claudeAiOauth": { + "accessToken": "sk-ant-oat01-...", + "refreshToken": "sk-ant-ort01-...", + "expiresAt": 1748658860401, + "scopes": ["user:inference", "user:profile"] + } +} +``` + +### How credentials are provided + +- **OAuth flow**: `claude` CLI launches browser-based OAuth, receives tokens, + stores them in keychain (macOS) or `.credentials.json` (Linux/Windows) +- **API key**: Set `ANTHROPIC_API_KEY` environment variable +- **API key helper**: Configure `apiKeyHelper` in settings to run a shell script + that returns an API key. Called on startup, refreshed every 5 minutes or on + HTTP 401. TTL configurable via `CLAUDE_CODE_API_KEY_HELPER_TTL_MS`. + +### MCP server credential passing + +MCP servers are subprocesses. Credentials are passed via the `env` block in +`.mcp.json` configuration: + +```json +{ + "mcpServers": { + "postgres": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-postgres", "..."], + "env": { + "PGPASSWORD": "the-actual-password" + } + } + } +} +``` + +**Known limitation:** Environment variable substitution (`${VAR}`) is NOT +supported in `.mcp.json` files. The `env` block contains literal values. This +means committing `.mcp.json` to source control with credentials is unsafe. The +recommended workaround is to use a wrapper script or keep credentials in +non-committed config. + +For stdio transport MCP servers, process isolation is the security boundary -- +only the process that launches the server can communicate with it via stdin/stdout. + +For HTTP/SSE transport MCP servers, OAuth is supported. The `MCP_CLIENT_SECRET` +env var can skip interactive OAuth prompts. OAuth tokens are stored in the macOS +keychain or a credentials file. + +### References + +- [Claude Code Authentication Docs](https://code.claude.com/docs/en/authentication) +- [Managing API key environment variables](https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code) +- [MCP server env var security issue #2065](https://github.com/anthropics/claude-code/issues/2065) +- [Credential file bug #10039](https://github.com/anthropics/claude-code/issues/10039) +- [Keychain issue #9403](https://github.com/anthropics/claude-code/issues/9403) + +--- + +## 2. OpenCode + +**Type:** CLI tool (Go, now TypeScript/Bun) + +### Where credentials are stored + +- `~/.local/share/opencode/auth.json` -- plaintext JSON, mode 0600 +- `~/.local/share/opencode/mcp-auth.json` -- OAuth tokens for MCP servers +- Environment variables auto-detected (e.g., `OPENAI_API_KEY`) +- `.env` file in project root + +### Encryption at rest + +**None.** The `auth.json` file is plaintext. There is an open feature request +([#4318](https://github.com/sst/opencode/issues/4318)) to add system keyring +support using `@napi-rs/keyring` or `Bun.secrets` for cross-platform keychain +access (GNOME Keyring, macOS Keychain, Windows Credential Manager). + +### How credentials are provided + +- `opencode auth login` -- interactive CLI flow, stores to `auth.json` +- Environment variables -- auto-detected per provider convention +- `.env` file -- loaded from project root + +### Secret reference pattern + +OpenCode does not currently support referencing external secret stores. The +proposed keyring integration would store credentials with service name `opencode` +and account name as the provider ID. + +### References + +- [OpenCode Providers Docs](https://opencode.ai/docs/providers/) +- [OpenCode CLI Docs](https://opencode.ai/docs/cli/) +- [Keyring feature request #4318](https://github.com/sst/opencode/issues/4318) + +--- + +## 3. Cursor / Windsurf / AI IDEs (Electron-based) + +**Type:** Desktop IDE (Electron/VS Code fork) + +### Where credentials are stored + +All Electron-based IDEs (Cursor, Windsurf, VS Code) use the same underlying +mechanism inherited from VS Code: + +| Platform | Backend | Location | +|----------|---------|----------| +| macOS | macOS Keychain | Keychain Access, service: ` Safe Storage` | +| Windows | DPAPI | `%APPDATA%\\` encrypted SQLite | +| Linux | libsecret (GNOME Keyring) / kwallet | `~/.config//` SQLite (encrypted if keyring available) | + +The actual storage is an **encrypted SQLite database** in the user data directory. +Electron's `safeStorage` API encrypts values before writing them to SQLite. + +### Encryption mechanism + +Electron's `safeStorage` is a thin wrapper (~100 lines C++) around Chromium's +`OSCrypt` package: +- Uses **AES-128-CBC** for encryption +- Keys are derived from the OS credential store (Keychain/DPAPI/libsecret) +- **Linux fallback**: If no secret store is available, uses a hardcoded plaintext + password -- effectively no encryption + +### How credentials are provided + +- **Built-in auth**: Cursor/Windsurf use account-based auth, synced to their + cloud service. API keys are sent to their backend with each request. +- **BYOK (Bring Your Own Key)**: Users paste API keys into settings UI. Keys + are stored via `safeStorage` in the encrypted SQLite database. +- **MCP config**: `~/.codeium/windsurf/mcp_config.json` for Windsurf MCP + servers, with `env` blocks for API keys (same pattern as Claude Code) + +### VS Code SecretStorage API (for extension developers) + +```typescript +// Extensions use context.secrets +const token = await context.secrets.get('myExtension.apiKey'); +await context.secrets.store('myExtension.apiKey', 'sk-...'); +``` + +This API was previously backed by `keytar` (now archived), migrated to +Electron's `safeStorage` in VS Code 1.80 (June 2023). + +### References + +- [Cursor API Keys Docs](https://cursor.com/docs/settings/api-keys) +- [Windsurf Provider API Keys](https://windsurf.com/subscription/provider-api-keys) +- [Electron safeStorage Docs](https://www.electronjs.org/docs/latest/api/safe-storage) +- [VS Code keytar migration #185677](https://github.com/microsoft/vscode/issues/185677) +- [VS Code SecretStorage discussion #748](https://github.com/microsoft/vscode-discussions/discussions/748) + +--- + +## 4. Open WebUI + +**Type:** Self-hosted web app (Python/Svelte) + +### Where credentials are stored + +- **Database**: SQLite (default), PostgreSQL, or cloud storage backends +- **Environment variables**: Provider API keys (`OPENAI_API_KEY`, etc.) +- **OAuth client credentials**: Encrypted in database with `OAUTH_CLIENT_INFO_ENCRYPTION_KEY` + +### Encryption at rest + +- OAuth client credentials are encrypted using **Fernet symmetric encryption** + (AES-128-CBC with HMAC-SHA256) with the `OAUTH_CLIENT_INFO_ENCRYPTION_KEY` +- API keys for providers are stored in the database but documentation is unclear + on whether they are encrypted at rest +- SQLite itself can optionally be encrypted + +### How credentials are provided + +- **Web UI**: Admin settings page for configuring provider connections +- **Environment variables**: `OPENAI_API_KEY`, `OLLAMA_BASE_URL`, etc. +- **Per-user API keys**: JWT-based, disabled by default, must be explicitly enabled + +### Special handling + +- Connection management UI to enable/disable individual OpenAI and Ollama + connections +- API key functionality is disabled by default for security +- Supports S3, GCS, Azure Blob for storage backends + +### References + +- [Open WebUI Features](https://docs.openwebui.com/features/) +- [Open WebUI API Keys & Monitoring](https://docs.openwebui.com/reference/monitoring/) +- [Open WebUI Auth & Security (DeepWiki)](https://deepwiki.com/open-webui/open-webui/11-authentication-and-access-control) + +--- + +## 5. AnythingLLM + +**Type:** Self-hosted desktop + Docker app (Node.js/React) + +### Where credentials are stored + +- **SQLite database**: `anythingllm.db` in the storage directory +- **`.env` file**: `server/.env` for environment-level configuration +- **Desktop app**: Local settings in OS-specific app data directory + +### Encryption at rest + +**Credentials are NOT encrypted at rest.** API keys entered through the web UI +are stored in the SQLite database in plaintext or in the `.env` file. The +documentation advises treating the host storage as sensitive. + +### How credentials are provided + +- **Web UI**: Provider configuration pages for each LLM provider (15+ supported) +- **Environment variables**: `.env` file with provider-specific keys + (`OPEN_AI_KEY`, `ANTHROPIC_API_KEY`, etc.) +- **Docker**: Mount `.env` to `/app/server/.env` and storage to + `/app/server/storage` + +### Storage structure + +``` +storage/ + anythingllm.db # SQLite database + vector-cache/ # Embedded file cache + models/ # Locally stored LLMs + plugins/ # Custom agent skills + direct-uploads/ # User-uploaded files +``` + +### References + +- [AnythingLLM Configuration](https://docs.anythingllm.com/configuration) +- [AnythingLLM Desktop Storage](https://docs.anythingllm.com/installation-desktop/storage) +- [AnythingLLM Docker Installation](https://docs.anythingllm.com/installation-docker/local-docker) + +--- + +## 6. LibreChat + +**Type:** Self-hosted web app (Node.js/React, MongoDB backend) + +### Where credentials are stored + +- **MongoDB**: User API keys are encrypted and stored in the database +- **`.env` file**: Server-side provider keys, encryption keys, JWT secrets + +### Encryption at rest + +**Yes -- AES encryption with application-managed keys.** This is one of the more +sophisticated implementations in this survey. + +**Required environment variables:** +```bash +CREDS_KEY= # openssl rand -base64 32 (AES encryption key) +CREDS_IV= # openssl rand -base64 16 (AES initialization vector) +JWT_SECRET= # For session tokens +JWT_REFRESH_SECRET= # For refresh tokens +``` + +The application **will crash on startup** if `CREDS_KEY` and `CREDS_IV` are not +set. Encryption is implemented in `api/server/services/Config/encrypt.js`. + +### How credentials are provided + +- **Admin `.env`**: Server-wide provider API keys +- **User-provided keys**: Users can enter their own API keys via the UI; these + are encrypted with `CREDS_KEY`/`CREDS_IV` before storage in MongoDB +- **Config file reference pattern**: `librechat.yaml` supports `${VARIABLE_NAME}` + syntax to reference environment variables from `.env` +- **MCP server user vars**: `customUserVars` allows per-user credentials for MCP + servers using `{{VARIABLE_NAME}}` syntax + +### Secret reference pattern + +```yaml +# librechat.yaml +endpoints: + custom: + - name: "My Provider" + apiKey: "${MY_PROVIDER_KEY}" # References .env variable +``` + +### Credentials generator tool + +LibreChat provides a [web-based credentials generator](https://www.librechat.ai/toolkit/creds_generator) +to generate `CREDS_KEY`, `CREDS_IV`, `JWT_SECRET`, and `JWT_REFRESH_SECRET`. + +### References + +- [LibreChat Environment Variables](https://www.librechat.ai/docs/configuration/dotenv) +- [LibreChat Credentials Generator](https://www.librechat.ai/toolkit/creds_generator) +- [LibreChat Config Structure](https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/config) +- [Manual Encryption Discussion #6678](https://github.com/danny-avila/LibreChat/discussions/6678) + +--- + +## 7. Home Assistant + +**Type:** Self-hosted homelab platform (Python) + +### Where credentials are stored + +Home Assistant has **three distinct credential storage mechanisms**: + +| Type | Location | Format | Encrypted? | +|------|----------|--------|------------| +| User passwords | `.storage/auth_provider.homeassistant` | JSON | **Hashed + salted** (bcrypt) | +| OAuth2 app credentials | `.storage/application_credentials` | JSON | **No** (plaintext in JSON) | +| Integration config entries | `.storage/core.config_entries` | JSON | **No** (plaintext tokens/keys) | +| User-defined secrets | `secrets.yaml` | YAML | **No** (plaintext) | +| OAuth2 refresh tokens | `.storage/auth` | JSON | **No** (plaintext) | + +### The `secrets.yaml` pattern + +```yaml +# secrets.yaml (NOT encrypted, just separated from config) +slack_token: "xoxb-your-token-here" +github_pat: "ghp_xxxxxxxxxxxx" + +# configuration.yaml +slack: + token: !secret slack_token +``` + +**The `!secret` directive provides separation, not security.** The file is +plaintext. Its purpose is to allow sharing `configuration.yaml` publicly (e.g., +GitHub) while keeping secrets in a `.gitignore`d file. + +### OAuth2 integration flow + +- Modern integrations use the **Application Credentials** component +- Users register OAuth client ID/secret via the web UI +- Home Assistant handles the OAuth2 authorization code flow +- Refresh tokens are stored in `.storage/auth` as plaintext JSON +- Token refresh happens automatically in the background + +### Why no encryption at rest? + +Home Assistant's security model assumes the host filesystem is the trust boundary. +If an attacker has filesystem access, they have access to everything. Encryption +at rest would require a key management solution that doesn't align with the +"run on a Raspberry Pi" deployment model. + +### References + +- [Home Assistant Storing Secrets](https://www.home-assistant.io/docs/configuration/secrets) +- [Home Assistant Application Credentials](https://developers.home-assistant.io/docs/core/platform/application_credentials/) +- [Home Assistant Authentication](https://developers.home-assistant.io/docs/auth_index/) +- [Community: Where are credentials stored?](https://community.home-assistant.io/t/where-are-config-values-credentials-stored/588068) + +--- + +## 8. n8n + +**Type:** Self-hosted workflow automation (Node.js/TypeScript) + +### Where credentials are stored + +- **Database**: SQLite (default) or PostgreSQL +- All credential data is **encrypted before writing to the database** + +### Encryption at rest + +**Yes -- AES-256 encryption.** This is the strongest built-in encryption in the +survey. + +**How it works:** +1. On first launch, n8n generates a random encryption key and saves it to + `~/.n8n/` (or uses `N8N_ENCRYPTION_KEY` environment variable) +2. Every credential is encrypted with AES-256 before database insertion +3. Credentials are decrypted only at workflow execution time + +```bash +# Set custom encryption key +export N8N_ENCRYPTION_KEY="your-32-char-hex-key" +``` + +**Security model:** If an attacker has only database access, decryption is hard. +If they have full server access (including the encryption key file), credentials +are compromised. + +### External secrets (vault integration) + +n8n supports **runtime secret resolution** from external vaults: + +| Provider | Reference Syntax | +|----------|-----------------| +| HashiCorp Vault | `={{ $secrets.vault.secretName }}` | +| AWS Secrets Manager | `={{ $secrets.awsSecretsManager.secretName }}` | +| Infisical | `={{ $secrets.infisical.secretName }}` | +| Azure Key Vault | `={{ $secrets.azureKeyVault.secretName }}` | +| GCP Secret Manager | (supported) | + +Secrets are fetched at runtime, not stored in n8n's database. Secret names must +be alphanumeric with underscores only (no hyphens or spaces). + +### How credentials are provided + +- **Web UI**: Credential creation forms with type-specific fields +- **OAuth flows**: Built-in OAuth2 redirect handling for supported services +- **Environment variables**: `N8N_ENCRYPTION_KEY`, database connection, etc. + +### Cloud vs self-hosted + +n8n Cloud adds Azure server-side encryption (AES-256, FIPS-140-2 compliant) on +top of the application-level encryption. + +### References + +- [n8n Security](https://n8n.io/legal/security/) +- [n8n Custom Encryption Key](https://docs.n8n.io/hosting/configuration/configuration-examples/encryption-key/) +- [n8n External Secrets](https://docs.n8n.io/external-secrets/) +- [Community: How are credentials stored?](https://community.n8n.io/t/how-are-credentials-stored/40166) + +--- + +## 9. Activepieces + +**Type:** Self-hosted workflow automation (TypeScript) + +### Where credentials are stored + +- **Database**: PostgreSQL (primary) or SQLite +- Credentials encrypted before storage + +### Encryption at rest + +**Yes -- AES-256 encryption** with a user-configured key. + +```bash +# .env +AP_ENCRYPTION_KEY= # 256-bit / 32 hex character encryption key +``` + +### Authentication types supported + +- **SecretText**: Masked input for API keys and passwords +- **OAuth2**: Full OAuth2 flow with auth URL, token URL, scope +- **BasicAuth**: Username + password +- **CustomAuth**: Arbitrary properties (base URL, access token, etc.) + +### How credentials are provided + +- **Web UI**: Type-specific credential forms +- **OAuth2**: Built-in redirect flow +- **Predefined connections**: Admin can pre-configure connections for embedding + scenarios + +### References + +- [Activepieces Authentication](https://www.activepieces.com/docs/developers/piece-reference/authentication) +- [Activepieces Predefined Connection](https://www.activepieces.com/docs/embedding/predefined-connection) +- [Activepieces .env.example](https://github.com/activepieces/activepieces/blob/main/.env.example) + +--- + +## 10. Botpress + +**Type:** Cloud-first chatbot platform (TypeScript) + +### Where credentials are stored + +- **Cloud**: Botpress-managed secure storage +- **Integration definitions**: Secrets declared in `integration.definition.ts` + +### How secrets work + +Secrets are defined declaratively in the integration schema: + +```typescript +export default new IntegrationDefinition({ + name: 'my-integration', + secrets: { + CLIENT_ID: { description: 'OAuth Client ID' }, + CLIENT_SECRET: { description: 'OAuth Client Secret' }, + SIGNING_SECRET: { description: 'Webhook Signing Secret' }, + }, +}) +``` + +Secrets are accessed at runtime via `ctx.secrets`: + +```typescript +const handler = async ({ ctx }) => { + const apiKey = ctx.secrets.CLIENT_SECRET; + // ... +} +``` + +### How credentials are provided + +- **Botpress Studio UI**: Users enter secrets when installing an integration +- **OAuth**: Automatic OAuth flow for supported channels (Slack, etc.) +- Secrets are never exposed in plaintext in the UI after initial entry + +### Rasa (comparison) + +Rasa takes a very different approach: + +- **`credentials.yml`**: Plaintext YAML file for channel integrations (Slack + tokens, Facebook secrets, etc.) +- **Environment variables**: Recommended for sensitive values +- **HashiCorp Vault integration**: Enterprise feature via `endpoints.yml` + - Credentials stored in Vault, encrypted at rest + - Transit Engine for additional encryption layer + - Token auto-renewal (15s before expiry) + - Namespace isolation support + +```yaml +# Rasa endpoints.yml +secrets_manager: + type: "vault" + url: "https://vault.example.com" + secrets_path: "rasa-secrets" + token: "${VAULT_TOKEN}" +``` + +### References + +- [Botpress Secrets Docs](https://botpress.com/docs/integration/concepts/secrets/) +- [Botpress Slack Integration](https://botpress.com/docs/cloud/channels/slack) +- [Rasa Secrets Managers](https://rasa.com/docs/rasa/secrets-managers) +- [Rasa Vault Integration](https://rasa.com/docs/reference/integrations/secrets-managers/) + +--- + +## 11. MCP Server Credential Patterns + +MCP (Model Context Protocol) servers use a consistent credential pattern across +all client implementations. + +### Standard pattern: env block injection + +```json +{ + "mcpServers": { + "my-server": { + "command": "npx", + "args": ["-y", "my-mcp-server"], + "env": { + "API_KEY": "sk-actual-key-value", + "DATABASE_URL": "postgres://..." + } + } + } +} +``` + +**Key properties:** +- Environment variables are injected into the subprocess at spawn time +- Each MCP server gets its own isolated env (servers cannot see each other's keys) +- The config file itself is the secret store -- **no env var substitution** +- Process isolation (stdin/stdout) is the security boundary for stdio transport + +### Docker MCP Gateway (newer pattern) + +Docker's MCP Gateway introduces a more sophisticated model: + +- **Secret store**: `docker mcp secret` command manages encrypted secrets +- **OAuth flow**: `docker mcp oauth` handles OAuth token acquisition +- **Runtime scanning**: Gateway scans payloads for leaked secrets +- Secrets managed through Docker Desktop's credential store +- OAuth tokens managed automatically, no plaintext in env vars + +### Workarounds for the "no substitution" problem + +1. **Wrapper script**: Shell script that loads `.env` and exec's the MCP server +2. **`apiKeyHelper`** (Claude Code): Shell command that returns a key +3. **Non-committed config**: Keep `.mcp.json` in `.gitignore`, use a template +4. **Docker secret mounting**: Mount secrets as files in the container + +### References + +- [MCP Configuration is a sh*tshow (Medium)](https://0xhagen.medium.com/mcp-configuration-is-a-sh-tshow-but-heres-how-i-fixed-secrets-handling-5395010762a1) +- [Docker MCP Gateway](https://docs.docker.com/ai/mcp-catalog-and-toolkit/mcp-gateway/) +- [Docker MCP Gateway secret bypass #317](https://github.com/docker/mcp-gateway/issues/317) +- [MCP env var management](https://apxml.com/courses/getting-started-model-context-protocol/chapter-4-debugging-and-client-integration/managing-environment-variables) + +--- + +## 12. OS Keychain / Credential Store APIs + +### Cross-platform comparison + +| Platform | API | Used By | Encryption | +|----------|-----|---------|------------| +| macOS | Keychain Services | Claude Code, Electron apps, GCM | AES-256-GCM, hardware-backed | +| Windows | DPAPI / Credential Manager | Electron apps, GCM, VS Code | DPAPI (user/machine key) | +| Linux | libsecret / Secret Service API | Electron apps (when available) | Depends on backend (GNOME Keyring, KWallet) | +| Linux (fallback) | File with hardcoded key | Electron apps (no keyring) | **Effectively none** | + +### Electron `safeStorage` (used by Cursor, Windsurf, VS Code) + +```javascript +// Encrypt +const encrypted = safeStorage.encryptString('my-api-key'); +// Store encrypted buffer in SQLite or file + +// Decrypt +const decrypted = safeStorage.decryptString(encrypted); +``` + +Technical details: +- Thin wrapper (~100 LOC C++) around Chromium's `OSCrypt` +- AES-128-CBC encryption +- Key derived from OS credential store +- **Linux caveat**: Without a running Secret Service, falls back to a hardcoded + password, providing no real security + +### Git Credential Manager (reference implementation) + +GCM is the gold standard for cross-platform credential storage in CLI tools: + +| Store | Platform | Mechanism | +|-------|----------|-----------| +| `wincredman` | Windows | Windows Credential Manager (DPAPI) | +| `dpapi` | Windows | DPAPI-encrypted files in `%USERPROFILE%\.gcm\` | +| `keychain` | macOS | macOS Keychain | +| `secretservice` | Linux | libsecret/Secret Service API | +| `gpg` | Linux | GPG-encrypted files | +| `cache` | All | In-memory, no persistence | +| `plaintext` | All | **Plaintext file** (last resort) | + +### References + +- [Electron safeStorage](https://www.electronjs.org/docs/latest/api/safe-storage) +- [Git Credential Manager credential stores](https://github.com/git-ecosystem/git-credential-manager/blob/release/docs/credstores.md) +- [VS Code Secret Storage discussion](https://github.com/microsoft/vscode-discussions/discussions/748) + +--- + +## 13. .NET Ecosystem Patterns + +### `dotnet user-secrets` (development only) + +**Storage:** +- Windows: `%APPDATA%\Microsoft\UserSecrets\\secrets.json` +- macOS/Linux: `~/.microsoft/usersecrets//secrets.json` + +**Encryption: None.** Plaintext JSON. Explicitly documented as "not a trusted +store" -- development only. + +```bash +# Initialize +dotnet user-secrets init + +# Set a secret +dotnet user-secrets set "Slack:BotToken" "xoxb-..." + +# Access in code +builder.Configuration.AddUserSecrets(); +var token = config["Slack:BotToken"]; +``` + +### ASP.NET Core Data Protection API (production) + +The Data Protection API is the .NET ecosystem's answer to cross-platform +credential encryption: + +**Default behavior (Windows):** +- Keys stored in `%LOCALAPPDATA%\ASP.NET\DataProtection-Keys` +- Encrypted at rest with DPAPI + +**Default behavior (Linux/macOS):** +- Keys stored in user home directory +- **NOT encrypted at rest** by default (no DPAPI equivalent) +- Must explicitly configure encryption + +**Configuration options:** + +```csharp +// File system with certificate encryption +services.AddDataProtection() + .PersistKeysToFileSystem(new DirectoryInfo("/keys")) + .ProtectKeysWithCertificate(cert); + +// Database with Azure Key Vault +services.AddDataProtection() + .PersistKeysToDbContext() + .ProtectKeysWithAzureKeyVault(keyUri, credential); + +// Redis +services.AddDataProtection() + .PersistKeysToStackExchangeRedis(redis, "DataProtection-Keys"); +``` + +**Key protection options:** +- X.509 certificate (cross-platform) +- Windows DPAPI (Windows only) +- Windows DPAPI-NG with certificate rule (Windows Server 2012 R2+) +- Azure Key Vault +- Null protector (no encryption -- for testing) + +### Production patterns for self-hosted .NET apps + +1. **Azure Key Vault**: `Azure.Extensions.AspNetCore.Configuration.Secrets` + package. Secrets appear as regular `IConfiguration` keys. +2. **Environment variables**: Standard `IConfiguration` provider, highest + priority in the default chain. +3. **Docker secrets**: Mounted as files, read via file-based config provider. +4. **Data Protection API**: For encrypting/decrypting arbitrary data at the + application level, with pluggable key storage and protection backends. + +### References + +- [ASP.NET Core App Secrets](https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets) +- [Data Protection Configuration](https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/overview) +- [Key Storage Providers](https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/implementation/key-storage-providers) +- [Secrets Management in .NET (Auth0)](https://auth0.com/blog/secret-management-in-dotnet-applications/) + +--- + +## Comparative Analysis + +### Encryption at rest + +| Project | Encrypted at rest? | Method | Key management | +|---------|-------------------|--------|----------------| +| Claude Code (macOS) | Yes | OS Keychain | OS-managed | +| Claude Code (Linux) | **No** | Plaintext JSON | N/A | +| OpenCode | **No** | Plaintext JSON | N/A | +| Cursor/Windsurf | Yes* | Electron safeStorage (AES-128) | OS keychain | +| Open WebUI | Partial | Fernet for OAuth | App env var | +| AnythingLLM | **No** | Plaintext in SQLite/.env | N/A | +| LibreChat | Yes | AES (custom key/IV) | User-provided env vars | +| Home Assistant | **No** (secrets) / Hashed (passwords) | bcrypt for passwords | N/A | +| n8n | Yes | AES-256 | Auto-generated or env var | +| Activepieces | Yes | AES-256 | User-provided env var | +| Botpress | Yes (cloud) | Platform-managed | Platform-managed | +| Rasa + Vault | Yes | Vault Transit Engine | Vault-managed | + +*Cursor/Windsurf on Linux without a keyring daemon: effectively no encryption. + +### Credential provision methods + +| Method | Used By | +|--------|---------| +| Environment variables | All projects | +| Web UI form | Open WebUI, AnythingLLM, LibreChat, n8n, Activepieces, Botpress, Home Assistant | +| CLI interactive | Claude Code, OpenCode | +| Config file | Home Assistant (secrets.yaml), Rasa (credentials.yml), MCP (.mcp.json) | +| OAuth browser flow | Claude Code, n8n, Activepieces, Botpress, Home Assistant, Docker MCP | +| OS keychain | Claude Code (macOS), Electron apps | +| External vault | n8n, Rasa, Docker MCP Gateway | +| Shell script helper | Claude Code (apiKeyHelper) | + +### Subprocess credential passing + +| Method | Used By | Security Model | +|--------|---------|---------------| +| Env var injection at spawn | MCP servers, Docker, n8n | Process isolation | +| Stdin/stdout pipe | MCP stdio transport | Process isolation | +| File mount | Docker secrets, Kubernetes | Filesystem permissions | +| Runtime vault fetch | n8n external secrets, Rasa | Network + auth | +| HTTP header injection | LibreChat MCP, Docker Gateway | TLS + auth | + +--- + +## Patterns Relevant to Netclaw + +Given Netclaw is a self-hosted .NET homelab assistant with Slack integration: + +### Immediate patterns to consider + +1. **Home Assistant's `secrets.yaml` model**: Simple, well-understood by homelab + users. Plaintext but separated from config. Effective for single-user, + single-machine deployments where filesystem = trust boundary. + +2. **n8n's encryption model**: AES-256 with an auto-generated key stored on + first run. Good balance of security and usability. The key can be + user-provided via env var for advanced deployments. + +3. **ASP.NET Core Data Protection**: Native .NET, cross-platform, pluggable + backends. Could encrypt credentials at rest with DPAPI on Windows or + X.509 certificate on Linux. Already part of the framework. + +4. **`dotnet user-secrets` for development**: Keep Slack tokens and API keys + out of `appsettings.json` during development. Already standard .NET practice. + +5. **Environment variable injection for tools/MCP**: When Netclaw spawns tool + subprocesses, pass credentials via env vars (not command-line args, which + appear in `ps` output). + +### Architecture decision points + +- **Single-user homelab**: Home Assistant's model (plaintext secrets file, + filesystem trust boundary) is arguably sufficient and is the simplest to + implement and support. +- **Multi-user or shared access**: n8n/LibreChat's model (AES encryption with + app-managed key) becomes necessary. +- **Enterprise/corporate**: External vault integration (n8n's pattern) or + ASP.NET Core Data Protection with Azure Key Vault. + +### Implementation tiers + +**Tier 1 (MVP):** +- `appsettings.json` + environment variable overrides for provider API keys +- `dotnet user-secrets` for development +- Slack OAuth tokens stored in Akka persistence (serialized state) +- No encryption at rest beyond filesystem permissions + +**Tier 2 (hardened):** +- ASP.NET Core Data Protection for encrypting stored credentials +- Auto-generated encryption key on first run (n8n pattern) +- `NETCLAW_ENCRYPTION_KEY` env var override for advanced deployments +- Separate secrets from config (Home Assistant pattern) + +**Tier 3 (enterprise):** +- Azure Key Vault / HashiCorp Vault integration via `IConfiguration` +- External secret resolution at runtime (n8n external secrets pattern) +- Per-user credential isolation + +--- + +## Key Takeaways + +1. **Almost no one encrypts credentials at rest in single-user self-hosted + tools.** Claude Code, OpenCode, Home Assistant, AnythingLLM -- all store + plaintext. The security model is "if they have filesystem access, game over." + +2. **Multi-user web apps encrypt because they must.** n8n, LibreChat, and + Activepieces all encrypt because their database might be on a shared server + or backed up to cloud storage. + +3. **OS keychain is the gold standard for desktop/CLI tools** but has poor + Linux support (requires a running keyring daemon, which headless servers + don't have). + +4. **Environment variables are the universal credential transport** -- every + single project uses them for at least some credentials. + +5. **The "secret reference" pattern is rare** outside of workflow tools. Most + projects either embed credentials or use env vars. LibreChat's + `${VAR_NAME}` and n8n's `={{ $secrets.vault.name }}` are notable exceptions. + +6. **OAuth token lifecycle management is uniformly poor.** Most projects store + refresh tokens but handle rotation ad-hoc. Only Rasa (via Vault) and Docker + MCP Gateway have structured token lifecycle management. + +7. **The .NET ecosystem has strong primitives** (Data Protection API, user-secrets, + IConfiguration) that most of these projects would benefit from if they were + .NET-based. Netclaw can leverage these immediately. diff --git a/docs/spec/README.md b/docs/spec/README.md index e6c2f34ee..38798c51d 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -14,5 +14,6 @@ This directory contains implementation-facing specifications derived from PRDs. - `SPEC-008-model-provider-abstraction.md` (PRD-005) - `SPEC-009-mcp-integration.md` (PRD-006) - `SPEC-010-testing-and-smoke-strategy.md` (PRD-001, PRD-005) +- `SPEC-011-daemon-architecture.md` (PRD-001, PRD-002, PRD-004) OpenSpec equivalents live in `openspec/specs/` and should remain aligned. diff --git a/docs/spec/SPEC-011-daemon-architecture.md b/docs/spec/SPEC-011-daemon-architecture.md new file mode 100644 index 000000000..fc8150cf6 --- /dev/null +++ b/docs/spec/SPEC-011-daemon-architecture.md @@ -0,0 +1,279 @@ +# SPEC-011: Daemon Architecture and Process Model + +Source PRDs: `PRD-001`, `PRD-002`, `PRD-004` + +## Purpose + +Define the single-process daemon model, mode selection, gateway surface, +configuration hot-reload, and deployment model for Netclaw. + +This spec complements: +- `SPEC-001` (runtime boundaries — logical separation within the process) +- `SPEC-004` (CLI contract — command surface) +- `SPEC-006` (gateway exposure — network access controls) +- `SPEC-007` (guided onboarding — init wizard flow) + +Research basis: `docs/research/agent-gateway-architecture.md` — analysis of +OpenClaw, IronClaw, and ZeroClaw validates single-process architecture for +homelab/personal agent use. + +## Single-Process Model + +Netclaw runs as a single OS process. All components — Akka actor system, +persistence, gateway endpoints, TUI, and tool execution — share one process +boundary. This matches the consensus architecture from OpenClaw, IronClaw, and +ZeroClaw. + +The CLI/TUI is "just another channel" that uses the same `SessionPipeline` +abstraction as any other channel adapter (Slack, scheduled tasks, webhooks). + +### Logical Boundaries (within one process) + +``` +┌────────────────────────────────────────────────────────────┐ +│ Netclaw Process │ +│ │ +│ ┌──────────────────┐ ┌────────────────────────────────┐ │ +│ │ Presentation │ │ Gateway │ │ +│ │ │ │ │ │ +│ │ Termina TUI │ │ SignalR Hub (/hub/session) │ │ +│ │ Headless Client │ │ Health Probe (/api/health) │ │ +│ └────────┬─────────┘ └──────────────┬─────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ SessionPipeline │ │ +│ │ (Akka.Streams — typed Input/Output channels) │ │ +│ └────────────────────────┬────────────────────────────┘ │ +│ │ │ +│ ┌────────────────────────▼────────────────────────────┐ │ +│ │ Akka Actor System │ │ +│ │ SessionManager → LlmSessionActor (per session) │ │ +│ │ Persistence (journal + snapshots) │ │ +│ │ Tool Execution (shell, file, MCP) │ │ +│ └─────────────────────────────────────────────────────┘ │ +└────────────────────────────────────────────────────────────┘ +``` + +In-process channels (TUI, headless) call `SessionPipeline.CreateAsync()` +directly — no network hop. The SignalR hub uses the same `SessionPipeline` +for remote clients. + +## Mode Selection + +The executable supports multiple modes selected by command-line arguments. +Modes fall into two categories based on service requirements. + +### Daemon Modes (full service stack) + +These modes boot the complete service stack: Akka actor system, persistence, +`SessionPipeline`, tool registry, SignalR hub, and health endpoint. + +| Command | Behavior | +|---------|----------| +| `netclaw run` | Daemon only. Hosts actors, gateway, Slack adapter, scheduler. Runs as a service. | +| `netclaw chat` | Daemon + Termina TUI. Interactive chat via `SessionPipeline`. | +| `netclaw -p "prompt"` | Daemon + headless client. Single turn via `SessionPipeline`, exits on `TurnCompleted`. | + +### Lightweight Modes (config services only) + +These modes boot a minimal host with configuration services only. No Akka +actor system, no persistence, no SignalR, no tool execution. + +| Command | Behavior | +|---------|----------| +| `netclaw init` | Reentrant TUI wizard for provider/model/Slack/MCP configuration. | +| `netclaw doctor` | Health checks against config files and provider connectivity. | + +### Service Registration Split + +Shared services (all modes): +- `NetclawPaths` — directory layout +- `IConfiguration` chain — netclaw.json + secrets.json + NETCLAW_* env vars +- `ChatClientFactory` — creates `IChatClient` from provider config +- `IChatClientProvider` — resolves clients by model role +- `TimeProvider` — virtualized time + +Daemon-only services: +- Akka actor system (with `WithNetclawActors()`) +- Persistence (journal + snapshot store) +- `SessionPipeline` — stream factory for channels +- `ToolRegistry` + `IToolExecutor` — tool execution +- `ISystemPromptProvider` — layered system prompt assembly +- `ConfigWatcherService` — file system hot-reload +- SignalR hub + +### Host Selection + +Lightweight modes use `Host.CreateApplicationBuilder()` (standard .NET host). +Daemon modes use `WebApplication.CreateBuilder()` (ASP.NET host for SignalR +and health endpoints). + +## Gateway Surface + +### Phase 1 (MVP) + +Minimal external surface. In-process channels are the primary interaction model. + +**SignalR Hub** (`/hub/session`): +Mapped and documented for future remote clients (Blazor ops console, remote +CLI). Not actively used by the TUI or headless modes in Phase 1. + +Contract: +``` +Client → Server: + CreateSession(channelType: string) → sessionId: string + SendMessage(sessionId: string, text: string) → void + +Server → Client: + ReceiveOutput(output: SessionOutputDto) → void +``` + +`SessionOutputDto` is a wire-safe mapping of `SessionOutput` (the actor +protocol type). The mapper handles discriminated union → flat DTO conversion. + +**Health Probe** (`GET /api/health/ready`): +Returns `200 OK` when the host is accepting connections. Used for Docker +health checks and external monitoring. + +### Future Phases + +REST endpoints for schedule CRUD, project management, tool listing, etc. will +be added when remote clients (Blazor ops console) require them. The ASP.NET +pipeline is already in place — no architectural debt. + +## Tool Execution Model + +Tools execute on the host process. The daemon runs shell commands, file +operations, Docker commands, and MCP tool calls. This is the only model that +works for: + +- **Slack channel**: No client process to delegate tool execution to. +- **Scheduled tasks**: Execute autonomously without any connected client. +- **Docker deployment**: Tools need access to the host's Docker socket. + +The TUI is a presentation layer — it renders tool call/result output but does +not execute tools. + +## Configuration Hot-Reload + +### Trigger + +`FileSystemWatcher` on `~/.netclaw/config/` monitors for changes to +`netclaw.json` and `secrets.json`. + +### Behavior + +1. File change event received +2. 500ms debounce timer started (reset on additional events) +3. After debounce: read and validate new configuration +4. **Valid config**: rebuild `IChatClientProvider`, notify actor system +5. **Invalid config**: log warning with validation errors, preserve previous config + +### What Reloads + +- Provider credentials and endpoints +- Model selections (main, fallback, compaction) +- Session parameters (compaction threshold, tool iteration limits) +- Tool configuration (shell timeout, output limits) + +### What Does Not Reload (requires restart) + +- Akka actor system configuration +- Persistence provider +- Network binding / exposure mode + +### Sources of Config Changes + +- `netclaw init` TUI wizard (writes incrementally per section) +- Manual file editing by operator +- Agent self-configuration via `config_write` tool grant (SEC-008) + +All changes go to disk first. The `FileSystemWatcher` is the single reload +trigger — there is no in-memory config mutation path. + +## Reentrant Init Wizard + +`netclaw init` is designed for both first-run and reconfiguration. + +### First Run + +Linear guided flow through all sections (per SPEC-007). Config written +incrementally as each section completes. + +### Subsequent Runs + +Dashboard view showing current configuration state per section. Each section +shows status (configured/unconfigured/error). Operator can jump to any section +to modify or add entries. + +### Sections + +| Section | Purpose | +|---------|---------| +| Providers | Add/modify LLM provider endpoints and credentials | +| Models | Assign provider + model to each role (main, fallback, compaction) | +| Slack | Bot token, app token, Socket Mode configuration | +| Persistence | PostgreSQL connection string (future, in-memory for MVP) | +| MCP | Add/modify MCP server connections | +| Exposure | Choose network exposure mode (local/tailscale/cloudflare) | +| Health Check | Run validation across all configured services | + +### Provider Onboarding Flow (within Providers section) + +1. Choose provider type (Ollama, OpenRouter, future: Anthropic, OpenAI) +2. Enter endpoint URL +3. Enter API key (if required, masked input) +4. Test connectivity (direct HTTP to provider endpoint) +5. List available models from provider +6. Select default model +7. Write to `netclaw.json` / `secrets.json` + +This is a local operation — the wizard calls `ChatClientFactory` and provider +APIs directly through DI. No daemon or REST endpoint required. + +## Security Context + +Every connection (SignalR, in-process channel) carries a `ChannelSecurityContext` +that identifies the trust level. + +| Level | Description | Phase | +|-------|-------------|-------| +| `LocalOperator` | Local connection, full trust | Phase 1 (MVP) | +| `Authenticated` | Validated remote sender, ACL-gated | Future | +| `Anonymous` | Default deny | Future | + +In Phase 1, all connections are `LocalOperator` and the gateway binds +loopback-only (SEC-005). + +## Docker Deployment Model + +Recommended production deployment: + +```bash +docker run -d \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v ~/.netclaw:/root/.netclaw \ + -p 127.0.0.1:5000:5000 \ + netclaw run +``` + +- Docker socket mount enables host container management +- Config volume persists across container restarts +- Port binding on loopback only (SEC-005 default) +- Container includes Docker CLI, git, and other management tools + +Tools executed by the agent (shell commands, Docker operations) run inside the +container and reach the host Docker daemon via the mounted socket. This is the +same pattern used by Portainer, Watchtower, and Dockge. + +## Cross-References + +- Runtime boundaries: SPEC-001 +- Session lifecycle: SPEC-002 +- Security controls: SPEC-003 +- CLI contract: SPEC-004 +- Operator UI: SPEC-005 +- Gateway exposure: SPEC-006 +- Guided onboarding: SPEC-007 +- Architecture research: `docs/research/agent-gateway-architecture.md` diff --git a/docs/ui/TUI-001-command-wireframes.md b/docs/ui/TUI-001-command-wireframes.md index ab4bd7060..8e035a0eb 100644 --- a/docs/ui/TUI-001-command-wireframes.md +++ b/docs/ui/TUI-001-command-wireframes.md @@ -4,15 +4,15 @@ Source PRDs: `PRD-004`, `PRD-009` ## Overview -Netclaw's CLI uses **Cocona** for command routing and **Termina 0.5.1** for -interactive TUI commands. Only two commands use Termina TUI rendering — all -other commands use plain console output via Cocona. +Netclaw's CLI uses **simple arg routing** in `Program.cs` for mode selection and +**Termina 0.5.1** for interactive TUI commands. Only two commands use Termina +TUI rendering — all other commands use plain console output. | Command | Interface | Framework | |-----------------|-----------|-----------| -| `netclaw init` | TUI | Termina | -| `netclaw chat` | TUI | Termina | -| All others | Plain CLI | Cocona | +| `netclaw init` | TUI | Termina (lightweight mode — no Akka) | +| `netclaw chat` | TUI | Termina (daemon mode — full stack) | +| All others | Plain CLI | Plain console output | ## Termina Component Vocabulary @@ -232,7 +232,7 @@ Result: 1 error, 1 warning. Netclaw cannot start. ## Commands That Stay Plain CLI (No TUI) -All of the following commands use standard console output via Cocona. +All of the following commands use standard console output. No Termina TUI components are used. | Command | Output Style | @@ -265,5 +265,5 @@ This is the primary production entry point. - CLI command surface: PRD-004 - TUI adapter contract: PRD-009 - Ops console (web): UI-001 -- Cocona framework: implementation decision +- Daemon architecture and mode selection: SPEC-011 - Termina 0.5.1: implementation decision diff --git a/src/Netclaw.App/ConsoleChannel.cs b/src/Netclaw.App/ConsoleChannel.cs deleted file mode 100644 index c4bd253fd..000000000 --- a/src/Netclaw.App/ConsoleChannel.cs +++ /dev/null @@ -1,215 +0,0 @@ -using Akka.Actor; -using Akka.Streams; -using Akka.Streams.Dsl; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Netclaw.Actors.Channels; -using Netclaw.Configuration; -using Netclaw.Actors.Protocol; -using Netclaw.Channels; - -namespace Netclaw.App; - -/// -/// Interactive console channel. Reads user input from stdin and renders -/// session output to stdout with color formatting. -/// -/// Creates a single session for the channel's lifetime using a -/// for stream-based communication. -/// -/// All session activity is logged to ~/.netclaw/logs/{sessionId}.log. -/// Console output is reserved exclusively for the chat UI. -/// -public sealed class ConsoleChannel : IChannel -{ - private readonly SessionPipeline _pipeline; - private readonly ActorSystem _system; - private readonly NetclawPaths _paths; - private readonly IHostApplicationLifetime _lifetime; - private readonly TimeProvider _timeProvider; - private readonly ILogger _logger; - - private CancellationTokenRegistration _shutdownRegistration; - private MaterializedSession? _session; - - public string ChannelType => "console"; - public string DisplayName => "Console Chat"; - - public ChannelHealth GetHealth() => _session is not null - ? new ChannelHealth(ChannelHealthStatus.Healthy) - : new ChannelHealth(ChannelHealthStatus.Disconnected, "No active session"); - - public ConsoleChannel( - SessionPipeline pipeline, - ActorSystem system, - NetclawPaths paths, - IHostApplicationLifetime lifetime, - TimeProvider timeProvider, - ILogger logger) - { - _pipeline = pipeline; - _system = system; - _paths = paths; - _lifetime = lifetime; - _timeProvider = timeProvider; - _logger = logger; - } - - public Task StartAsync(CancellationToken cancellationToken) - { - _shutdownRegistration = _lifetime.ApplicationStarted.Register(() => - { - _ = Task.Run(() => RunChatLoopAsync(_lifetime.ApplicationStopping), CancellationToken.None); - }); - - return Task.CompletedTask; - } - - public async Task StopAsync(CancellationToken cancellationToken) - { - if (_session is not null) - await _session.DisposeAsync(); - _shutdownRegistration.Dispose(); - } - - private async Task RunChatLoopAsync(CancellationToken stopping) - { - try - { - var sessionId = new SessionId($"console/{Guid.NewGuid():N}"); - - // Set up session log file - _paths.EnsureDirectoriesExist(); - var logFileName = $"{sessionId.Value.Replace("/", "-")}.log"; - var logPath = Path.Combine(_paths.LogsDirectory, logFileName); - var logWriter = new StreamWriter(logPath, append: false) { AutoFlush = true }; - - logWriter.WriteLine($"[{_timeProvider.GetUtcNow():o}] Session started: {sessionId}"); - - // Create session pipeline - _session = await _pipeline.CreateAsync(sessionId, new SessionPipelineOptions - { - ChannelType = ChannelType - }, stopping); - - // Materialize output stream → console rendering + disk logging - _session.Output - .To(Sink.ForEach(output => RenderOutput(output, logWriter))) - .Run(_system); - - // Materialize input with queue for imperative push from readline - var inputQueue = Source.Queue(16, OverflowStrategy.Backpressure) - .ToMaterialized(_session.Input, Keep.Left) - .Run(_system); - - _logger.LogInformation("Session started: {SessionId} (log: {LogPath})", sessionId, logPath); - Console.WriteLine(); - Console.WriteLine($"Netclaw console chat (log: {logPath})"); - Console.WriteLine("Type 'exit' to quit."); - Console.WriteLine("──────────────────────────────────────────"); - Console.WriteLine(); - - while (!stopping.IsCancellationRequested) - { - Console.Write("You> "); - var input = Console.ReadLine(); - - if (input is null || string.Equals(input.Trim(), "exit", StringComparison.OrdinalIgnoreCase)) - { - logWriter.WriteLine($"[{_timeProvider.GetUtcNow():o}] User exited chat"); - _lifetime.StopApplication(); - break; - } - - if (string.IsNullOrWhiteSpace(input)) - continue; - - logWriter.WriteLine($"[{_timeProvider.GetUtcNow():o}] USER: {input}"); - - await inputQueue.OfferAsync(new ChannelInput - { - SenderId = "local-user", - Contents = [new TextContent(input)], - ReceivedAt = _timeProvider.GetUtcNow() - }); - } - } - catch (OperationCanceledException ex) - { - _logger.LogDebug(ex, "Console chat loop cancelled (shutdown)"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Console chat loop failed"); - _lifetime.StopApplication(); - } - } - - private static void RenderOutput(SessionOutput output, StreamWriter log) - { - switch (output) - { - case SessionJoined msg: - Log(log, $"SESSION_JOINED turn_count={msg.TurnCount} title={msg.Title ?? "(none)"}"); - break; - - case TextOutput msg: - Console.WriteLine(); - Console.WriteLine($"Netclaw> {msg.Text}"); - Console.WriteLine(); - Log(log, $"ASSISTANT: {msg.Text}"); - break; - - case ThinkingOutput msg: - Console.ForegroundColor = ConsoleColor.DarkGray; - Console.WriteLine($" [thinking] {msg.Text}"); - Console.ResetColor(); - Log(log, $"THINKING: {msg.Text}"); - break; - - case ToolCallOutput msg: - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine($" [tool] {msg.ToolName}({msg.ArgumentsJson ?? ""})"); - Console.ResetColor(); - Log(log, $"TOOL_CALL: {msg.ToolName} call_id={msg.CallId} args={msg.ArgumentsJson ?? "{}"}"); - break; - - case ToolResultOutput msg: - Log(log, $"TOOL_RESULT: {msg.ToolName} call_id={msg.CallId} result={msg.Result}"); - break; - - case UsageOutput msg: - var usage = msg.UsagePercent.HasValue - ? $" ({msg.UsagePercent.Value:P0} context)" - : ""; - Log(log, $"USAGE: in={msg.InputTokens} out={msg.OutputTokens} total={msg.TotalTokens} cached={msg.CachedInputTokens} reasoning={msg.ReasoningTokens} context_window={msg.ContextWindowTokens}{usage}"); - break; - - case ErrorOutput msg: - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($" [error] {msg.Message}"); - Console.ResetColor(); - Log(log, $"ERROR: {msg.Message}"); - if (msg.Cause is not null) - Log(log, $"EXCEPTION: {msg.Cause}"); - break; - - case TurnCompleted msg: - Log(log, $"TURN_COMPLETED: turn={msg.TurnNumber}"); - break; - - case CompactionOutput msg: - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($" [compaction] {msg.MessagesBefore} → {msg.MessagesAfter} messages"); - Console.ResetColor(); - Log(log, $"COMPACTION: before={msg.MessagesBefore} after={msg.MessagesAfter} tool_results_cleared={msg.ToolResultsCleared} summarized={msg.Summarized}"); - break; - } - } - - private static void Log(StreamWriter log, string message) - { - log.WriteLine($"[{DateTimeOffset.UtcNow:o}] {message}"); - } -} diff --git a/src/Netclaw.App/Gateway/ChannelSecurityContext.cs b/src/Netclaw.App/Gateway/ChannelSecurityContext.cs new file mode 100644 index 000000000..c01c8ee6a --- /dev/null +++ b/src/Netclaw.App/Gateway/ChannelSecurityContext.cs @@ -0,0 +1,35 @@ +namespace Netclaw.App.Gateway; + +/// +/// Trust level for a connection to the Netclaw gateway. +/// Every connection (SignalR, in-process channel) carries a security context +/// that identifies the trust level. See SPEC-011 §Security Context. +/// +public enum SecurityTrust +{ + /// Local connection, full trust. Only level implemented in Phase 1. + LocalOperator, + + /// Validated remote sender, ACL-gated. Future. + Authenticated, + + /// Default deny. Future. + Anonymous +} + +/// +/// Security context attached to every gateway connection. +/// Phase 1: all connections are . +/// +public sealed record ChannelSecurityContext +{ + public required SecurityTrust Trust { get; init; } + + public string? SenderId { get; init; } + + /// + /// Creates a local operator context (full trust, Phase 1 default). + /// + public static ChannelSecurityContext LocalOperator(string? senderId = null) => + new() { Trust = SecurityTrust.LocalOperator, SenderId = senderId }; +} diff --git a/src/Netclaw.App/Gateway/SessionHub.cs b/src/Netclaw.App/Gateway/SessionHub.cs new file mode 100644 index 000000000..bd608c9b1 --- /dev/null +++ b/src/Netclaw.App/Gateway/SessionHub.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.SignalR; + +namespace Netclaw.App.Gateway; + +/// +/// SignalR hub for remote session access. Bridges remote clients +/// (future Blazor ops console, remote CLI) to . +/// +/// Phase 1: mapped at /hub/session but not actively used by TUI or headless +/// modes (they use SessionPipeline directly, in-process). +/// +/// Contract: +/// +/// Client → Server: +/// CreateSession(channelType: string) → sessionId: string +/// SendMessage(sessionId: string, text: string) → void +/// +/// Server → Client: +/// ReceiveOutput(output: SessionOutputDto) → void +/// +/// +public sealed class SessionHub : Hub +{ + // Phase 1 stub — hub is mapped but not wired to SessionPipeline. + // Implementation deferred until remote clients (Blazor ops console) need it. + + public Task CreateSession(string channelType) + { + // TODO: wire to SessionPipeline.CreateAsync() when remote clients are implemented + throw new HubException("Remote sessions are not yet implemented. Use netclaw chat for local sessions."); + } + + public Task SendMessage(string sessionId, string text) + { + // TODO: wire to materialized session input queue + throw new HubException("Remote sessions are not yet implemented. Use netclaw chat for local sessions."); + } +} diff --git a/src/Netclaw.App/Gateway/SessionOutputDto.cs b/src/Netclaw.App/Gateway/SessionOutputDto.cs new file mode 100644 index 000000000..6f13ce0e0 --- /dev/null +++ b/src/Netclaw.App/Gateway/SessionOutputDto.cs @@ -0,0 +1,50 @@ +namespace Netclaw.App.Gateway; + +/// +/// Wire-safe DTO for session output. Flattens the discriminated union +/// () into a single +/// serializable type for SignalR transport. +/// +public sealed record SessionOutputDto +{ + /// + /// Output type discriminator (e.g. "text", "thinking", "tool_call", + /// "tool_result", "usage", "turn_completed", "error", "compaction", + /// "session_joined", "session_title"). + /// + public required string Type { get; init; } + + public required string SessionId { get; init; } + + public long TimestampMs { get; init; } + + // Text / Thinking + public string? Text { get; init; } + + // Tool Call / Tool Result + public string? CallId { get; init; } + public string? ToolName { get; init; } + public string? ArgumentsJson { get; init; } + public string? Result { get; init; } + + // Usage + public long? InputTokens { get; init; } + public long? OutputTokens { get; init; } + public long? TotalTokens { get; init; } + public int? ContextWindowTokens { get; init; } + public double? UsagePercent { get; init; } + + // Turn Completed + public int? TurnNumber { get; init; } + + // Error + public string? ErrorMessage { get; init; } + + // Compaction + public int? MessagesBefore { get; init; } + public int? MessagesAfter { get; init; } + + // Session Joined + public string? Title { get; init; } + public int? TurnCount { get; init; } +} diff --git a/src/Netclaw.App/Gateway/SessionOutputMapper.cs b/src/Netclaw.App/Gateway/SessionOutputMapper.cs new file mode 100644 index 000000000..851a673bb --- /dev/null +++ b/src/Netclaw.App/Gateway/SessionOutputMapper.cs @@ -0,0 +1,110 @@ +using Netclaw.Actors.Protocol; + +namespace Netclaw.App.Gateway; + +/// +/// Maps discriminated union types to +/// for wire transport. +/// +public static class SessionOutputMapper +{ + public static SessionOutputDto ToDto(SessionOutput output) => output switch + { + TextOutput msg => new SessionOutputDto + { + Type = "text", + SessionId = msg.SessionId.Value, + TimestampMs = msg.TimestampMs, + Text = msg.Text + }, + + ThinkingOutput msg => new SessionOutputDto + { + Type = "thinking", + SessionId = msg.SessionId.Value, + TimestampMs = msg.TimestampMs, + Text = msg.Text + }, + + ToolCallOutput msg => new SessionOutputDto + { + Type = "tool_call", + SessionId = msg.SessionId.Value, + TimestampMs = msg.TimestampMs, + CallId = msg.CallId, + ToolName = msg.ToolName, + ArgumentsJson = msg.ArgumentsJson + }, + + ToolResultOutput msg => new SessionOutputDto + { + Type = "tool_result", + SessionId = msg.SessionId.Value, + TimestampMs = msg.TimestampMs, + CallId = msg.CallId, + ToolName = msg.ToolName, + Result = msg.Result + }, + + UsageOutput msg => new SessionOutputDto + { + Type = "usage", + SessionId = msg.SessionId.Value, + TimestampMs = msg.TimestampMs, + InputTokens = msg.InputTokens, + OutputTokens = msg.OutputTokens, + TotalTokens = msg.TotalTokens, + ContextWindowTokens = msg.ContextWindowTokens, + UsagePercent = msg.UsagePercent + }, + + TurnCompleted msg => new SessionOutputDto + { + Type = "turn_completed", + SessionId = msg.SessionId.Value, + TimestampMs = msg.TimestampMs, + TurnNumber = msg.TurnNumber + }, + + SessionTitleOutput msg => new SessionOutputDto + { + Type = "session_title", + SessionId = msg.SessionId.Value, + TimestampMs = msg.TimestampMs, + Title = msg.Title + }, + + ErrorOutput msg => new SessionOutputDto + { + Type = "error", + SessionId = msg.SessionId.Value, + TimestampMs = msg.TimestampMs, + ErrorMessage = msg.Message + }, + + CompactionOutput msg => new SessionOutputDto + { + Type = "compaction", + SessionId = msg.SessionId.Value, + TimestampMs = msg.TimestampMs, + MessagesBefore = msg.MessagesBefore, + MessagesAfter = msg.MessagesAfter + }, + + SessionJoined msg => new SessionOutputDto + { + Type = "session_joined", + SessionId = msg.SessionId.Value, + TimestampMs = msg.TimestampMs, + Title = msg.Title, + TurnCount = msg.TurnCount + }, + + _ => new SessionOutputDto + { + Type = "unknown", + SessionId = output.SessionId.Value, + TimestampMs = output.TimestampMs + } + }; +} diff --git a/src/Netclaw.App/HeadlessChannel.cs b/src/Netclaw.App/HeadlessChannel.cs index 1c8521f04..76d270f29 100644 --- a/src/Netclaw.App/HeadlessChannel.cs +++ b/src/Netclaw.App/HeadlessChannel.cs @@ -169,8 +169,8 @@ private void HandleOutput(SessionOutput output, StreamWriter log) } } - private static void Log(StreamWriter log, string message) + private void Log(StreamWriter log, string message) { - log.WriteLine($"[{DateTimeOffset.UtcNow:o}] {message}"); + log.WriteLine($"[{_timeProvider.GetUtcNow():o}] {message}"); } } diff --git a/src/Netclaw.App/Netclaw.App.csproj b/src/Netclaw.App/Netclaw.App.csproj index 20e7e6a96..f4d50fa86 100644 --- a/src/Netclaw.App/Netclaw.App.csproj +++ b/src/Netclaw.App/Netclaw.App.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -10,9 +10,8 @@ - - + diff --git a/src/Netclaw.App/Program.cs b/src/Netclaw.App/Program.cs index 0782f541b..4d987c179 100644 --- a/src/Netclaw.App/Program.cs +++ b/src/Netclaw.App/Program.cs @@ -9,117 +9,236 @@ using Netclaw.Actors.Tools; using Netclaw.App; using Netclaw.App.Configuration; +using Netclaw.App.Gateway; +using Netclaw.App.Services; +using Netclaw.App.Tui; using Netclaw.Channels; using Netclaw.Configuration; +using Termina.Hosting; -// -- CLI mode selection -- -string? headlessPrompt = null; -for (var i = 0; i < args.Length; i++) +try { - if (args[i] is "-p" or "--prompt" && i + 1 < args.Length) + await RunAsync(args); +} +catch (Exception ex) +{ + // Write crash log to ~/.netclaw/logs/ so fatal errors are always diagnosable + WriteCrashLog(ex); + throw; +} + +static async Task RunAsync(string[] args) +{ + // ── Mode selection from CLI args ── + var mode = args.Length > 0 ? args[0] : "chat"; + string? headlessPrompt = null; + + if (mode is "-p" or "--prompt") + { + headlessPrompt = args.Length > 1 + ? args[1] + : throw new InvalidOperationException("Missing prompt argument after -p/--prompt"); + mode = "headless"; + } + + // ── Lightweight modes (no Akka, no persistence, no SignalR) ── + if (mode is "init" or "doctor") + { + var builder = Host.CreateApplicationBuilder(args); + ConfigureConfigServices(builder.Services, builder.Configuration); + + // Suppress framework console logging + builder.Logging.ClearProviders(); + builder.Logging.SetMinimumLevel(LogLevel.Warning); + + // TODO: init → Termina TUI wizard (Task 1.22) + // TODO: doctor → health checks (Task 1.21) + Console.WriteLine($"netclaw {mode}: not yet implemented"); + + await builder.Build().RunAsync(); + return; + } + + // ── Daemon modes (Akka, persistence, SignalR, tools) ── + var webBuilder = WebApplication.CreateBuilder(args); + + // Use port 5199 to avoid conflicts with Aspire (5000) and other defaults + webBuilder.WebHost.UseUrls("http://127.0.0.1:5199"); + + ConfigureConfigServices(webBuilder.Services, webBuilder.Configuration); + ConfigureDaemonServices(webBuilder.Services, webBuilder.Configuration); + + // Suppress framework console logging — session logs go to disk, + // console is reserved for the chat UI + webBuilder.Logging.ClearProviders(); + webBuilder.Logging.SetMinimumLevel(LogLevel.Warning); + + // SignalR for future remote clients (Blazor ops console) + webBuilder.Services.AddSignalR(); + + // Channel selection based on mode + switch (mode) { - headlessPrompt = args[i + 1]; - break; + case "chat": + webBuilder.Services.AddTermina("/chat", termina => + { + termina.RegisterRoute("/chat"); + }); + break; + + case "headless": + webBuilder.Services.AddSingleton(sp => + ActivatorUtilities.CreateInstance(sp, headlessPrompt!)); + webBuilder.Services.AddSingleton(sp => sp.GetRequiredService()); + webBuilder.Services.AddSingleton(sp => sp.GetRequiredService()); + break; + + case "run": + // Daemon only — no interactive channel. Slack adapter, scheduled tasks, etc. + // TODO: Slack adapter (Task 1.23) + break; + + default: + // Treat unknown commands as "chat" for backward compatibility + webBuilder.Services.AddTermina("/chat", termina => + { + termina.RegisterRoute("/chat"); + }); + break; } + + var app = webBuilder.Build(); + + // Gateway surface (Phase 1 — minimal) + app.MapHub("/hub/session"); + app.MapGet("/api/health/ready", () => Results.Ok("healthy")); + + await app.RunAsync(); } -var builder = Host.CreateApplicationBuilder(args); - -// -- Netclaw paths (creates ~/.netclaw/ structure) -- -var paths = new NetclawPaths(); -paths.EnsureDirectoriesExist(); -builder.Services.AddSingleton(paths); - -// -- Layered configuration chain -- -// 1. netclaw.json (base config, optional) -// 2. secrets.json (credentials overlay, optional) -// 3. NETCLAW_* environment variables (highest priority) -builder.Configuration - .AddJsonFile(paths.NetclawConfigPath, optional: true, reloadOnChange: false) - .AddJsonFile(paths.SecretsPath, optional: true, reloadOnChange: false) - .AddEnvironmentVariables("NETCLAW_"); - -// Suppress all framework console logging — session logs go to disk, -// console is reserved for the chat UI -builder.Logging.ClearProviders(); -builder.Logging.SetMinimumLevel(LogLevel.Warning); - -// -- TimeProvider -- -builder.Services.AddSingleton(TimeProvider.System); - -// -- Providers and models -- -var providers = builder.Configuration.GetSection("Providers") - .Get>() - ?? new() { ["local-ollama"] = new ProviderEntry() }; -var models = builder.Configuration.GetSection("Models") - .Get() ?? new ModelSelection(); - -var factory = new ChatClientFactory(providers); -var clientProvider = new NetclawChatClientProvider(factory, models); -builder.Services.AddSingleton(clientProvider); - -// -- Session config from resolved models -- -var sessionSection = builder.Configuration.GetSection("Session"); -builder.Services.AddSingleton(new SessionConfig -{ - ModelId = models.Main.ModelId, - ContextWindowTokens = models.Main.ContextWindow ?? 32_768, - CompactionModelId = models.Compaction?.ModelId, - CompactionThreshold = sessionSection.GetValue("CompactionThreshold", 0.75), - SnapshotInterval = sessionSection.GetValue("SnapshotInterval", 20), - KeepRecentToolResults = sessionSection.GetValue("KeepRecentToolResults", 3), - MaxToolIterationsPerTurn = sessionSection.GetValue("MaxToolIterationsPerTurn", 10), -}); - -// -- Tools (auto-bound, no required properties) -- -var toolConfig = builder.Configuration.GetSection("Tools") - .Get() ?? new ToolConfig(); -builder.Services.AddSingleton(toolConfig); - -var toolRegistry = new ToolRegistry(); -toolRegistry.WithFirstPartyTools(toolConfig); -builder.Services.AddSingleton(toolRegistry); -builder.Services.AddSingleton(new DispatchingToolExecutor(toolRegistry)); - -// -- System prompt (file-based, with first-run seed) -- -if (!File.Exists(paths.PersonalityPath)) - File.WriteAllText(paths.PersonalityPath, - "You are Netclaw, a helpful homelab operations assistant. " - + "Be concise and direct."); -builder.Services.AddSingleton( - new FileSystemPromptProvider(paths)); - -// -- Akka.NET actor system -- -builder.Services.AddAkka("netclaw", (akkaBuilder, sp) => -{ - akkaBuilder - .ConfigureLoggers(setup => - { - setup.ClearLoggers(); - setup.AddLoggerFactory(); - setup.LogLevel = Akka.Event.LogLevel.WarningLevel; - }) - .WithInMemoryJournal() - .WithInMemorySnapshotStore() - .WithNetclawActors(); -}); - -// -- Session pipeline (stream API for channels) -- -builder.Services.AddSingleton(); - -// -- Channel selection -- -if (headlessPrompt is not null) +static void WriteCrashLog(Exception ex) { - builder.Services.AddSingleton(sp => - ActivatorUtilities.CreateInstance(sp, headlessPrompt)); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); + try + { + var logsDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".netclaw", "logs"); + Directory.CreateDirectory(logsDir); + + var crashPath = Path.Combine(logsDir, + $"crash-{DateTime.UtcNow:yyyyMMdd-HHmmss}.log"); + File.WriteAllText(crashPath, + $""" + Netclaw crash at {DateTime.UtcNow:O} + + {ex} + """); + + Console.Error.WriteLine($"Fatal error — crash log written to {crashPath}"); + } + catch + { + // Last resort: write to stderr if we can't write the log file + Console.Error.WriteLine($"Fatal error (could not write crash log): {ex}"); + } } -else + +// ═══════════════════════════════════════════════════════════════════════ +// Shared configuration services (all modes) +// ═══════════════════════════════════════════════════════════════════════ + +static void ConfigureConfigServices(IServiceCollection services, IConfigurationManager configuration) { - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); + // Netclaw paths (creates ~/.netclaw/ structure) + var paths = new NetclawPaths(); + paths.EnsureDirectoriesExist(); + services.AddSingleton(paths); + + // Layered configuration chain: + // 1. netclaw.json (base config, optional) + // 2. secrets.json (credentials overlay, optional) + // 3. NETCLAW_* environment variables (highest priority) + configuration + .AddJsonFile(paths.NetclawConfigPath, optional: true, reloadOnChange: false) + .AddJsonFile(paths.SecretsPath, optional: true, reloadOnChange: false) + .AddEnvironmentVariables("NETCLAW_"); + + // TimeProvider (virtualized for testing) + services.AddSingleton(TimeProvider.System); + + // Providers and model resolution + var providers = configuration.GetSection("Providers") + .Get>() + ?? new() { ["local-ollama"] = new ProviderEntry() }; + var models = configuration.GetSection("Models") + .Get() ?? new ModelSelection(); + + var factory = new ChatClientFactory(providers); + var clientProvider = new NetclawChatClientProvider(factory, models); + services.AddSingleton(clientProvider); } -await builder.Build().RunAsync(); +// ═══════════════════════════════════════════════════════════════════════ +// Daemon-only services (run, chat, headless modes) +// ═══════════════════════════════════════════════════════════════════════ + +static void ConfigureDaemonServices(IServiceCollection services, IConfigurationManager configuration) +{ + // Resolve models for session config + var models = configuration.GetSection("Models") + .Get() ?? new ModelSelection(); + + // Session config from resolved models + var sessionSection = configuration.GetSection("Session"); + services.AddSingleton(new SessionConfig + { + ModelId = models.Main.ModelId, + ContextWindowTokens = models.Main.ContextWindow ?? 32_768, + CompactionModelId = models.Compaction?.ModelId, + CompactionThreshold = sessionSection.GetValue("CompactionThreshold", 0.75), + SnapshotInterval = sessionSection.GetValue("SnapshotInterval", 20), + KeepRecentToolResults = sessionSection.GetValue("KeepRecentToolResults", 3), + MaxToolIterationsPerTurn = sessionSection.GetValue("MaxToolIterationsPerTurn", 10), + }); + + // Tools (auto-bound, no required properties) + var toolConfig = configuration.GetSection("Tools") + .Get() ?? new ToolConfig(); + services.AddSingleton(toolConfig); + + var toolRegistry = new ToolRegistry(); + toolRegistry.WithFirstPartyTools(toolConfig); + services.AddSingleton(toolRegistry); + services.AddSingleton(new DispatchingToolExecutor(toolRegistry)); + + // System prompt (file-based, with first-run seed) + var paths = new NetclawPaths(); + if (!File.Exists(paths.PersonalityPath)) + File.WriteAllText(paths.PersonalityPath, + "You are Netclaw, a helpful homelab operations assistant. " + + "Be concise and direct."); + services.AddSingleton( + new FileSystemPromptProvider(paths)); + + // Akka.NET actor system + services.AddAkka("netclaw", (akkaBuilder, sp) => + { + akkaBuilder + .ConfigureLoggers(setup => + { + setup.ClearLoggers(); + setup.AddLoggerFactory(); + setup.LogLevel = Akka.Event.LogLevel.WarningLevel; + }) + .WithInMemoryJournal() + .WithInMemorySnapshotStore() + .WithNetclawActors(); + }); + + // Session pipeline (stream API for channels) + services.AddSingleton(); + + // Config hot-reload watcher + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); +} diff --git a/src/Netclaw.App/Services/ConfigWatcherService.cs b/src/Netclaw.App/Services/ConfigWatcherService.cs new file mode 100644 index 000000000..bdec8958f --- /dev/null +++ b/src/Netclaw.App/Services/ConfigWatcherService.cs @@ -0,0 +1,136 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Netclaw.Configuration; + +namespace Netclaw.App.Services; + +/// +/// Monitors ~/.netclaw/config/ for changes to netclaw.json and +/// secrets.json. Debounces file system events and validates new config +/// before applying. See SPEC-011 §Configuration Hot-Reload. +/// +/// +/// Single reload trigger: all config changes go to disk first (TUI wizard, +/// manual editing, agent self-configuration). This watcher is the only +/// mechanism that triggers config reload — there is no in-memory config +/// mutation path. +/// +/// +public sealed class ConfigWatcherService : IHostedService, IDisposable +{ + private readonly NetclawPaths _paths; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + private FileSystemWatcher? _watcher; + private CancellationTokenSource? _debounceCts; + private readonly TimeSpan _debounceInterval = TimeSpan.FromMilliseconds(500); + + public ConfigWatcherService( + NetclawPaths paths, + TimeProvider timeProvider, + ILogger logger) + { + _paths = paths; + _timeProvider = timeProvider; + _logger = logger; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + var configDir = Path.GetDirectoryName(_paths.NetclawConfigPath); + if (configDir is null || !Directory.Exists(configDir)) + { + _logger.LogWarning("Config directory does not exist: {ConfigDir}. Hot-reload disabled.", configDir); + return Task.CompletedTask; + } + + _watcher = new FileSystemWatcher(configDir) + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.CreationTime, + EnableRaisingEvents = true + }; + + _watcher.Changed += OnFileChanged; + _watcher.Created += OnFileChanged; + _watcher.Deleted += OnFileDeleted; + + _logger.LogInformation("Config hot-reload watching: {ConfigDir}", configDir); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _watcher?.Dispose(); + _debounceCts?.Cancel(); + return Task.CompletedTask; + } + + private void OnFileChanged(object sender, FileSystemEventArgs e) + { + if (!IsWatchedFile(e.Name)) + return; + + _logger.LogDebug("Config file changed: {FileName}", e.Name); + ScheduleReload(); + } + + private void OnFileDeleted(object sender, FileSystemEventArgs e) + { + if (!IsWatchedFile(e.Name)) + return; + + _logger.LogWarning("Config file deleted: {FileName}. Keeping current config.", e.Name); + } + + private void ScheduleReload() + { + // Cancel any pending debounce timer and start a new one + _debounceCts?.Cancel(); + _debounceCts = new CancellationTokenSource(); + var token = _debounceCts.Token; + + _ = Task.Run(async () => + { + try + { + await Task.Delay(_debounceInterval, token); + if (token.IsCancellationRequested) return; + + ApplyReload(); + } + catch (OperationCanceledException) + { + _logger.LogDebug("Config reload debounce cancelled by newer event"); + } + }, CancellationToken.None); + } + + private void ApplyReload() + { + try + { + // TODO: Read and validate new configuration + // TODO: Rebuild IChatClientProvider on valid change + // TODO: Notify actor system of config changes via Akka pub/sub + // TODO: Log validation errors on invalid change, preserve previous config + + _logger.LogInformation( + "[{Timestamp:o}] Config reload triggered (validation not yet implemented)", + _timeProvider.GetUtcNow()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Config reload failed. Keeping previous config."); + } + } + + private static bool IsWatchedFile(string? fileName) => + fileName is "netclaw.json" or "secrets.json"; + + public void Dispose() + { + _watcher?.Dispose(); + _debounceCts?.Dispose(); + } +} diff --git a/src/Netclaw.App/Tui/ChatPage.cs b/src/Netclaw.App/Tui/ChatPage.cs new file mode 100644 index 000000000..6fe591d0f --- /dev/null +++ b/src/Netclaw.App/Tui/ChatPage.cs @@ -0,0 +1,273 @@ +using System.Reactive.Disposables; +using System.Reactive.Linq; +using Netclaw.Actors.Protocol; +using Termina.Components.Streaming; +using Termina.Extensions; +using Termina.Input; +using Termina.Layout; +using Termina.Reactive; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.App.Tui; + +/// +/// Termina page for the interactive chat UI (netclaw chat). +/// Layout: scrollable chat history (fill) + fixed input panel (3 rows) + status bar. +/// +public sealed class ChatPage : ReactivePage +{ + private StreamingTextNode _chatHistory = null!; + private TextInputNode _promptInput = null!; + + private int _nextSegmentId = 1; + + private SegmentId NextSegmentId() => new(_nextSegmentId++); + + // Track the current "thinking" spinner segment so we can replace it + private SegmentId _thinkingSegmentId; + + // Track active tool timer so we can read final elapsed on completion + private ElapsedTimeSegment? _toolTimer; + + protected override void OnBound() + { + base.OnBound(); + + _chatHistory = StreamingTextNode.Create(); + _promptInput = new TextInputNode() + .WithPlaceholder("Type a message..."); + + // Handle prompt submission — Buffer coalesces rapid-fire submissions + // (e.g., pasting multi-line text where each CRLF triggers Submitted) + // into a single message. Normal typing produces one item per buffer window. + _promptInput.Submitted + .Where(text => !string.IsNullOrWhiteSpace(text)) + .Buffer(TimeSpan.FromMilliseconds(100)) + .Where(batch => batch.Count > 0) + .Subscribe(batch => + { + _promptInput.Clear(); + + var combined = string.Join("\n", batch); + + // Render user message + _chatHistory.AppendLine(""); + _chatHistory.AppendLine($"You: {combined}", Color.Cyan); + + _ = ViewModel.SubmitAsync(combined); + }) + .DisposeWith(Subscriptions); + + // Subscribe to session output + ViewModel.SessionOutput + .ObserveOn(System.Reactive.Concurrency.CurrentThreadScheduler.Instance) + .Subscribe(HandleOutput) + .DisposeWith(Subscriptions); + + // Route keyboard input + ViewModel.Input.OfType() + .Subscribe(HandleKeyPress) + .DisposeWith(Subscriptions); + } + + public override ILayoutNode BuildLayout() + { + return Layouts.Vertical() + // Chat history panel (fills available space) + .WithChild( + new PanelNode() + .WithTitle("Netclaw Chat") + .WithBorder(BorderStyle.Rounded) + .WithBorderColor(Color.Gray) + .WithContent(_chatHistory.Fill()) + .Fill()) + // Input panel (fixed 3 rows) + .WithChild( + new PanelNode() + .WithTitle("Input") + .WithBorder(BorderStyle.Rounded) + .WithBorderColor(Color.Cyan) + .WithContent(_promptInput) + .Height(3)) + // Status bar + .WithChild( + BuildStatusBar()); + } + + private LayoutNode BuildStatusBar() + { + return Observable.CombineLatest( + ViewModel.IsGeneratingChanged, + ViewModel.StatusMessageChanged, + ViewModel.UsageDisplayChanged.StartWith((string?)null), + (isGenerating, status, usage) => + { + var keys = isGenerating + ? "[Ctrl+Q] Quit" + : "[Enter] Send [PgUp/PgDn] Scroll [Ctrl+Q] Quit"; + + var usagePart = usage is not null ? $" | {usage}" : ""; + var text = $" {keys} | {status} | {ViewModel.ModelId}{usagePart}"; + + var barColor = status switch + { + "Ready" => Color.Green, + "Connecting..." => Color.Yellow, + _ when status.StartsWith("Generating") => Color.Yellow, + _ when status.StartsWith("Connection failed") => Color.Red, + _ => Color.BrightBlack + }; + + return (ILayoutNode)new TextNode(text).WithForeground(barColor); + }) + .AsLayout() + .Height(1); + } + + private void HandleKeyPress(KeyPressed key) + { + var keyInfo = key.KeyInfo; + + // Ctrl+Q always quits + if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + ViewModel.RequestAppShutdown(); + return; + } + + // Escape: cancel or quit + if (keyInfo.Key == ConsoleKey.Escape) + { + if (ViewModel.IsGenerating) + { + // TODO: cancel generation when supported + } + else + { + ViewModel.RequestAppShutdown(); + } + + return; + } + + // PageUp/PageDown: scroll chat history + if (_chatHistory.HandleInput(keyInfo, viewportHeight: 20, viewportWidth: 80)) + return; + + // Everything else goes to the text input + _promptInput.HandleInput(keyInfo); + } + + private void HandleOutput(SessionOutput output) + { + switch (output) + { + case SessionJoined msg: + _chatHistory.AppendLine( + $"System: Session started. {(msg.Title is not null ? $"Title: {msg.Title}" : "New session.")}", + Color.BrightBlack); + _chatHistory.AppendLine(""); + break; + + case TextOutput msg: + // Remove thinking spinner if present + RemoveThinkingSpinner(); + _chatHistory.AppendLine(""); + _chatHistory.AppendLine($"Netclaw: {msg.Text}", Color.White); + _chatHistory.AppendLine(""); + _chatHistory.ScrollToBottom(); + break; + + case ThinkingOutput: + // Hidden — reasoning output is too verbose for the chat view. + // TODO: collapsible thinking sections when Termina supports it. + break; + + case ToolCallOutput msg: + RemoveThinkingSpinner(); + var toolSegmentId = NextSegmentId(); + _thinkingSegmentId = toolSegmentId; + _toolTimer = new ElapsedTimeSegment(Color.BrightBlack); + _chatHistory.AppendTracked(toolSegmentId, + new CompositeTextSegment( + new SpinnerSegment(Termina.Components.Streaming.SpinnerStyle.Dots, Color.Yellow, intervalMs: 80), + new StaticTextSegment($" {msg.ToolName}({TruncateArgs(msg.ArgumentsJson)})", + Color.Yellow), + _toolTimer)); + break; + + case ToolResultOutput msg: + // Replace spinner+timer with checkmark and final elapsed time + if (_thinkingSegmentId.Value != 0) + { + // Read elapsed from the timer segment before it gets disposed by Replace + var elapsed = _toolTimer is not null + ? $" ({FormatElapsed(_toolTimer.Elapsed)})" + : ""; + _toolTimer = null; + + _chatHistory.Replace(_thinkingSegmentId, + new StaticTextSegment( + $" \u2713 {msg.ToolName} \u2192 {Truncate(msg.Result, 80)}{elapsed}", + Color.Green), + keepTracked: false); + _thinkingSegmentId = default; + } + + break; + + case UsageOutput msg: + // Compute context % from ViewModel's SessionConfig (known-good) + // rather than msg.ContextWindowTokens which may be default(0) + var ctxWindow = ViewModel.ContextWindowTokens; + var usagePercent = msg.InputTokens.HasValue && ctxWindow > 0 + ? (double)msg.InputTokens.Value / ctxWindow + : (double?)null; + var ctxPart = usagePercent.HasValue + ? $" ({usagePercent.Value:P0} ctx)" + : ""; + ViewModel.UsageDisplay = $"in={msg.InputTokens ?? 0} out={msg.OutputTokens ?? 0}{ctxPart}"; + break; + + case ErrorOutput msg: + RemoveThinkingSpinner(); + _chatHistory.AppendLine($" [error] {msg.Message}", Color.Red); + break; + + case TurnCompleted: + RemoveThinkingSpinner(); + ViewModel.StatusMessage = "Ready"; + _chatHistory.ScrollToBottom(); + break; + + case CompactionOutput msg: + _chatHistory.AppendLine( + $" [compaction] {msg.MessagesBefore} \u2192 {msg.MessagesAfter} messages", + Color.Yellow); + break; + } + + ViewModel.RequestRedraw(); + } + + private void RemoveThinkingSpinner() + { + if (_thinkingSegmentId.Value != 0) + { + _chatHistory.Remove(_thinkingSegmentId); + _thinkingSegmentId = default; + } + } + + private static string TruncateArgs(string? json) => + json is null or "" ? "" : Truncate(json, 60); + + private static string Truncate(string text, int maxLength) => + text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength - 3), "..."); + + private static string FormatElapsed(TimeSpan elapsed) => + elapsed.TotalSeconds < 60 + ? $"{elapsed.TotalSeconds:F1}s" + : $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s"; +} diff --git a/src/Netclaw.App/Tui/ChatViewModel.cs b/src/Netclaw.App/Tui/ChatViewModel.cs new file mode 100644 index 000000000..0ea55ed3f --- /dev/null +++ b/src/Netclaw.App/Tui/ChatViewModel.cs @@ -0,0 +1,151 @@ +using System.Reactive.Linq; +using System.Reactive.Subjects; +using Akka.Actor; +using Akka.Streams; +using Akka.Streams.Dsl; +using Microsoft.Extensions.AI; +using Netclaw.Actors.Channels; +using Netclaw.Actors.Protocol; +using Netclaw.Configuration; +using Termina.Reactive; + +namespace Netclaw.App.Tui; + +/// +/// Reactive ViewModel for the chat page. Uses +/// directly (in-process, no SignalR indirection). Manages session lifecycle, +/// input submission, and output forwarding to the page. +/// +public partial class ChatViewModel : ReactiveViewModel +{ + private readonly SessionPipeline _pipeline; + private readonly ActorSystem _system; + private readonly TimeProvider _timeProvider; + private readonly SessionConfig _sessionConfig; + + private MaterializedSession? _session; + private ISourceQueueWithComplete? _inputQueue; + private readonly Subject _outputSubject = new(); + +#pragma warning disable CS0169, CS0414 // Backing fields used by [Reactive] source generator + [Reactive] private bool _isGenerating; + [Reactive] private string _statusMessage = "Connecting..."; + [Reactive] private string? _sessionIdDisplay; + [Reactive] private string? _usageDisplay; +#pragma warning restore CS0169, CS0414 + + /// + /// Observable stream of session output events. The page subscribes to this + /// to render chat messages, tool activity, usage, etc. + /// + public IObservable SessionOutput => _outputSubject.AsObservable(); + + /// + /// The configured model identifier for display in the status bar. + /// + public string ModelId => _sessionConfig.ModelId; + + public int ContextWindowTokens => _sessionConfig.ContextWindowTokens; + + public ChatViewModel( + SessionPipeline pipeline, + ActorSystem system, + TimeProvider timeProvider, + SessionConfig sessionConfig) + { + _pipeline = pipeline; + _system = system; + _timeProvider = timeProvider; + _sessionConfig = sessionConfig; + } + + public override void OnActivated() + { + base.OnActivated(); + _ = InitializeSessionAsync(); + } + + private async Task InitializeSessionAsync() + { + try + { + var sessionId = new SessionId($"tui/{Guid.NewGuid():N}"); + SessionIdDisplay = sessionId.Value; + + _session = await _pipeline.CreateAsync(sessionId, new SessionPipelineOptions + { + ChannelType = "tui" + }); + + // Materialize output stream → forward to Subject for page rendering + _session.Output + .To(Sink.ForEach(output => + { + _outputSubject.OnNext(output); + + // Track turn lifecycle for generation state + switch (output) + { + case TurnCompleted: + IsGenerating = false; + break; + case ErrorOutput: + IsGenerating = false; + break; + } + + RequestRedraw(); + })) + .Run(_system); + + // Materialize input with queue for imperative push + _inputQueue = Source.Queue(16, OverflowStrategy.Backpressure) + .ToMaterialized(_session.Input, Keep.Left) + .Run(_system); + + StatusMessage = "Ready"; + RequestRedraw(); + } + catch (Exception ex) + { + StatusMessage = $"Connection failed: {ex.Message}"; + RequestRedraw(); + } + } + + /// + /// Submit user text to the session pipeline. + /// + public async Task SubmitAsync(string text) + { + if (_inputQueue is null || string.IsNullOrWhiteSpace(text)) + return; + + IsGenerating = true; + StatusMessage = "Generating..."; + + await _inputQueue.OfferAsync(new ChannelInput + { + SenderId = "local-user", + Contents = [new TextContent(text)], + ReceivedAt = _timeProvider.GetUtcNow() + }); + } + + public void RequestAppShutdown() + { + Shutdown(); + } + + public override void Dispose() + { + _outputSubject.Dispose(); + if (_session is not null) + { + _ = _session.DisposeAsync(); + } + + DisposeReactiveFields(); + base.Dispose(); + } +} diff --git a/src/Netclaw.App/Tui/ElapsedTimeSegment.cs b/src/Netclaw.App/Tui/ElapsedTimeSegment.cs new file mode 100644 index 000000000..1118e648f --- /dev/null +++ b/src/Netclaw.App/Tui/ElapsedTimeSegment.cs @@ -0,0 +1,74 @@ +using System.Diagnostics; +using System.Reactive; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using Termina.Components.Streaming; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.App.Tui; + +/// +/// Animated text segment that displays elapsed time since creation. +/// Updates every second: "0s", "1s", ..., "1m 5s", etc. +/// Used alongside in tool call progress display. +/// +public sealed class ElapsedTimeSegment : IAnimatedTextSegment +{ + private readonly Stopwatch _stopwatch = Stopwatch.StartNew(); + private readonly System.Timers.Timer _timer; + private readonly Subject _invalidated = new(); + private readonly TextStyle _style; + private bool _disposed; + + public IObservable Invalidated => _invalidated.AsObservable(); + + public bool IsAnimating => _timer.Enabled; + + /// + /// The current elapsed time. Read this before disposing to capture the final value. + /// + public TimeSpan Elapsed => _stopwatch.Elapsed; + + public ElapsedTimeSegment(Color? color = null, int intervalMs = 1000) + { + _style = new TextStyle(color ?? Color.BrightBlack); + _timer = new System.Timers.Timer(intervalMs); + _timer.Elapsed += OnTick; + _timer.AutoReset = true; + Start(); + } + + public StyledSegment GetCurrentSegment() + { + var elapsed = _stopwatch.Elapsed; + var text = elapsed.TotalSeconds < 60 + ? $" {elapsed.TotalSeconds:F0}s" + : $" {(int)elapsed.TotalMinutes}m {elapsed.Seconds}s"; + return new StyledSegment(text, _style); + } + + public void Start() + { + if (!_disposed) + _timer.Start(); + } + + public void Stop() => _timer.Stop(); + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + Stop(); + _timer.Dispose(); + _invalidated.OnCompleted(); + _invalidated.Dispose(); + } + + private void OnTick(object? sender, System.Timers.ElapsedEventArgs e) + { + if (!_disposed) + _invalidated.OnNext(Unit.Default); + } +}