Skip to content

Commit ed86a6c

Browse files
committed
fix: address review findings — security, correctness, and UX improvements
1 parent 3b219ec commit ed86a6c

7 files changed

Lines changed: 119 additions & 36 deletions

File tree

CLAUDE.md

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,21 @@ claude-plane is a self-hosted control plane for managing interactive Claude CLI
88

99
- **`claude-plane-server`** — Control plane. Serves the frontend, manages sessions, orchestrates jobs, accepts inbound gRPC connections from agents. SQLite storage.
1010
- **`claude-plane-agent`** — Runs on worker machines. Manages Claude CLI processes in PTYs, buffers terminal output, maintains persistent gRPC connection to the server.
11-
- **`claude-plane-bridge`** — Connects external services (GitHub, Telegram, Slack) to the server via its REST API. Polls for events, triggers jobs, and relays notifications.
11+
- **`claude-plane-bridge`** — Connects external services (GitHub, Telegram, Slack) to the server via its REST API. Polls for events, triggers jobs, and relays notifications. Auto-configured when running via Docker.
1212
- **Frontend** — React 19 + TypeScript SPA embedded via `go:embed` into the server binary. Modes: Command Center (dashboard), single-session terminal view, and Multi-View (2-6 sessions in resizable split panes).
1313

14+
## Product-First Development (CRITICAL)
15+
16+
**Every code change must be validated against the full user workflow it affects.** We have repeatedly introduced bugs by fixing one piece of code in isolation without tracing how it impacts the rest of the system. Before implementing any feature or fix:
17+
18+
1. **Trace the full user story.** Walk through the complete user journey that touches the code you're changing — from the UI action, through the API, to the backend, to the agent, and back. Map every component in the chain.
19+
2. **Check all consumers.** A session status change affects: the session terminal header, the SessionsPage list, the CommandCenter dashboard cards, the MultiView picker, the event stream, and the reaper. If you change how status works, verify ALL of them.
20+
3. **Think about state transitions.** What happens when a session goes from `created``running``waiting_for_input``running``completed`? What about `created``running``errored`? What about a machine disconnecting mid-session? Trace every transition.
21+
4. **Test with real Claude CLI.** The CLI has behaviors that are invisible in unit tests: Ink status bar redraws, periodic tips/updates below the prompt, cursor repositioning escape sequences. Assumptions about terminal output patterns have caused multiple critical bugs.
22+
5. **Frontend views share data but display independently.** When you invalidate a query, use `refetchType: 'all'` to ensure unmounted components (other pages the user navigates to) also get fresh data. Stale caches cause status mismatches between views.
23+
24+
**The cost of tracing a full workflow is 10 minutes. The cost of a bug that reaches production is hours of debugging.**
25+
1426
## Architecture Principles
1527

1628
1. **Agents dial in, server never dials out.** Workers can be behind NATs/firewalls.
@@ -20,6 +32,19 @@ claude-plane is a self-hosted control plane for managing interactive Claude CLI
2032

2133
## Build & Run
2234

35+
### Docker (recommended — production & development)
36+
37+
```bash
38+
# Start server + bridge (bridge auto-configures on first run)
39+
docker compose up -d
40+
41+
# The server image includes the bridge binary. On first start,
42+
# docker-entrypoint.sh auto-generates an API key and bridge.toml.
43+
# No manual bridge configuration needed.
44+
```
45+
46+
### Local Development
47+
2348
**Prerequisites:** Go 1.25+, Node.js 22+
2449

2550
```bash
@@ -40,6 +65,7 @@ cd web && npm install && npm run build && cd ..
4065
./claude-plane-server ca issue-server --ca-dir ./ca --out-dir ./server-cert
4166
./claude-plane-server ca issue-agent --ca-dir ./ca --machine-id "worker-1"
4267
./claude-plane-server seed-admin --email admin@example.com --name Admin
68+
./claude-plane-server create-api-key --name bridge --admin # for manual bridge setup
4369

4470
# Agent subcommands
4571
./claude-plane-agent run --config agent.toml
@@ -67,6 +93,14 @@ When debugging frontend issues in production mode, verify the bundle hash in the
6793

6894
**WebSocket attach failure:** When `runSession` in `session/ws.go` fails to attach to the agent, it must publish end markers (`scrollback_end` + `session_ended`) AND close the WebSocket. Do not fall through to the relay loops — the reader loop would repeatedly call `sendToAgent()` against a missing agent.
6995

96+
**Idle detection and CLI noise:** The idle detector (`idle_detector.go`) uses silence-based timing, NOT prompt marker matching. Claude CLI renders its UI with Ink (React for terminals), which sends cursor repositioning escape sequences (`\x1b7`, `\x1b8`, `\x1b[<n>;<m>H`, `\x1b[<n>A/B/C/D`) for status bar redraws even when idle. The `isRepositioningNoise()` classifier filters these out — only sequential text with SGR color codes (`\x1b[...m`) counts as real output. Do NOT attempt to detect idle state by matching prompt characters (❯) — they are persistent TUI elements, not line-delimited prompts.
97+
98+
**Machine connection debounce:** When an agent registers, there's a brief gap between `Register()` and `CommandStream()` that causes a legitimate disconnect/reconnect cycle. The connection manager uses a 5-second grace period (`disconnectGrace`) before publishing `machine.disconnected`. Never publish disconnect events immediately.
99+
100+
**Session status across views:** Session status is displayed in: terminal header, SessionsPage list, CommandCenter cards, MultiView picker, and event stream. All these views use independent TanStack Query caches. When invalidating session queries, always use `refetchType: 'all'` to ensure unmounted views also refresh — otherwise users see stale status when navigating between pages.
101+
102+
**Bridge auto-config in Docker:** The server Docker image includes the bridge binary. `docker-entrypoint.sh` auto-generates an API key via `create-api-key` CLI command and writes `bridge.toml` on first start. Do not require manual bridge configuration for Docker deployments.
103+
70104
## Testing
71105

72106
```bash
@@ -144,6 +178,12 @@ Single proto file: `proto/claudeplane/v1/agent.proto`. Defines `Register()` and
144178
| `event/` | In-process pub/sub bus with glob-style pattern matching, WebSocket fanout, webhook delivery with retry |
145179
| `provision/` | Agent provisioning token generation and install script building |
146180
| `agentdl/` | Multi-platform agent binary download endpoints (embedded via `go:embed`) |
181+
| `broker/` | Message broker for inter-component communication |
182+
| `ingest/` | Data ingestion pipeline for agent metrics and token usage |
183+
| `logging/` | Structured logging infrastructure — slog TeeHandler writing to stderr + SQLite (async batch) + WebSocket broadcast |
184+
| `notify/` | Notification delivery (email, Slack, etc.) |
185+
| `reaper/` | Background cleanup — terminates idle sessions, sweeps stale `created` sessions stuck > 5 min |
186+
| `retention/` | Data retention policies and cleanup for old sessions/runs/events |
147187
| `config/` | TOML config parsing |
148188

149189
**Key patterns:**
@@ -162,16 +202,19 @@ Single proto file: `proto/claudeplane/v1/agent.proto`. Defines `Register()` and
162202
| `connector/` | Connector interface + implementations (GitHub, Telegram) |
163203
| `state/` | State management for connector sync |
164204

165-
Connectors implement a common interface. Each polls an external service, maps events to job triggers, and relays via the server's REST API.
205+
Connectors implement a common interface. Each polls an external service, maps events to job triggers, and relays via the server's REST API. In Docker deployments, the bridge binary is embedded in the server image and auto-configured by `docker-entrypoint.sh` — no manual setup required.
166206

167207
## Agent Architecture (`internal/agent/`)
168208

169209
- `session.go` / `session_manager.go` — PTY-backed process management using `creack/pty`, scrollback file storage, output buffering
170210
- `client.go` — gRPC client with automatic reconnection and backoff
171211
- `backoff.go` — Exponential backoff logic for reconnection
172212
- `health.go` — Health check reporting
173-
- `idle_detector.go`Session idle tracking
213+
- `idle_detector.go`Silence-based idle detection with noise classifier. Uses `isRepositioningNoise()` to filter Ink cursor-positioning escape sequences from real Claude output. Configurable silence timeout, minimum activity bytes threshold.
174214
- `scrollback.go` — Scrollback buffer persistence
215+
- `directory.go` — Working directory resolution and validation
216+
- `join.go` — Agent provisioning join flow (exchanges join code for mTLS certs)
217+
- `log_sink.go` — Structured log forwarding to server via gRPC
175218
- `config/` — Agent TOML config loading
176219
- `lifecycle/` — Agent lifecycle utilities: PID file, process scanning, orphan reaping, service detection
177220

@@ -189,16 +232,16 @@ When using `vi.fn()` in tests, use the Vitest 3.x single-type-parameter form: `v
189232
| Directory | Purpose |
190233
|-----------|---------|
191234
| `api/` | HTTP client (`/api/v1` base), per-domain API functions |
192-
| `stores/` | Zustand stores: auth, jobs, runs, UI state, multiview (workspace persistence via localStorage) |
193-
| `hooks/` | TanStack Query hooks for data fetching; `useTerminalSession()` for xterm.js + WebSocket (supports optional WebGL toggle); `useEventStream()` for multiplexed event WS with exponential backoff |
235+
| `stores/` | Zustand stores: auth, jobs, runs, logs, UI state, multiview (workspace persistence via localStorage) |
236+
| `hooks/` | TanStack Query hooks for data fetching (~29 hooks); `useTerminalSession()` for xterm.js + WebSocket (supports optional WebGL toggle); `useEventStream()` for multiplexed event WS with exponential backoff and `refetchType: 'all'` |
194237
| `types/` | TypeScript interfaces for all domain entities |
195-
| `components/` | Feature-organized: layout, jobs, runs, terminal, sessions, multiview, webhooks, triggers, events, admin, credentials, dag, shared |
238+
| `components/` | Feature-organized: layout, jobs, runs, terminal, sessions, multiview, webhooks, triggers, events, admin, credentials, dag, shared, apikeys, connectors, docs, logs, machines, provisioning, templates, settings, dashboard |
196239
| `views/` | Page-level route components |
197240
| `lib/` | Utility functions |
198241

199242
**Notable frontend libraries:** `@xyflow/react` + `@dagrejs/dagre` for DAG visualization, `cron-parser` + `cronstrue` for cron display, `xterm.js` with WebGL addon for terminal rendering, `react-resizable-panels` for multi-view split panes.
200243

201-
**Routing (React Router 7):** Protected redirect to LoginPage. Routes: `/` (CommandCenter), `/sessions`, `/multiview`, `/multiview/:workspaceId`, `/machines`, `/jobs`, `/runs`, `/webhooks`, `/events`, `/users`, `/provisioning`, `/credentials`.
244+
**Routing (React Router 7):** Protected redirect to LoginPage. `/users` requires admin role (`AdminRoute` guard). Routes: `/` (CommandCenter), `/sessions`, `/sessions/:sessionId`, `/multiview`, `/multiview/:workspaceId`, `/machines`, `/jobs`, `/jobs/new`, `/jobs/:id`, `/templates`, `/templates/new`, `/templates/:id/edit`, `/runs`, `/runs/:id`, `/webhooks`, `/webhooks/:id/deliveries`, `/triggers`, `/schedules`, `/events`, `/logs`, `/users` (admin), `/provisioning`, `/credentials`, `/api-keys`, `/connectors`, `/connectors/:connectorId`, `/search`, `/settings`, `/docs`, `/docs/:guideId`.
202245

203246
**WebSocket patterns:**
204247
- Terminal WS (`/ws/terminal/{sessionID}`): binary data, real-time terminal I/O, scrollback replay on connect
@@ -215,7 +258,7 @@ When using `vi.fn()` in tests, use the Vitest 3.x single-type-parameter form: `v
215258

216259
## Data Model (SQLite)
217260

218-
Core tables: `machines`, `sessions`, `jobs`, `job_steps`, `job_runs`, `job_step_results`, `token_usage`, `model_pricing`
261+
Core tables: `machines`, `sessions`, `jobs`, `steps`, `step_dependencies`, `runs`, `run_steps`, `run_step_values`, `users`, `api_keys`, `credentials`, `webhooks`, `webhook_deliveries`, `events`, `cron_schedules`, `job_triggers`, `session_templates`, `injections`, `bridge_connectors`, `bridge_control`, `provisioning_tokens`, `revoked_tokens`, `audit_log`, `user_preferences`, `notification_channels`, `notification_subscriptions`, `server_settings`
219262

220263
## Release Process
221264

internal/server/handler/bridge.go

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -541,38 +541,28 @@ func (h *BridgeHandler) populateBridgeHealth(resp *bridgeStatusResponse, events
541541
latest := events[0].Timestamp
542542
resp.LastSeen = &latest
543543

544-
// Determine running state: look for the most recent started/stopped.
545-
bridgeStartedSeen := false
544+
// Determine running state: find the most recent bridge lifecycle event
545+
// (bridge.started or bridge.stopped). The bridge is running if the most
546+
// recent lifecycle event is bridge.started AND that timestamp is within
547+
// 60 seconds (staleness check).
546548
for _, e := range events {
547549
switch e.Type {
548550
case event.TypeBridgeStarted:
549-
bridgeStartedSeen = true
550-
// Running if bridge started recently (within 60 seconds).
551-
if time.Since(e.Timestamp) < 60*time.Second {
552-
resp.Running = true
553-
}
551+
resp.Running = time.Since(e.Timestamp) < 60*time.Second
554552
case event.TypeBridgeStopped:
555-
// If stopped is more recent than started, not running.
556-
if !bridgeStartedSeen {
557-
resp.Running = false
558-
}
559-
}
560-
if bridgeStartedSeen {
561-
break
553+
resp.Running = false
554+
default:
555+
continue
562556
}
563-
}
564-
565-
// If we saw a bridge.started and the last event was recent, consider it running.
566-
if bridgeStartedSeen && time.Since(latest) < 60*time.Second {
567-
resp.Running = true
557+
break
568558
}
569559

570560
// Build per-connector status from most recent connector events.
571561
// Track which connectors we've already resolved (first occurrence wins
572562
// since events are newest-first).
573563
seen := make(map[string]bool)
574564
for _, e := range events {
575-
name, _ := e.Payload["connector_name"].(string)
565+
name, _ := e.Payload["name"].(string)
576566
if name == "" {
577567
continue
578568
}

internal/server/handler/bridge_ingest.go

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
"github.com/kodrunhq/claude-plane/internal/server/logging"
1313
)
1414

15+
const maxIngestEntries = 200
16+
1517
// BridgeIngestHandler handles REST endpoints for ingesting logs and events
1618
// from the bridge binary into the server's logging and event systems.
1719
type BridgeIngestHandler struct {
@@ -79,11 +81,14 @@ func (h *BridgeIngestHandler) HandleLogs(w http.ResponseWriter, r *http.Request)
7981
return
8082
}
8183

82-
source := req.Source
83-
if source == "" {
84-
source = "bridge"
84+
if len(req.Entries) > maxIngestEntries {
85+
writeError(w, http.StatusBadRequest, "too many entries (max 200)")
86+
return
8587
}
8688

89+
// Issue 5: Always force source to "bridge" — do not trust caller-supplied value.
90+
source := "bridge"
91+
8792
records := make([]logging.LogRecord, 0, len(req.Entries))
8893
for _, entry := range req.Entries {
8994
rec := logging.LogRecord{
@@ -143,6 +148,16 @@ func (h *BridgeIngestHandler) HandleLogs(w http.ResponseWriter, r *http.Request)
143148
w.WriteHeader(http.StatusAccepted)
144149
}
145150

151+
// allowedIngestEventTypes is the set of event types permitted through the
152+
// bridge ingest endpoint. Any other type is rejected with 400.
153+
var allowedIngestEventTypes = map[string]bool{
154+
event.TypeBridgeStarted: true,
155+
event.TypeBridgeStopped: true,
156+
event.TypeBridgeConnectorStarted: true,
157+
event.TypeBridgeConnectorError: true,
158+
event.TypeBridgeConnectorCommand: true,
159+
}
160+
146161
// HandleEvents ingests events from the bridge and publishes them on the event bus.
147162
func (h *BridgeIngestHandler) HandleEvents(w http.ResponseWriter, r *http.Request) {
148163
var req eventsRequest
@@ -156,6 +171,19 @@ func (h *BridgeIngestHandler) HandleEvents(w http.ResponseWriter, r *http.Reques
156171
return
157172
}
158173

174+
if len(req.Events) > maxIngestEntries {
175+
writeError(w, http.StatusBadRequest, "too many events (max 200)")
176+
return
177+
}
178+
179+
// Validate all event types before publishing any.
180+
for _, entry := range req.Events {
181+
if !allowedIngestEventTypes[entry.Type] {
182+
writeError(w, http.StatusBadRequest, "disallowed event type: "+entry.Type)
183+
return
184+
}
185+
}
186+
159187
if h.eventBus != nil {
160188
for _, entry := range req.Events {
161189
evt := event.NewBridgeEvent(entry.Type, entry.Payload)

web/src/components/settings/NotificationsTab.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,8 @@ export function NotificationsTab() {
160160
<div>
161161
<h3 className="text-base font-semibold text-text-primary">Channels</h3>
162162
<p className="text-sm text-text-secondary">
163-
Configure where notifications are delivered.
163+
Configure email notification channels. For Telegram, set up a connector on the{' '}
164+
<Link to="/connectors" className="text-accent-primary hover:underline">Connectors page</Link>.
164165
</p>
165166
</div>
166167
<button
@@ -174,7 +175,16 @@ export function NotificationsTab() {
174175

175176
{channels.length === 0 ? (
176177
<div className="text-center py-8 text-text-secondary text-sm border border-border-primary rounded-lg bg-bg-secondary">
177-
No notification channels configured yet.
178+
<p>No notification channels configured yet.</p>
179+
{!channels.some((ch) => !!ch.connector_id) && (
180+
<p className="mt-1">
181+
For Telegram notifications,{' '}
182+
<Link to="/connectors" className="text-accent-primary hover:underline">
183+
set up a Telegram connector
184+
</Link>{' '}
185+
first.
186+
</p>
187+
)}
178188
</div>
179189
) : (
180190
<div className="space-y-2">
@@ -203,6 +213,11 @@ export function NotificationsTab() {
203213
Connector
204214
</span>
205215
)}
216+
{ch.channel_type === 'telegram' && !ch.connector_id && (
217+
<span className="text-xs px-2 py-0.5 rounded-full bg-status-warning/15 text-status-warning font-medium" title="Created before connector integration">
218+
Legacy
219+
</span>
220+
)}
206221
</div>
207222
<div className="flex items-center gap-1">
208223
<button

web/src/constants/eventTypes.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,6 @@ export const EVENT_GROUPS: { label: string; events: EventType[] }[] = [
175175
BRIDGE_STOPPED,
176176
BRIDGE_CONNECTOR_STARTED,
177177
BRIDGE_CONNECTOR_ERROR,
178-
BRIDGE_CONNECTOR_COMMAND,
179178
],
180179
},
181180
{

web/src/views/ConnectorDetailPage.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,11 @@ export function ConnectorDetailPage() {
390390
<ConfirmDialog
391391
open={showDeleteConfirm}
392392
title="Delete Connector"
393-
message={`Are you sure you want to delete "${connector.name}"? This cannot be undone.`}
393+
message={
394+
connector.connector_type === 'telegram'
395+
? `Are you sure you want to delete "${connector.name}"? The linked notification channel and its subscriptions will also be deleted. This cannot be undone.`
396+
: `Are you sure you want to delete "${connector.name}"? This cannot be undone.`
397+
}
394398
confirmLabel="Delete"
395399
variant="danger"
396400
onConfirm={handleDelete}

web/src/views/ConnectorsPage.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,11 @@ export function ConnectorsPage() {
173173
<ConfirmDialog
174174
open={deletingConnector !== null}
175175
title="Delete Connector"
176-
message={`Are you sure you want to delete "${deletingConnector?.name}"? This cannot be undone.`}
176+
message={
177+
deletingConnector?.connector_type === 'telegram'
178+
? `Are you sure you want to delete "${deletingConnector?.name}"? The linked notification channel and its subscriptions will also be deleted. This cannot be undone.`
179+
: `Are you sure you want to delete "${deletingConnector?.name}"? This cannot be undone.`
180+
}
177181
confirmLabel="Delete"
178182
variant="danger"
179183
onConfirm={handleDelete}

0 commit comments

Comments
 (0)