Rework core integration: validated capability layer, docs, and session state

This commit is contained in:
Fisto
2026-09-11 14:09:04 +02:00
parent 2a15f057df
commit e62d3007d8
33 changed files with 1897 additions and 612 deletions
+23
View File
@@ -0,0 +1,23 @@
# Veskslop documentation index
This file lists the project documents.
## Design documents
| Document | Scope |
| --- | --- |
| [architecture.md](architecture.md) | System parts, boundaries, and data flow |
| [core-bridge.md](core-bridge.md) | The Vesktop-side capability layer |
| [daemon.md](daemon.md) | The external MCP daemon |
| [communication.md](communication.md) | Trust model, commands, and the trigger log |
| [auth.md](auth.md) | QR login and token persistence |
| [testing.md](testing.md) | Tests and the live test setup |
| [decisions.md](decisions.md) | Design decisions and their reasons |
## Documents outside `docs/`
| File | Scope |
| --- | --- |
| [../AGENTS.md](../AGENTS.md) | Agent entry point and guardrails |
| [../poc/README.md](../poc/README.md) | Spike and daemon run commands |
| [../README.md](../README.md) | Project introduction |
+74
View File
@@ -0,0 +1,74 @@
# Architecture
## Purpose
Veskslop controls one or more Discord user accounts without a visible client window. It uses the
real Vesktop client, so the traffic comes from the official client.
## Parts
The system has four parts:
1. **Vesktop core** — the real Vesktop client. It runs headless. It exposes a small capability layer.
2. **Core bridge** — the capability layer. It accepts validated actions and pushes events.
3. **Veskslop daemon** — the external process. It holds all logic. It exposes MCP.
4. **MCP clients** — agents and tools. They call the daemon.
```
MCP clients (agents)
│ Streamable HTTP (MCP)
▼
Veskslop daemon ──── JSON lines over a Unix socket ────▶ Vesktop core
│ │
│ QR login, pacing, trigger log, │ validated actions + events
│ trust list, MCP tools ▼
│ Discord (official client traffic)
▼
Local state files (.token, .veskslop-state.json)
```
## Boundary rules
The boundary is narrow on purpose.
- The core exposes a fixed action list. The list has no general "run this code" action.
- The core validates every input. Bad input returns a structured error. The client does not crash.
- The core rate limits all outbound actions. A bug in the daemon cannot cause a send flood.
- The daemon holds the logic: triggers, trust list, command parsing, the trigger log, and pacing.
- The daemon holds no Discord protocol code.
These rules keep upstream rebases small. Logic changes do not touch the core. The core changes only
when the action list changes.
## Components and status
| Component | Location | Status |
| --- | --- | --- |
| Action protocol | `src/shared/puppetProtocol.ts` | Implemented |
| Validated actions | `src/renderer/puppet/actions.ts` | Implemented |
| Event push | `src/renderer/puppet/events.ts` | Pending |
| Main bridge | `src/main/puppetBridge.ts` | Pending |
| Core hooks | `src/main/main.ts`, `src/renderer/ipcCommands.ts`, `src/shared/IpcEvents.ts`, `src/preload/VesktopNative.ts` | Pending |
| Daemon (CDP transport) | `poc/src/` | Implemented and verified live |
| Daemon (core-bridge transport) | `poc/src/core-client.ts` | Pending |
| QR login | `poc/src/qr-login.ts` | Implemented and verified live |
| Pacing | `poc/src/pacing.ts` | Implemented |
| Trigger log and trust list | `poc/src/communication.ts`, `poc/src/puppet.ts` | Implemented on the CDP transport |
| Voice, audio, video | Not started | Not started |
## Data flow
1. An agent calls an MCP tool on the daemon.
2. The daemon applies pacing and policy.
3. The daemon sends one JSON-line action to the core bridge.
4. The bridge forwards the action to the renderer.
5. The renderer validates the action and calls the official client.
6. The renderer returns a result or a structured error.
7. The bridge returns the response to the daemon.
For inbound messages, the flow goes the other way:
1. The client receives a gateway event.
2. The renderer normalizes the event.
3. The bridge pushes the event to subscribed daemon connections.
4. The daemon applies the trigger rules and updates the log.
+63
View File
@@ -0,0 +1,63 @@
# Authentication
This document describes account login and token persistence.
## QR login
`poc/src/qr-login.ts` logs in to an account with a QR code. The tool connects to the Discord
remote-auth gateway. It shows the QR code in the terminal. The user scans the code with the Discord
mobile app. The tool then writes the account token to a file.
```sh
pnpm poc:qr
TOKEN_FILE=/path/to/token pnpm poc:qr
```
The tool prints the QR code, the approval result, and the token file path. If Discord asks for a
captcha, the tool shows the challenge and reads a key from `HCAPTCHA_KEY` or from the terminal.
The flow was verified end-to-end on 2026-09-11.
## Token file
- Default path: `poc/.token`, relative to the `poc/` directory.
- Override: `TOKEN_FILE`.
- File mode: `600`.
- The file is gitignored.
- The token belongs to the puppet account. zzib is only the DM test target.
## Client session persistence
The client session is separate from the token file. The session lives in the Electron profile.
The design has three parts:
1. **Pinned profile directory.** `DATA_DIR` is `$VENCORD_USER_DATA_DIR`, else `$VESKSLOP_DATA_DIR`,
else `<appData>/veskslop`. The path does not depend on the app name. A rename or a version update
does not move the profile.
2. **Token seeding.** On startup, the preload reads the token file through a synchronous IPC call.
The preload writes `localStorage.token` before the client boots. The seed happens only when no
session exists. `VESKSLOP_FORCE_TOKEN=1` overrides an existing session.
3. **Recovery.** If Discord invalidates the session, run `pnpm poc:qr` again. Then update the token
file.
The client stores the session in `<DATA_DIR>/sessionData`. A version update keeps the directory.
## Devtools caution
Discord deletes the stored token when it thinks that devtools opened. Vesktop already patches this
detector in `src/renderer/patches/devtoolsFixes.ts`. Keep this patch. Without it, random logouts
can occur.
## Status
| Part | Status |
| --- | --- |
| QR login and token file | Implemented and verified |
| Pinned profile directory | Implemented and verified. Currently out of the tree after the CDP pivot. |
| Token seeding | Implemented and verified. Currently out of the tree after the CDP pivot. |
| Headless first-launch skip | Implemented and verified. Currently out of the tree after the CDP pivot. |
| Devtools patch | Upstream Vesktop code. Keep it. |
The three out-of-tree parts are small. Add them again with the core bridge, at the same time as the
bridge hooks.
+86
View File
@@ -0,0 +1,86 @@
# Communication
This document describes the trigger rules, the trust model, the commands, and the trigger log.
## Trigger rules
A message is a trigger when one of these conditions is true:
- The message is a direct message to the puppet. A direct message is a one-to-one DM, not a group.
- The message mentions the puppet. This applies in guild channels and group DMs.
All other messages are ignored.
## Trust model
The puppet has one owner and a trust list.
- The owner is always trusted. The default owner is `177117296140091393` (zzib).
- The trust list holds additional user IDs.
- A trusted user gets the 100-entry log. An untrusted user gets the 30-entry log.
The owner controls the trust list. Commands from other users do not change the list.
## Commands
The owner sends commands from Discord. In a direct message, the text is the command. In a guild or
a group DM, mention the puppet first.
| Command | Effect |
| --- | --- |
| `whitelist list` | Show the owner and the trusted users. |
| `whitelist add @user` | Add one or more users to the trust list. |
| `whitelist remove @user` | Remove one or more users from the trust list. |
Notes:
- The owner cannot be removed.
- The command accepts mentions and raw user IDs.
- The daemon replies with a confirmation. The reply uses the same pacing as all other sends.
## Trigger log
The log has two ring buffers.
| Buffer | Limit | Content |
| --- | --- | --- |
| Trusted | 100 entries | Triggers from the owner and trusted users |
| Untrusted | 30 entries | Triggers from all other users |
One entry holds these fields:
| Field | Meaning |
| --- | --- |
| `id` | The message ID |
| `channelId` | The channel ID |
| `guildId` | The guild ID, or absent for a DM |
| `authorId` | The author user ID |
| `authorName` | The author username |
| `content` | The raw message content |
| `timestamp` | The message timestamp |
| `trusted` | True for a trusted author |
| `command` | The parsed command text, when the message is a command |
## Event flow
1. The client receives a `MESSAGE_CREATE` gateway event.
2. The core renderer normalizes the event.
3. The core bridge pushes the event to the daemon.
4. The daemon applies the trigger rules.
5. The daemon adds a qualifying message to the log.
6. The daemon runs a trusted command, when the message is a command.
## State file
The daemon stores the owner ID and the trust list in JSON.
- Default path: `poc/.veskslop-state.json`.
- Override: `VESKSLOP_STATE_FILE`.
- The trigger log is in memory only. The log resets at restart.
## How to add a trigger rule
1. Add the rule in `poc/src/puppet.ts`, in `handleEvent`.
2. Keep the rule small and testable.
3. Add a unit test in `poc/src/test-communication.ts` when the rule is pure logic.
4. Run `pnpm test:comm`.
+129
View File
@@ -0,0 +1,129 @@
# Core bridge
The core bridge is the only surface between the Vesktop core and the external daemon. This document
describes the protocol, the validation rules, and the hooks into upstream files.
## Files
| File | Role | Status |
| --- | --- | --- |
| `src/shared/puppetProtocol.ts` | Action names, error codes, limits, and shared types | Implemented |
| `src/renderer/puppet/actions.ts` | Validated action handlers and the core rate limit | Implemented |
| `src/renderer/puppet/events.ts` | Event normalization and push to main | Pending |
| `src/renderer/puppet/index.ts` | Renderer initialization | Pending |
| `src/main/puppetBridge.ts` | Unix socket server, request forwarding, event fan-out | Pending |
## Transport
The core bridge uses a Unix socket. On Linux the path is `$XDG_RUNTIME_DIR/vesktop-puppet.sock`.
On Linux without `XDG_RUNTIME_DIR`, the path is `/tmp/vesktop-puppet.sock`. On Windows the path is
the named pipe `\\.\pipe\vesktop-puppet.sock`.
The protocol is newline-delimited JSON. Each request gets one response line. An event subscriber
gets event lines until the connection closes.
Request:
```json
{ "id": 1, "action": "message.send", "params": { "channelId": "123", "content": "hello" } }
```
Success response:
```json
{ "id": 1, "ok": true, "result": { "channelId": "123", "sent": true } }
```
Error response:
```json
{ "id": 1, "ok": false, "error": { "code": "CONTENT_EMPTY", "message": "content is empty" } }
```
Event line:
```json
{ "type": "event", "event": { "type": "MESSAGE_CREATE", "channelId": "123" } }
```
## Actions
| Action | Parameters | Result |
| --- | --- | --- |
| `ping` | none | `"pong"` |
| `events.subscribe` | none | `{ "subscribed": true }`. The socket becomes an event subscriber. |
| `state.get` | none | The current user, the guilds, and the channels. Cache-only. |
| `message.send` | `channelId`, `content` | `{ channelId, sent: true }` |
| `message.read` | `channelId`, `limit` | `{ cached, messages }`. Cache-only. |
| `dm.send` | `userId`, `content` | `{ userId, channelId, sent: true }` |
## Validation rules
The core validates all input. The daemon cannot bypass these rules.
- A channel ID and a user ID must match the snowflake format. The format is 17 to 20 digits.
- `content` must be a string. The content must not be empty after a trim.
- `content` must not contain control characters.
- `content` must not be longer than 2000 characters.
- The target channel must exist. The channel type must accept text. The allowed types are
`GUILD_TEXT`, `DM`, `GROUP_DM`, `GUILD_ANNOUNCEMENT`, `ANNOUNCEMENT_THREAD`, `PUBLIC_THREAD`,
and `PRIVATE_THREAD`.
- `limit` must be an integer from 1 to 100.
- A request line must not be larger than 64 KiB.
## Core rate limit
The core rate limit is a safety net. It does not replace the daemon pacing.
- The minimum interval between two outbound actions is 500 ms.
- The maximum is 30 outbound actions per minute.
- The limit applies to `message.send` and `dm.send`.
## Error codes
| Code | Meaning |
| --- | --- |
| `INVALID_REQUEST` | The line is not valid JSON. |
| `PAYLOAD_TOO_LARGE` | The request line is larger than 64 KiB. |
| `UNKNOWN_ACTION` | The action name is not in the action list. |
| `CORE_UNAVAILABLE` | The bridge cannot reach the renderer. |
| `NOT_LOGGED_IN` | No user is logged in. |
| `INVALID_SNOWFLAKE` | The ID is not a snowflake. |
| `INVALID_PARAM` | A parameter has the wrong type or value. |
| `CONTENT_EMPTY` | The content is empty. |
| `CONTENT_TOO_LONG` | The content is longer than 2000 characters. |
| `CHANNEL_NOT_FOUND` | The channel is not in the client cache. |
| `NOT_SENDABLE` | The channel type cannot receive messages. |
| `SEND_FAILED` | Discord rejected the message. |
| `DM_UNAVAILABLE` | The client cannot create a DM channel. |
| `RATE_LIMITED` | The core rate limit rejected the action. |
| `INTERNAL` | An unexpected error occurred. The details go to the local log only. |
## Hooks into upstream files
The bridge uses a small number of hooks. Keep these changes minimal.
| File | Change |
| --- | --- |
| `src/main/main.ts` | Import the bridge. Call `initPuppetBridge()` after `app.whenReady()`. |
| `src/shared/IpcEvents.ts` | Add `PUPPET_EVENT` and `IpcCommands.PUPPET_COMMAND`. |
| `src/renderer/index.ts` | Import `./puppet`. |
| `src/renderer/ipcCommands.ts` | Register `PUPPET_COMMAND` and call `handlePuppetAction`. |
| `src/preload/VesktopNative.ts` | Add `puppet.emit(event)` for event push. |
## How to add an action
1. Add the action name to `PuppetAction` in `src/shared/puppetProtocol.ts`.
2. Add a validation case in `src/renderer/puppet/actions.ts`.
3. Add the action to the known-action set in `src/main/puppetBridge.ts`.
4. Add a tool in the daemon (`poc/src/control-bridge.ts`).
5. Add a unit test for the validation path.
6. Run `pnpm testTypes`, `pnpm lint`, and `pnpm build`.
## Why this shape
The shape gives three properties:
- The daemon cannot run arbitrary code in the client.
- A daemon bug cannot crash the client or send malformed data to Discord.
- The core patch stays small, so an upstream rebase is easy.
+93
View File
@@ -0,0 +1,93 @@
# Daemon
The daemon runs outside Vesktop. It holds all logic. It talks to the core bridge and exposes MCP.
## Files
| File | Role | Status |
| --- | --- | --- |
| `poc/src/control-bridge.ts` | MCP server, tool list, health endpoint | Implemented on CDP |
| `poc/src/puppet.ts` | Trust list, command handling, trigger log | Implemented |
| `poc/src/communication.ts` | Pure log and parser logic | Implemented |
| `poc/src/pacing.ts` | Paced queue and query coalescing | Implemented |
| `poc/src/cdp-client.ts` | Chrome DevTools Protocol client | Implemented (current transport) |
| `poc/src/discord.ts` | Client actions over CDP | Implemented (current transport) |
| `poc/src/core-client.ts` | Client actions over the core bridge | Pending (target transport) |
| `poc/src/qr-login.ts` | Terminal QR login | Implemented and verified |
| `poc/src/test-communication.ts` | Unit tests for the pure logic | Implemented |
| `poc/src/cdp-probe.ts` | CDP feasibility probe | Implemented (dev tool) |
## Transport
The daemon supports two transports.
1. **CDP (current)** — the daemon starts Vesktop with `--remote-debugging-port=9222`. The daemon
connects to the Discord page target. It evaluates the action code in the page and receives
events through a CDP binding. This transport needs no Vesktop patch. The project used it to
prove the concept and to test the client.
2. **Core bridge (target)** — the daemon connects to `vesktop-puppet.sock`. All actions go through
the validated capability layer. This transport gives the core-side guardrails.
The daemon logic is the same on both transports. Only the client class changes.
## Pacing
The daemon paces all commands. The policy is a random delay of 500 to 2800 ms. The queue
serializes the commands. The queue coalesces identical concurrent reads. The daemon also prefers
cached state over queries.
The core adds a second, hard rate limit. See [core-bridge.md](core-bridge.md).
## MCP tools
The daemon exposes MCP over Streamable HTTP. The default address is `http://127.0.0.1:8788/mcp`.
| Tool | Purpose |
| --- | --- |
| `get_state` | List the user, the guilds, and the channels. |
| `send_message` | Send a message to a channel. |
| `read_messages` | Read cached messages from a channel. |
| `send_dm` | Send a DM to a user. |
| `get_message_log` | Read the trigger log. |
| `whitelist_list` | List the owner and the trusted users. |
| `whitelist_add` | Add users to the trust list. |
| `whitelist_remove` | Remove users from the trust list. |
## Run commands
```sh
# Start the daemon. It connects to the client on CDP port 9222.
pnpm control:bridge
# Send one test DM. The only permitted live target is zzib.
pnpm poc:dm
# Run the pure-logic unit tests.
pnpm test:comm
# Probe CDP access and events.
pnpm cdp:probe
```
## Health endpoint
`GET /health` returns the CDP state, the owner ID, and the query count.
```json
{ "ok": true, "cdpConnected": true, "ownerId": "177117296140091393", "queries": 4 }
```
## How to add an MCP tool
1. Add the action to the core bridge. See [core-bridge.md](core-bridge.md).
2. Add a `server.registerTool` call in `poc/src/control-bridge.ts`.
3. Wrap outbound calls in `paced`.
4. Add a unit test if the tool has local logic.
5. Run `pnpm check` in `poc/`.
## Security notes
- The MCP server binds to localhost by default.
- Remote access must go over LAN or Tailscale. Do not expose the daemon to the public internet.
- The daemon holds no Discord credentials. The client holds the session.
- The daemon token file is separate from the client session. See [auth.md](auth.md).
+81
View File
@@ -0,0 +1,81 @@
# Decisions
This document records the main design decisions and their reasons.
## ADR-1: Use the real client
**Decision.** Run the real Vesktop client. Do not use a third-party selfbot protocol library.
**Reason.** The popular selfbot libraries are archived or deprecated. Stale protocol emulation is a
ban risk. The real client sends official traffic.
## ADR-2: Validated capability layer
**Decision.** The core exposes a small, fixed action list. The core validates all input. The core
returns structured errors. All logic lives in the external daemon.
**Reason.** The split gives three properties:
- The daemon cannot run arbitrary code in the client.
- A daemon bug cannot crash the client or send malformed data to Discord.
- Core patches stay small, so an upstream rebase is easy.
**Consequence.** A new capability needs a small core change. The action list is the contract.
## ADR-3: CDP is a fallback, not the primary transport
**Decision.** The project validated control over Chrome DevTools Protocol. The primary transport is
the validated core bridge.
**Reason.** CDP needs no Vesktop patch. However, CDP evaluates raw code in the page. It bypasses the
core-side validation and rate limits. The project keeps the probe as a development tool.
**Status.** The daemon uses CDP at this time. The core-bridge transport is pending.
## ADR-4: No bespoke client
**Decision.** Do not write a new Electron shell for Discord.
**Reason.** Vesktop maintains the desktop integration for Vencord, for example settings, themes,
quick CSS, and the native plugin bridge. A bespoke shell must reimplement that surface. Vesktop
already does this work.
**Consequence.** Keep the patch small and isolated. This approach is cheaper than a new shell.
## ADR-5: Two pacing layers
**Decision.** The daemon paces all commands with a random delay of 500 to 2800 ms. The core adds a
hard limit of 500 ms between sends and 30 sends per minute.
**Reason.** The daemon policy looks human. The core limit is a safety net. A daemon bug cannot cause
a send flood.
## ADR-6: Pin the profile directory
**Decision.** `DATA_DIR` is `$VENCORD_USER_DATA_DIR`, else `$VESKSLOP_DATA_DIR`, else
`<appData>/veskslop`.
**Reason.** The Discord session lives in the Electron profile. An app rename or a version update
must not move the profile. A stable path keeps the login.
## ADR-7: Separate token file
**Decision.** The QR tool writes the account token to a file. The client seeds the token at startup
when no session exists.
**Reason.** The token file survives a profile reset. It also survives a change in the Discord
storage format. The recovery path is one QR scan.
## ADR-8: Trigger log limits
**Decision.** Keep the last 100 triggers from trusted users and the last 30 triggers from untrusted
users.
**Reason.** The limits keep memory small. The trusted limit is larger because those messages matter
more.
## ADR-9: One live test target
**Decision.** The only permitted live-send target is user zzib (`177117296140091393`).
**Reason.** Tests must not send messages to other users. DMs to zzib are safe and permitted.
+101
View File
@@ -0,0 +1,101 @@
# Testing
This document describes the test setup, the run commands, and the known gaps.
## Unit tests
The pure daemon logic has unit tests. The tests cover the ring buffers, the mention helpers, the
command parser, the trust check, and the pacing range.
```sh
cd poc
pnpm test:comm
```
The file is `poc/src/test-communication.ts`. The tests do not need Discord or Vesktop.
## Type checks
```sh
cd poc && pnpm check # daemon and spike code
cd .. && pnpm testTypes # Vesktop core
```
Both commands must pass before a change is complete.
## Build
```sh
pnpm build
```
The build writes the application to `dist/`.
## CDP probe
The probe checks the current CDP transport. Start the client with
`--remote-debugging-port=9222` first.
```sh
cd poc
pnpm cdp:probe
```
A good result shows the Vencord global, the Flux dispatcher, the current user, and one binding
event.
## Live test setup
The container has no display and no PipeWire. Use a virtual display.
```sh
# 1. Start a virtual display. On Linux, install Xvfb or extract it locally.
Xvfb :99 -screen 0 1280x720x24 &
# 2. Start the stock client with a pinned profile and remote debugging.
DISPLAY=:99 \
VENCORD_USER_DATA_DIR=~/.config/veskslop \
electron . --no-sandbox --disable-gpu --disable-dev-shm-usage --remote-debugging-port=9222
# 3. Start the daemon.
cd poc && pnpm control:bridge
# 4. Check the health endpoint.
curl http://127.0.0.1:8788/health
```
Notes:
- `VENCORD_USER_DATA_DIR` pins the Electron profile. The profile holds the client session.
- Write `{"firstLaunch": false}` to `<DATA_DIR>/state.json` before the first start. This step skips
the first-launch window. The `VESKSLOP_HEADLESS=1` flag does the same job when it is in the tree.
- The container needs `libasound.so.2` for Electron. Extract the library locally when the system
package is not available.
## Sanctioned live target
The only permitted live-send target is user **zzib** (`177117296140091393`). Send a test message
with this command:
```sh
cd poc
pnpm poc:dm
```
## CDP live results
The CDP transport was verified on 2026-09-11:
- `get_state` returned the real account and 7 guilds.
- `send_dm` delivered a real DM to zzib.
- The client ran headless under Xvfb.
- The token was seeded from the token file.
## Known gaps
| Gap | Effect |
| --- | --- |
| Mock core is not in the tree. | The daemon tests need the real client. |
| The trigger event path needs an incoming message. | Use zzib's account for a live test. |
| `message.read` is cache-only. | A channel that the client never opened returns zero messages. |
| Voice, audio, and video are not started. | No test coverage. |