Skip to content

control-plane: add session logs to dashboard#53

Merged
benvinegar merged 2 commits into
mainfrom
benvinegar/control-plane-logs
Feb 18, 2026
Merged

control-plane: add session logs to dashboard#53
benvinegar merged 2 commits into
mainfrom
benvinegar/control-plane-logs

Conversation

@benvinegar

Copy link
Copy Markdown
Member

Adds session log viewing to the control plane — see what the agent is doing without SSH/tmux.

New endpoints

  • GET /logs?session=&lines=50 — recent session log entries (JSON)
  • GET /sessions — list recent sessions with metadata

Dashboard changes

  • Tabbed UI: Status (existing) and Logs (new)
  • Logs tab shows recent activity across all sessions: user prompts, agent responses, tool calls with badges
  • Auto-refreshes every 30s

Reads pi session JSONL files from the agent's home directory. Filters to useful entry types (messages, tool results, compactions) and truncates output for readability.

- GET /logs — recent session log entries (JSON), filterable by session
- GET /sessions — list recent sessions with metadata
- Dashboard now has Status and Logs tabs
- Logs tab shows recent agent activity: user prompts, agent responses, tool calls
- Auto-refreshes every 30s
@greptile-apps

greptile-apps Bot commented Feb 18, 2026

Copy link
Copy Markdown

Greptile Summary

Adds session log viewing to the control plane dashboard: two new JSON endpoints (GET /logs and GET /sessions) that read pi agent session JSONL files, and a tabbed dashboard UI with a new Logs tab showing recent agent activity (user prompts, agent responses, tool calls). The implementation parses JSONL session files from the agent's home directory, filters to useful entry types, and truncates output for readability.

  • XSS vulnerability: The tab query parameter is injected directly into an HTML href attribute without escaping on the dashboard page. Should be escaped with esc() or whitelist-validated.
  • NaN propagation: The /logs endpoint's lines parameter silently returns 0 results instead of the default 50 when given non-numeric input, due to parseInt returning NaN.
  • Missing docblock entry: The new GET /sessions endpoint is not documented in the file header's endpoint list.
  • Memory efficiency: listSessions() reads entire JSONL files (up to 20) into memory just to parse the first line for the session name. This could be optimized for large files.

Confidence Score: 3/5

  • This PR has a reflected XSS vulnerability in the dashboard that should be fixed before merging, even though the server runs behind auth on localhost.
  • Score of 3 reflects one clear XSS bug (unescaped tab parameter in HTML), one input validation issue (NaN propagation on lines parameter), and some efficiency concerns. The core functionality is sound and well-structured, but the XSS should be addressed before merge.
  • control-plane/server.mjs — reflected XSS via tab query parameter on the dashboard (line 648), NaN handling in /logs endpoint (line 427)

Important Files Changed

Filename Overview
control-plane/server.mjs Adds session log viewing (parsing JSONL files), two new API endpoints (/logs, /sessions), and a tabbed dashboard UI. Has a reflected XSS via the unescaped tab query parameter and a NaN propagation bug in the lines parameter parsing.

Sequence Diagram

sequenceDiagram
    participant Browser
    participant Dashboard as GET /dashboard?tab=logs
    participant LogsAPI as GET /logs
    participant SessionsAPI as GET /sessions
    participant FS as Session JSONL Files

    Browser->>Dashboard: Request logs tab
    Dashboard->>FS: findSessionFiles()
    FS-->>Dashboard: Sorted JSONL file list
    Dashboard->>FS: tailSessionFile() per file (up to 10)
    FS-->>Dashboard: Raw JSONL entries
    Dashboard->>Dashboard: summarizeEntry() + filter
    Dashboard-->>Browser: Server-rendered HTML with log entries

    Browser->>LogsAPI: GET /logs?session=X&lines=50
    LogsAPI->>FS: findSessionFiles()
    FS-->>LogsAPI: Filtered file list
    LogsAPI->>FS: tailSessionFile() per file
    FS-->>LogsAPI: Raw entries
    LogsAPI-->>Browser: JSON {entries, count}

    Browser->>SessionsAPI: GET /sessions
    SessionsAPI->>FS: findSessionFiles()
    FS-->>SessionsAPI: Top 20 files
    SessionsAPI->>FS: readFileSync() first line per file
    FS-->>SessionsAPI: Session metadata
    SessionsAPI-->>Browser: JSON {sessions}
Loading

Last reviewed commit: 89a159a

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 file reviewed, 4 comments

Edit Code Review Agent Settings | Greptile

Comment thread control-plane/server.mjs
</div>

<div class="refresh-note">Auto-refreshes every 30 seconds &mdash; <a href="/dashboard">refresh now</a> &mdash; <a href="/status">JSON</a></div>
<div class="refresh-note">Auto-refreshes every 30s &mdash; <a href="/dashboard?tab=${tab}">refresh now</a> &mdash; JSON: <a href="/status">/status</a> <a href="/logs">/logs</a> <a href="/sessions">/sessions</a></div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reflected XSS via unescaped tab parameter

The tab query parameter is interpolated directly into the HTML href attribute without escaping. An attacker could craft a URL like /dashboard?tab="><script>alert(1)</script> to inject arbitrary JavaScript. Even though this server runs behind auth and on localhost, this is a reflected XSS that could be exploited by tricking an admin into clicking a malicious link.

The fix is to escape tab with the existing esc() helper, or better yet, whitelist the allowed values so only "status" or "logs" are accepted:

Suggested change
<div class="refresh-note">Auto-refreshes every 30s &mdash; <a href="/dashboard?tab=${tab}">refresh now</a> &mdash; JSON: <a href="/status">/status</a> <a href="/logs">/logs</a> <a href="/sessions">/sessions</a></div>
<div class="refresh-note">Auto-refreshes every 30s &mdash; <a href="/dashboard?tab=${esc(tab)}">refresh now</a> &mdash; JSON: <a href="/status">/status</a> <a href="/logs">/logs</a> <a href="/sessions">/sessions</a></div>

Context Used: Context from dashboard - security.mdc Cursor rule (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: control-plane/server.mjs
Line: 648

Comment:
**Reflected XSS via unescaped `tab` parameter**

The `tab` query parameter is interpolated directly into the HTML `href` attribute without escaping. An attacker could craft a URL like `/dashboard?tab="><script>alert(1)</script>` to inject arbitrary JavaScript. Even though this server runs behind auth and on localhost, this is a reflected XSS that could be exploited by tricking an admin into clicking a malicious link.

The fix is to escape `tab` with the existing `esc()` helper, or better yet, whitelist the allowed values so only `"status"` or `"logs"` are accepted:

```suggestion
  <div class="refresh-note">Auto-refreshes every 30s &mdash; <a href="/dashboard?tab=${esc(tab)}">refresh now</a> &mdash; JSON: <a href="/status">/status</a> <a href="/logs">/logs</a> <a href="/sessions">/sessions</a></div>
```

**Context Used:** Context from `dashboard` - security.mdc Cursor rule ([source](https://app.greptile.com/review/custom-context?memory=7155b921-a804-48fb-b2b8-e978e1c0cfa1))

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — whitelisted tab param to ["status", "logs"] so invalid values fall back to "status". No raw input reaches the HTML.

Comment thread control-plane/server.mjs Outdated

app.get("/logs", (req, res) => {
const session = req.query.session || undefined;
const lines = Math.min(parseInt(req.query.lines || "50", 10), 500);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NaN propagation from invalid lines query

If req.query.lines is a non-numeric string (e.g. ?lines=abc), parseInt returns NaN. Math.min(NaN, 500) is NaN, which propagates into getRecentLogs where allEntries.slice(0, NaN) returns an empty array. This won't crash, but silently returns 0 entries instead of the default 50 — which is confusing for callers.

Suggested change
const lines = Math.min(parseInt(req.query.lines || "50", 10), 500);
const lines = Math.min(parseInt(req.query.lines, 10) || 50, 500);
Prompt To Fix With AI
This is a comment left during a code review.
Path: control-plane/server.mjs
Line: 427

Comment:
**`NaN` propagation from invalid `lines` query**

If `req.query.lines` is a non-numeric string (e.g. `?lines=abc`), `parseInt` returns `NaN`. `Math.min(NaN, 500)` is `NaN`, which propagates into `getRecentLogs` where `allEntries.slice(0, NaN)` returns an empty array. This won't crash, but silently returns 0 entries instead of the default 50 — which is confusing for callers.

```suggestion
  const lines = Math.min(parseInt(req.query.lines, 10) || 50, 500);
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — using parseInt(..., 10) || 50 so NaN falls back to the default.

Comment thread control-plane/server.mjs Outdated
// Parse session name from first line
let sessionName = null;
try {
const firstLine = readFileSync(f.path, "utf8").split("\n")[0];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reads entire file to extract first line

readFileSync(f.path, "utf8").split("\n")[0] loads the entire JSONL session file into memory just to read the first line. Session log files can grow large over time. Consider reading only the first chunk of the file, e.g. using openSync/readSync with a small buffer, or using createReadStream with a line reader to avoid loading multi-MB files into memory 20 times per /sessions request.

Prompt To Fix With AI
This is a comment left during a code review.
Path: control-plane/server.mjs
Line: 352

Comment:
**Reads entire file to extract first line**

`readFileSync(f.path, "utf8").split("\n")[0]` loads the entire JSONL session file into memory just to read the first line. Session log files can grow large over time. Consider reading only the first chunk of the file, e.g. using `openSync`/`readSync` with a small buffer, or using `createReadStream` with a line reader to avoid loading multi-MB files into memory 20 times per `/sessions` request.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — now reads only the first 4KB via openSync/readSync buffer instead of loading the entire file. Session first lines are well under 4KB.

@greptile-apps

greptile-apps Bot commented Feb 18, 2026

Copy link
Copy Markdown
Additional Comments (1)

control-plane/server.mjs
Missing /sessions endpoint from docblock

The new GET /sessions endpoint (line 432) is not listed in the file header's endpoint documentation. This docblock documents all routes for quick reference.

 * Endpoints:
 *   GET  /health     — liveness check (no auth required)
 *   GET  /status     — agent processes, sessions, system info
 *   GET  /config     — agent configuration (secrets redacted)
 *   GET  /logs       — recent session log entries (JSON)
 *   GET  /sessions   — list recent sessions with metadata (JSON)
 *   GET  /dashboard  — server-rendered HTML overview (status + logs tabs)
Prompt To Fix With AI
This is a comment left during a code review.
Path: control-plane/server.mjs
Line: 9-14

Comment:
**Missing `/sessions` endpoint from docblock**

The new `GET /sessions` endpoint (line 432) is not listed in the file header's endpoint documentation. This docblock documents all routes for quick reference.

```suggestion
 * Endpoints:
 *   GET  /health     — liveness check (no auth required)
 *   GET  /status     — agent processes, sessions, system info
 *   GET  /config     — agent configuration (secrets redacted)
 *   GET  /logs       — recent session log entries (JSON)
 *   GET  /sessions   — list recent sessions with metadata (JSON)
 *   GET  /dashboard  — server-rendered HTML overview (status + logs tabs)
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread control-plane/server.mjs Outdated
…elist

- Read only first 4KB of session files instead of loading entire file
- Use || 50 fallback for non-numeric lines param (prevents NaN propagation)
- Whitelist tab param to 'status'|'logs' instead of interpolating raw input
@benvinegar benvinegar merged commit a8db4fd into main Feb 18, 2026
9 checks passed
@benvinegar benvinegar deleted the benvinegar/control-plane-logs branch February 18, 2026 15:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant