Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion QUICK-REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@
| Task | Command |
|------|---------|
| Start (first time or after reset) | `./quick-start.sh` |
| View dashboard | http://localhost:9876 |
| View dashboard | Open the authenticated `Local access:` URL from `docker compose logs --tail=30 chat-explorer` |
| View logs | `docker compose logs -f` |
| Stop | `docker compose down` |
| Restart | `docker compose restart` |
| Rebuild | `docker compose up -d --build` |

The authenticated URL sets an HttpOnly cookie and redirects to the clean
`http://localhost:9876/` address. To use monitoring scripts, export a stable
token before startup, for example `export CHAT_EXPLORER_AUTH_TOKEN="$(openssl rand -hex 32)"`.

## Auto-Start Behavior

| Scenario | Behavior |
Expand Down
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ cd claude-code-chat-explorer
./quick-start.sh
```

Open **http://localhost:9876** in your browser.
The startup output prints a one-time authenticated URL. Open that link in your
browser; it sets an HttpOnly session cookie and immediately removes the token
from the address bar.

The web server is published on `127.0.0.1:9876` only through a small proxy that
has no transcript or database mounts. The transcript-reading app stays on an
internal-only Docker network, so it cannot make outbound Internet connections.

## Features

Expand Down Expand Up @@ -90,7 +96,9 @@ The server needs read access to the conversation JSONL files at their indexed pa
3. **Watches** for changes and updates the index incrementally
4. **Serves** a web interface on port 9876

The database and all processing happens locally. Your conversations never leave your machine.
The database and all processing happens locally. The default container network
blocks runtime outbound access, and HTTP/WebSocket access requires the random
token printed at startup.

## Architecture

Expand Down Expand Up @@ -184,8 +192,13 @@ The container runs hardened:
- Dropped capabilities
- Memory limits (1GB)
- Localhost-only port binding
- Random-token authentication for HTTP and WebSocket access
- Exact-origin checks for WebSocket upgrades
- Internal Docker network with no runtime outbound access

Your Claude data is mounted read-only.
Your Claude data is mounted read-only. Native development runs also bind to
`127.0.0.1` by default and require a random token unless authentication is
explicitly disabled by a test harness.

## Troubleshooting

Expand Down
40 changes: 38 additions & 2 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ services:
USER_ID: 501
container_name: claude-code-chat-explorer-dev
restart: unless-stopped
ports:
- "127.0.0.1:9877:9876"
expose:
- "9876"
volumes:
- ${HOME}/.claude:${HOME}/.claude:ro
- chat-explorer-dev-db:/data
Expand All @@ -27,6 +27,11 @@ services:
- CLAUDE_DB_PATH=/data/conversations.db
- CHOKIDAR_USEPOLLING=1
- CHOKIDAR_INTERVAL=2000
- CHAT_EXPLORER_HOST=0.0.0.0
- CHAT_EXPLORER_AUTH_TOKEN=${CHAT_EXPLORER_AUTH_TOKEN:-}

networks:
- chat-explorer-dev-internal

cap_drop:
- ALL
Expand All @@ -44,5 +49,36 @@ services:
reservations:
memory: 256M

chat-explorer-dev-proxy:
build:
context: .
args:
USER_ID: 501
container_name: claude-code-chat-explorer-dev-proxy
restart: unless-stopped
command: ["node", "src/loopback-proxy.js"]
depends_on:
- chat-explorer-dev
ports:
- "127.0.0.1:9877:9876"
Comment on lines +62 to +63

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Live updates stop working in the dev container because the real-time connection is rejected

The real-time WebSocket connection is refused (getAllowedWebSocketOrigins at src/chats-mobile.js:1516-1521) whenever the page is opened on a host port that differs from the container's internal port, which is exactly the case for the dev setup published on 127.0.0.1:9877:9876.
Impact: In the dev container, new conversations and messages never appear live; the real-time panel silently stops updating.

Origin allowlist keyed on internal port vs. published host port

The WebSocket verifyClient (src/chats-mobile.js:1551-1562) calls authorizeWebSocketUpgrade, which requires info.origin to be an exact member of getAllowedWebSocketOrigins(). That set is built from this.port, the port the app listens on inside the container (9876). The dev proxy publishes 127.0.0.1:9877:9876 (docker-compose.dev.yml:62-63) and forwards the browser's Origin header verbatim (src/loopback-proxy.js:28). A browser loading http://localhost:9877 sends Origin: http://localhost:9877, which is not in {http://localhost:9876, http://127.0.0.1:9876, http://[::1]:9876}, so isOriginAllowed returns false and the upgrade is rejected with 403. The same breakage occurs for any user who follows the README "Change Port" instructions to remap the published port. Production works only because there the published port equals the internal port.

Prompt for agents
The WebSocket origin allowlist in src/chats-mobile.js (getAllowedWebSocketOrigins) is derived from this.port, which is the app's internal listening port (9876 inside the container). However, browsers connect through the loopback proxy on the published host port, which can differ from the internal port (e.g. 9877 in docker-compose.dev.yml, or any custom mapping per the README 'Change Port' section). Because src/loopback-proxy.js forwards the browser Origin header verbatim, the Origin (http://localhost:9877) never matches the allowlist built for port 9876, so authorizeWebSocketUpgrade returns a 403 and real-time updates break. Consider making the set of allowed WebSocket origins configurable (e.g. via an environment variable listing the externally-published origins/ports) so the dev container and custom port mappings can advertise their real browser-facing origin, rather than assuming the browser reaches the app on the same port the app listens on.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

environment:
- CHAT_EXPLORER_UPSTREAM_HOST=chat-explorer-dev
- CHAT_EXPLORER_UPSTREAM_PORT=9876
networks:
- chat-explorer-dev-internal
- chat-explorer-dev-ingress
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp:noexec,nosuid,size=16m

volumes:
chat-explorer-dev-db:

networks:
chat-explorer-dev-internal:
internal: true
chat-explorer-dev-ingress:
50 changes: 48 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ services:
USER_ID: 501
container_name: claude-code-chat-explorer
restart: unless-stopped
ports:
- "127.0.0.1:9876:9876" # Localhost only
expose:
- "9876"
volumes:
# Mount at SAME absolute path so symlinks resolve correctly
- ${HOME}/.claude:${HOME}/.claude:ro
Expand All @@ -23,6 +23,14 @@ services:
# events through Docker Desktop's VirtioFS bridge on macOS/Windows.
- CHOKIDAR_USEPOLLING=1
- CHOKIDAR_INTERVAL=2000
# Native runs bind to loopback by default. Inside Docker the process
# must listen on the container interface; the published host port above
# remains restricted to 127.0.0.1.
- CHAT_EXPLORER_HOST=0.0.0.0
- CHAT_EXPLORER_AUTH_TOKEN=${CHAT_EXPLORER_AUTH_TOKEN:-}

networks:
- chat-explorer-internal

# Security hardening
cap_drop:
Expand All @@ -41,6 +49,39 @@ services:
reservations:
memory: 256M

# This process publishes localhost but has no transcript/database mounts and
# uses only Node's core HTTP/TCP modules. The transcript-reading app above
# remains on an internal-only network with no outbound route.
chat-explorer-proxy:
build:
context: .
args:
USER_ID: 501
container_name: claude-code-chat-explorer-proxy
restart: unless-stopped
command: ["node", "src/loopback-proxy.js"]
depends_on:
- chat-explorer
ports:
- "127.0.0.1:9876:9876"
environment:
- CHAT_EXPLORER_UPSTREAM_HOST=chat-explorer
- CHAT_EXPLORER_UPSTREAM_PORT=9876
networks:
- chat-explorer-internal
- chat-explorer-ingress
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp:noexec,nosuid,size=16m
deploy:
resources:
limits:
memory: 128M

# chat-explorer-test:
# build:
# context: .
Expand All @@ -54,3 +95,8 @@ services:

volumes:
chat-explorer-db:

networks:
chat-explorer-internal:
internal: true
Comment on lines +99 to +101

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 App container is on an internal-only network with no outbound route, which disables URL-based session import

docker-compose.yml:99-101 places the transcript-reading app on chat-explorer-internal with internal: true, so the container has no outbound route. The session-sharing import path (src/session-sharing.js downloadSession) performs an outbound fetch to allowlisted hosts (x0.at, transfer.sh, etc.). With no egress, remote session cloning will always fail from inside the default container. The PR description states outbound access is intentionally blocked, so this appears deliberate, but it does silently break the import-from-URL feature when running under the default compose file — worth confirming this trade-off is intended and documented.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

chat-explorer-ingress:
Loading