Skip to content

Commit 1481847

Browse files
committed
feat(client): experimental env-driven exthook and doctor integration
1 parent 429e319 commit 1481847

14 files changed

Lines changed: 638 additions & 11 deletions

File tree

ARCHITECTURE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ The client is a standalone terminal user interface built with the Bubble Tea fra
3737
- **`hotkeys.go`**: Key binding definitions and methods
3838
- **`render.go`**: Message rendering and UI display logic (optional per-line metadata: message id and encrypted flag)
3939
- **`websocket.go`**: WebSocket connection management, send/receive, and E2E encryption helpers
40+
- **`exthook/`**: Optional, experimental subprocess hooks for local automation (stdin JSON per event); see **CLIENT_HOOKS.md**
4041
- **`commands.go`**: Help text generation and command-related utilities
4142
- **`notification_manager.go`**: Desktop/bell notification system
4243

@@ -108,7 +109,7 @@ The server package contains the core server logic and components that are used b
108109

109110
#### Core Components
110111

111-
- **WebSocket Handlers**: Connection management and message routing
112+
- **WebSocket Handlers**: Connection management and message routing; failed handshakes close with **RFC 6455** close frames (registered status code + UTF-8 reason, not a raw text payload—see `PROTOCOL.md`)
112113
- **Database Layer**: Pluggable SQL backends (SQLite/PostgreSQL/MySQL) with dialect-aware schema and query helpers
113114
- **Admin Interfaces**: Both TUI and web-based administrative panels
114115
- **Plugin Integration**: Plugin command handling and execution
@@ -209,7 +210,7 @@ The **client** stores `config.json`, `profiles.json`, keystore, themes, and debu
209210

210211
### Diagnostics (`internal/doctor`)
211212

212-
Shared package invoked by **`marchat-client`** and **`marchat-server`** when passed **`-doctor`** (human-readable report) or **`-doctor-json`** (JSON on stdout). It summarizes Go/OS, resolved config directories, known `MARCHAT_*` variables with secrets masked, role-specific checks (client: profiles, clipboard, TTY; server: `.env`, validation, detected DB dialect, DB connection-string format validation, DB/TLS ping checks), and optionally compares the embedded version to the latest GitHub release. For **server** doctor, the `MARCHAT_*` listing is captured **after** `LoadConfigWithoutValidation` applies **`godotenv.Overload`** on `config/.env`, matching effective runtime env; **client** doctor still reflects only the client process environment. The **text** report is **colorized** when stdout is a terminal and **`NO_COLOR`** is unset (otherwise plain); **`-doctor-json`** is always unstyled JSON. Set **`MARCHAT_DOCTOR_NO_NETWORK=1`** to skip the release check (e.g. air-gapped environments).
213+
Shared package invoked by **`marchat-client`** and **`marchat-server`** when passed **`-doctor`** (human-readable report) or **`-doctor-json`** (JSON on stdout). It summarizes Go/OS, resolved config directories, known `MARCHAT_*` variables with secrets masked, role-specific checks (client: profiles, clipboard, TTY, and optional **experimental client hook** env vars plus executable path validation when receive/send paths are set; server: `.env`, validation, detected DB dialect, DB connection-string format validation, DB/TLS ping checks), and optionally compares the embedded version to the latest GitHub release. For **server** doctor, the `MARCHAT_*` listing is captured **after** `LoadConfigWithoutValidation` applies **`godotenv.Overload`** on `config/.env`, matching effective runtime env; **client** doctor still reflects only the client process environment. **Server** doctor omits client-only hook keys from the environment section entirely, even when they are set in the process (for example the same shell session used to start the client). The **text** report is **colorized** when stdout is a terminal and **`NO_COLOR`** is unset (otherwise plain); **`-doctor-json`** is always unstyled JSON. Set **`MARCHAT_DOCTOR_NO_NETWORK=1`** to skip the release check (e.g. air-gapped environments).
213214

214215
### Command Line Tools (`cmd/`)
215216

CLIENT_HOOKS.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Client external hooks (experimental)
2+
3+
**Status:** experimental. The hook protocol, environment variables, and payload fields may change or be removed in a future release without a major version bump. If you build integrations on this, pin a marchat version and watch release notes.
4+
5+
Client hooks run **local executables** you choose, fed one JSON object per event on **stdin** (newline-terminated). They are **not** server plugins: they only see what **your** client process sees, after decrypt on receive and before encrypt on send.
6+
7+
## Why this exists
8+
9+
- **Server plugins** automate hub-side behavior and see server-wide trust boundaries.
10+
- **Built-in notifications** cover in-TUI alerts with a fixed feature set.
11+
- **Client hooks** fill the gap: pipe chat events to **your** scripts, loggers, bridges, or external notification systems **without** new client dependencies or server changes.
12+
13+
Hooks are **side-effect only**: they cannot modify, block, or transform messages (runs are asynchronous).
14+
15+
## Security and trust
16+
17+
1. **Plaintext:** A receive hook runs after the client decrypts; a send hook runs with the plaintext you typed. Anyone who can read hook logs or the hook binary's behavior gets the same material as the TUI. This does **not** weaken wire encryption (the server still sees ciphertext for E2E traffic); it widens **local** exposure to whatever you execute.
18+
2. **Absolute paths only:** `MARCHAT_CLIENT_HOOK_RECEIVE` and `MARCHAT_CLIENT_HOOK_SEND` must be absolute paths to a regular file. Relative paths are rejected to avoid surprising `PATH` / working-directory behavior.
19+
3. **Trust the binary:** Hooks run as your user. Only point at programs you trust, same as running them manually.
20+
4. **Timeouts:** Each invocation is killed if it exceeds the configured timeout (default 5s, max 120s) so a stuck script does not pile up forever.
21+
5. **File messages:** Payloads include file **metadata** (`filename`, `size`) only; raw file bytes are never sent to hooks.
22+
23+
## Environment variables
24+
25+
| Variable | Meaning |
26+
|----------|---------|
27+
| `MARCHAT_CLIENT_HOOK_RECEIVE` | Absolute path to executable for inbound events (`message_received`). |
28+
| `MARCHAT_CLIENT_HOOK_SEND` | Absolute path to executable for outbound composer sends (`message_send`). |
29+
| `MARCHAT_CLIENT_HOOK_TIMEOUT_SEC` | Optional. Per-hook timeout in seconds (default `5`, max `120`). |
30+
| `MARCHAT_CLIENT_HOOK_RECEIVE_TYPING` | Set to `1`, `true`, or `yes` to deliver **typing** indicators to the receive hook. Default: **off** (reduces log noise). |
31+
| `MARCHAT_CLIENT_HOOK_DEBUG` | Set to `1`, `true`, or `yes` to log successful hook completion (duration) and label stdout as a debug preview. |
32+
| `MARCHAT_HOOK_LOG` | Optional log file path for the **bundled** `example_hook` binary only. The marchat client does not read this variable. |
33+
34+
Unset hook paths mean that hook is disabled.
35+
36+
## Diagnostics (`-doctor`)
37+
38+
Run **`marchat-client -doctor`** (or **`-doctor-json`**) to inspect your client environment:
39+
40+
- The **Environment** section lists hook-related variables, including `MARCHAT_HOOK_LOG` for visibility, even though only `example_hook` uses it (with the same masking and truncation rules as other doctor output).
41+
- If **`MARCHAT_CLIENT_HOOK_RECEIVE`** or **`MARCHAT_CLIENT_HOOK_SEND`** is set, doctor runs a **path check**: the value must be an absolute path to an existing regular file, matching what the client enforces at runtime.
42+
43+
Server doctor does **not** list client-only hook variables, even if they are set in the environment (the server never reads them; hiding them avoids noise when client and server are run from the same shell).
44+
45+
## Protocol
46+
47+
### Transport
48+
49+
- One UTF-8 JSON object per invocation, written to the process **stdin**, terminated with a single newline (`\n`).
50+
- The client waits for the process to exit, up to the timeout. **Stdout** and **stderr** are captured; non-empty stdout is logged at INFO (see debug flag above). A non-zero exit or timeout is logged as a failure.
51+
52+
### Envelope
53+
54+
```json
55+
{
56+
"event": "message_received | message_send",
57+
"version": 1,
58+
"message": { }
59+
}
60+
```
61+
62+
- **`version`:** Integer. Incremented when incompatible payload changes are introduced; until then, treat as `1`.
63+
- **`event`:**
64+
- **`message_received`:** Fired for each inbound `shared.Message` on the chat WebSocket path after the client has applied decrypt when E2E is on. Typing is **excluded** unless `MARCHAT_CLIENT_HOOK_RECEIVE_TYPING` is enabled.
65+
- **`message_send`:** Fired for outbound text from the main composer path: global plaintext, global E2E (plaintext before encrypt), DMs, and `:` server/admin lines sent as `AdminCommandType`. Other UI actions (e.g. code snippet, file picker) may not invoke the send hook.
66+
67+
### `message` object
68+
69+
Mirrors [`shared.Message`](shared/types.go) fields where applicable, as a JSON object:
70+
71+
| Field | Notes |
72+
|-------|--------|
73+
| `type` | e.g. `text`, `dm`, `typing`, `reaction`, `admin_command`, … May be empty if the server omitted it on older history. |
74+
| `sender`, `content`, `encrypted`, `message_id`, `recipient`, `edited`, `channel` | Same meaning as wire types. |
75+
| `created_at` | RFC3339 nano UTC. **Omitted** when the client has no real timestamp (zero time), so you will not see `0001-01-01` sentinels. |
76+
| `reaction` | Present for reaction events (`emoji`, `target_id`, `is_removal`). |
77+
| `file` | For file messages: `{ "filename", "size" }` only; no `data`. |
78+
79+
Filter scripts on `message.type` / `event` as needed.
80+
81+
## Example: append-only logger
82+
83+
Build the bundled sample (from repo root):
84+
85+
```bash
86+
go build -o /tmp/marchat-hook-log ./client/exthook/example_hook
87+
```
88+
89+
Run the client with hooks (paths must be absolute on your OS):
90+
91+
```bash
92+
export MARCHAT_CLIENT_HOOK_RECEIVE=/tmp/marchat-hook-log
93+
export MARCHAT_CLIENT_HOOK_SEND=/tmp/marchat-hook-log
94+
# Optional: override log path (otherwise uses $TMPDIR/marchat-client-hook.log)
95+
export MARCHAT_HOOK_LOG=$HOME/marchat-hook.log
96+
go run ./client
97+
```
98+
99+
On Windows (PowerShell), use `Resolve-Path` for absolute paths and run `go run ./client` from the repository root (not `go run .`).
100+
101+
## Use cases (illustrative)
102+
103+
- Custom logging or archival to your own storage
104+
- Webhooks or bridges to other chat systems
105+
- Keyword-triggered local alerts beyond built-in notifications
106+
- Development and integration testing
107+
108+
## Relationship to other systems
109+
110+
| Mechanism | Where it runs | Typical use |
111+
|-----------|----------------|-------------|
112+
| Server plugins | Server | Commands, hub automation, shared bots |
113+
| Client hooks | Your machine | Personal automation, local pipelines |
114+
| Second WebSocket client | Your machine / elsewhere | Full bots, independent sessions |
115+
116+
## Stability
117+
118+
Treat this document and the `version` field as the reference for experiments. For production-like integrations, prefer discussing on GitHub issues so breaking changes can be coordinated.

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ The repository’s `config/` directory holds **server** runtime files and the **
280280

281281
### Diagnostics (`-doctor`)
282282

283-
Run **`./marchat-client -doctor`** or **`./marchat-server -doctor`** for a text report (paths, redacted `MARCHAT_*` secrets as length-only, other env values as shown, sanity checks). **Server** doctor lists `MARCHAT_*` **after** loading the resolved config directory’s **`.env`** (same as the running server), so values are not limited to what your shell exported. **Client** doctor only shows variables present in the client process (it does not read the server’s `config/.env`). Server doctor also reports the detected DB dialect, validates the configured DB connection string format, and attempts a DB ping. On a **color-capable terminal** (stdout is a TTY), the text report uses **ANSI colors** aligned with the server pre-TUI banner; set **`NO_COLOR`** or redirect to a file/pipe for **plain** output. Use **`-doctor-json`** for machine-readable output (never colorized). If both flags were passed, `-doctor-json` wins. Exits without starting the TUI or listening on a port. See [ARCHITECTURE.md](ARCHITECTURE.md) for details.
283+
Run **`./marchat-client -doctor`** or **`./marchat-server -doctor`** for a text report (paths, redacted `MARCHAT_*` secrets as length-only, other env values as shown, sanity checks). **Server** doctor lists `MARCHAT_*` **after** loading the resolved config directory’s **`.env`** (same as the running server), so values are not limited to what your shell exported. **Client** doctor only shows variables present in the client process (it does not read the server’s `config/.env`); it also lists **experimental client hook** env vars and validates receive/send hook paths when set (see [CLIENT_HOOKS.md](CLIENT_HOOKS.md)). **Server** doctor does **not** list those client-only hook variables, even if they are set in the shell (for example when you run client and server from the same session). Server doctor also reports the detected DB dialect, validates the configured DB connection string format, and attempts a DB ping. On a **color-capable terminal** (stdout is a TTY), the text report uses **ANSI colors** aligned with the server pre-TUI banner; set **`NO_COLOR`** or redirect to a file/pipe for **plain** output. Use **`-doctor-json`** for machine-readable output (never colorized). If both flags were passed, `-doctor-json` wins. Exits without starting the TUI or listening on a port. See [ARCHITECTURE.md](ARCHITECTURE.md) for details.
284284

285285
## Admin Commands
286286

@@ -675,6 +675,15 @@ Profiles stored in platform-appropriate locations:
675675
./marchat-client --non-interactive --server ws://localhost:8080/ws --username alice
676676
```
677677

678+
## Advanced features
679+
680+
### Experimental client hooks (local automation)
681+
682+
The TUI client can spawn **optional** external programs on send/receive and pass one JSON line on stdin per event, useful for custom logging, bridges, or local tooling. This is **experimental** (protocol may change); it does not replace server plugins or built-in notifications.
683+
684+
- **Documentation:** [CLIENT_HOOKS.md](CLIENT_HOOKS.md) (security model, env vars, JSON shape).
685+
- **Entrypoint:** from the repo root, `go run ./client` (the client lives in `client/`, not the repo root).
686+
678687
## Security Best Practices
679688

680689
1. **Generate Secure Keys**
@@ -820,6 +829,7 @@ go test ./...
820829
- **[PROTOCOL.md](PROTOCOL.md)** - WebSocket message types and payloads
821830
- **[deploy/CADDY-REVERSE-PROXY.md](deploy/CADDY-REVERSE-PROXY.md)** - Optional TLS reverse proxy (Caddy) for local or LAN `wss://`
822831
- **[NOTIFICATIONS.md](NOTIFICATIONS.md)** - Notification system guide (desktop, quiet hours, focus mode)
832+
- **[CLIENT_HOOKS.md](CLIENT_HOOKS.md)** - Experimental client-side external hooks (local automation)
823833
- **[THEMES.md](THEMES.md)** - Custom theme creation guide
824834
- **[PLUGIN_ECOSYSTEM.md](PLUGIN_ECOSYSTEM.md)** - Plugin development guide
825835
- **[ROADMAP.md](ROADMAP.md)** - Planned features and enhancements

SECURITY.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ It **does not cover**:
5959

6060
The `-doctor` / `-doctor-json` commands print masked values for sensitive `MARCHAT_*` variables; avoid sharing raw process environment dumps alongside doctor output. For air-gapped hosts, set `MARCHAT_DOCTOR_NO_NETWORK=1` so doctor does not call the GitHub API.
6161

62+
### Experimental client hooks (local)
63+
64+
Optional **`MARCHAT_CLIENT_HOOK_*`** settings run programs you choose on your machine with **decrypted message content** (receive) or **plaintext before send** (send). That does not break wire encryption, but it **extends trust** to those binaries and anything that can read their output or logs. Treat hook paths like running arbitrary executables. See **[CLIENT_HOOKS.md](CLIENT_HOOKS.md)** for the experimental protocol and env vars.
65+
6266
### Client global E2E key
6367

6468
When the client **auto-generates** a global E2E key, it does **not** print the full base64 key to stdout (only a Key ID). Distribute the key using **`MARCHAT_GLOBAL_E2E_KEY`**, **`keystore.dat`** plus passphrase, or another channel you treat as confidential; do not rely on terminal output for key material.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Example stdin hook: append each JSON line to a log file for spike testing.
2+
//
3+
// Build: go build -o marchat-hook-log ./client/exthook/example_hook
4+
// Run client from repo root: go run ./client (not go run .)
5+
// Env: MARCHAT_CLIENT_HOOK_RECEIVE=C:\full\path\marchat-hook-log.exe (and/or MARCHAT_CLIENT_HOOK_SEND)
6+
//
7+
// Default log: $TEMP/marchat-client-hook.log (Windows) or /tmp/marchat-client-hook.log
8+
package main
9+
10+
import (
11+
"bufio"
12+
"io"
13+
"log"
14+
"os"
15+
"path/filepath"
16+
)
17+
18+
func main() {
19+
logPath := os.Getenv("MARCHAT_HOOK_LOG")
20+
if logPath == "" {
21+
if d := os.Getenv("TEMP"); d != "" {
22+
logPath = filepath.Join(d, "marchat-client-hook.log")
23+
} else if d := os.Getenv("TMPDIR"); d != "" {
24+
logPath = filepath.Join(d, "marchat-client-hook.log")
25+
} else {
26+
logPath = filepath.Join(os.TempDir(), "marchat-client-hook.log")
27+
}
28+
}
29+
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
30+
if err != nil {
31+
log.Fatal(err)
32+
}
33+
defer f.Close()
34+
35+
r := bufio.NewReader(os.Stdin)
36+
line, err := r.ReadBytes('\n')
37+
if err != nil && err != io.EOF {
38+
log.Fatal(err)
39+
}
40+
if len(line) == 0 {
41+
return
42+
}
43+
if _, err := f.Write(line); err != nil {
44+
log.Fatal(err)
45+
}
46+
if line[len(line)-1] != '\n' {
47+
_, _ = f.Write([]byte{'\n'})
48+
}
49+
}

0 commit comments

Comments
 (0)