Skip to content
dsh.fish
Bundle

dsh-db-connections

Named database connections for DeepSeek Harness: configure MySQL in a settings page, then refer to them by name in chat (for example "query 128"). Read-only by default, with writes opt-in per connection.

Source
lb290
License
MIT
Updated
Updated 8 days ago

Readme

# dsh-db-connections

English | [中文](README.zh.md)

**Named database connections** for DeepSeek Harness. Configure your databases in a settings page, then refer to them by name in chat — say "use 128" and the model knows which database to reach and how.

## The problem it solves

Asking an AI to query a database normally means restating host, port, user, and password every time. This plugin stores those in configuration and leaves one short name (`128`) for you and the model to use:

- The connection names are contributed to the system prompt, so "use 128" resolves without you repeating an address or a credential.
- All three tools take `connection` as their first argument, so the model only carries names.
- **No password ever reaches the prompt** — only host, port, default schema, and whether the connection is writable.

## Install

Plugin directory: `<path-to>/dsh-db-connections`

Install dependencies first (`mysql2` and `yaml`):

```sh
cd <path-to>/dsh-db-connections
npm install
```

Then install into a profile with the DSH plugin command. The Electron desktop app owns its `desktop` profile exclusively, so use the CLI the app ships:

```sh
# Default Windows desktop path; substitute your own if installed elsewhere.
"C:\Program Files\deepseek\resources\runtime\cli\bin\dsh.cmd" ^
  plugin --profile desktop add "<path-to>/dsh-db-connections"
```

`dsh plugin add` records the package in the profile's `dsh.profile.bundles`, and the package's own `cordis.patch.yml` inserts the `id: db-connections` row with `connections: []` (connect to nothing by default).

Any other profile (`web`, `tui`, your own) uses the ordinary CLI:

```sh
dsh plugin --profile <name> add /absolute/path/to/dsh-db-connections
```

> **Restart the desktop app once after installing**, so the settings page appears: the client-plugin roster is scanned at startup, not re-scanned while running.
>
> Conversely, **the plugin directory must not be deleted, renamed, or moved**: the profile references it through a `link:`, and a missing directory produces `cannot resolve profile bundle "dsh-db-connections"`.

## Configure a database

Two routes, one configuration — use whichever you prefer.

### Route 1: click through the app (recommended)

Open **Settings → Databases** to add, edit, and delete connections, pick **read-only** or **read-write** per connection from a dropdown, and press **Test** to verify a connection on the spot. Saving applies immediately — no restart, and no need to touch the configuration file.

The page follows the shell's own language setting (Chinese/English): dictionaries are contributed with `ctx.locale.register()` and lookups go through `ctx.locale.bind()`, rather than guessing from the browser, so the page follows a language switch.

Passwords are shown and edited as plain text: the value lives in a file you own, and hiding it only makes a wrong password impossible to spot.

### Route 2: edit the configuration file

**Redefine** the `db-connections` row in the profile's `cordis.patch.yml`. The loader replaces a row's whole `config` rather than deep-merging it, so restate every key:

```yaml
- id: db-connections
  name: "dsh-db-connections"
  config:
    connections:
      - name: "128"              # the name you and the model use in chat
        host: 192.0.2.10
        port: 3306               # optional, default 3306
        user: root
        password: "change-me"       # or use passwordEnv, below
        database: appdb     # optional default schema
        description: dev database # optional; shown in the prompt
        allowWrite: false        # optional, default false = read-only
```

Where the profile's `cordis.patch.yml` lives:

| Platform | Path |
| --- | --- |
| Windows | `C:\Users\<you>\.dsh\profiles\desktop\cordis.patch.yml` |
| macOS / Linux | `~/.dsh/profiles/desktop/cordis.patch.yml` |

Press **Reload** on the settings page, or restart the app, to apply a hand edit. A change made through the settings page needs neither.

### Several connections

`connections` is a list, in order; names must be unique:

```yaml
- id: db-connections
  name: "dsh-db-connections"
  config:
    connections:
      - name: "128"
        host: 192.0.2.10
        user: root
        password: "change-me"
        database: appdb
        description: dev database
      - name: "prod"
        host: 192.0.2.9
        user: readonly_user
        passwordEnv: PROD_DB_PASSWORD   # keeps the secret out of the file
        database: app_prod
        description: production (read-only account)
      - name: "local"
        host: 127.0.0.1
        user: root
        password: ""
        description: local MySQL
```

### Keep the password out of the file

Name an environment variable with `passwordEnv`; it is resolved at load, and a missing variable fails loudly rather than becoming an empty password:

```yaml
        passwordEnv: PROD_DB_PASSWORD
```

When both are present, `passwordEnv` wins. **A settings-page save does not destroy this**: the page edits raw configuration, so a resolved password is never written back to the file. Note the other direction: the settings page has no `passwordEnv` field of its own — creating the indirection is a hand edit of the configuration file.

### Write access

**Set per connection, read-only by default.** The settings page's dropdown ("Read-only" / "Read-write") is exactly the `allowWrite` flag in the configuration file.

Read-only has two independent layers, neither of which is keyword matching in the plugin:

1. With `allowWrite: false` (the default), `db_execute` refuses outright.
2. Even on a writable connection, every `db_query` statement runs inside a server-enforced `START TRANSACTION READ ONLY` — **MySQL rejects the write** — so bypass-shaped statements such as `SELECT ... INTO OUTFILE`, `SELECT ... FOR UPDATE`, and `WITH ... UPDATE` are refused by the server too.

Before enabling writes, confirm the database should accept changes from the agent. A read-only account is the better choice for most deployments than turning on `allowWrite`.

## The three tools

| Tool | Purpose |
| --- | --- |
| `db_query` | Read-only queries, in a server-enforced read-only transaction. Returns columns plus rows. |
| `db_execute` | Writes (INSERT/UPDATE/DELETE/DDL). Available only with `allowWrite: true`. |
| `db_schema` | Inspect structure: omit `database` to list schemas; pass `database` to list tables; add `table` for columns, indexes, and DDL. |

You do not call these yourself. Typical requests:

- "list the schemas on 128" → `db_schema`
- "what does sys_user look like in 128?" → `db_schema`
- "show me the first 10 users in 128" → `db_query`
- "change the remark on id=1 in 128" → `db_execute` (needs a writable connection)

## Per-connection options

| Key | Default | Meaning |
| --- | --- | --- |
| `name` | required | Connection name, unique, ≤64 characters |
| `host` | required | Hostname or IP |
| `user` | required | Account name |
| `password` | `''` | Password, stored as plain text in the configuration file |
| `passwordEnv` | — | Environment variable holding the password; wins over `password` |
| `port` | `3306` | TCP port |
| `driver` | `mysql` | Only `mysql` is supported |
| `database` | — | Default schema; without it, qualify SQL as `schema.table` |
| `description` | — | Shown in the prompt, for context |
| `allowWrite` | `false` | Whether this connection accepts writes; the settings page's read-only / read-write dropdown |
| `ssl` | `false` | Connect over TLS |
| `connectTimeoutMs` | `10000` | Connection timeout |
| `queryTimeoutMs` | `30000` | Per-statement timeout (client and server `MAX_EXECUTION_TIME`) |
| `maxRows` | `200` | Rows returned per call; an excess is reported, never silently dropped |
| `maxCellChars` | `500` | Characters kept per cell |
| `maxDdlChars` | `20000` | Bound for the table DDL `db_schema` returns, kept separate so a CREATE TABLE is not cut off |
| `connectionLimit` | `4` | Pool size |

A bad configuration fails **at plugin load** naming the offending key, rather than when the model first calls a tool.

## Security notes

- Passwords never appear in the prompt or in tool results.
- Read-only comes from a server-side transaction, not a keyword denylist.
- Multiple statements are disabled (`multipleStatements: false`): one call runs one statement.
- Temporal and exact numeric columns are returned as strings (`dateStrings`, `bigNumberStrings`) so the plugin never invents a time-zone interpretation or loses precision.
- The settings API answers **same-origin writes only**, and requires `Content-Type: application/json` on them (a cross-origin request with that type must preflight, and the API answers no preflight). Another web page therefore cannot read or change your passwords.

## Which file the settings page writes

A save edits the `id: db-connections` row in the running profile's `cordis.patch.yml`; the page shows the full path at the bottom. The write parses the document, replaces only that row, and re-serializes, so:

- other plugins' configuration is preserved verbatim;
- **comments are preserved**, including indented and trailing ones.

If the row is absent (for example you keep it in a different patch layer), a save creates it and leaves everything else alone. A file that is not valid YAML fails the save rather than being overwritten.

## Development and tests

```sh
cd <path-to>/dsh-db-connections
npm install

node test-config.mjs      # config validation + load/dispose lifecycle (no database)
node test-store.mjs       # configuration file read/write + settings API (no database)
node test-client.mjs      # browser half: slot registration, inject declarations, save payload
node test-render.mjs      # renders the settings page: dropdown, save, test, add/remove (no browser)
node test-meta.mjs        # plugin-list display name/description localization (locale files + export map)
node test-plugin-meta.mjs # calls the harness's own readPluginMeta to verify display metadata
node test-profile.mjs     # package manifest + the harness's own scan deciding the page loads
node test-integration.mjs # real database: tools and every refusal path
node test-write.mjs       # real database: read-write mode (creates and drops its own table)
node test-loader.mjs      # loads through the real SystemPrompt/ToolRuntime services, then saves live
```

The scripts that contact a real database (`test-integration`, `test-write`, `test-loader`) hard-code connection settings at the top; read them before running.

Two tests run the harness's **real** logic rather than a restatement of it:

- `test-plugin-meta.mjs` calls the exported `readPluginMeta()`, the code that decides what the plugin list displays. It imposes hard rules (the English file must be named exactly `en.json`, it must resolve through the export map, every language file must share its directory, filenames must be bare language ids); violating any one fails the **whole** metadata read and the list silently falls back to the English technical name. A hand-written check would miss that.
- `test-profile.mjs` calls `ClientModuleRegistry.resolveMeta()`. A client plugin must declare both `dsh.client` **and** `exports["./client"]`; without the latter the harness throws at startup — breaking the whole app, not merely hiding this plugin.

## Layout

```
dsh-db-connections/
├── index.js              # entry: config, prompt section, three tools, settings API
├── client.js             # browser half: the settings page (hand-written ModuleLoader bundle, no build step)
├── cordis.patch.yml      # bundle layer: inserts the db-connections row
├── icon.svg              # the icon shown in the plugin list
├── locale/
│   ├── en.json           # plugin-list name and description, English
│   └── zh.json           # plugin-list name and description, Chinese
├── src/
│   ├── api.js            # settings API: same-origin, JSON, and error mapping
│   ├── config.js         # config validation and normalization (fails loud at load)
│   ├── live.js           # the mutable runtime connection set (a save swaps it in place)
│   ├── prompt.js         # model-facing connection list (never a password)
│   ├── registry.js       # pools plus the read-only execution path
│   ├── sql.js            # statement classification and identifier quoting
│   ├── store.js          # reads/writes the profile's cordis.patch.yml (keeps comments and other rows)
│   ├── tool.js           # raw tool definition and argument validation
│   ├── tools.js          # db_query / db_execute / db_schema
│   └── values.js         # cell values to lossless JSON
└── test-*.mjs
```

## How localization is wired

The plugin has two independent localizations, covering two different surfaces:

| Surface | Mechanism | Files |
| --- | --- | --- |
| Inside the settings page (field labels, buttons, hints) | the client locale service | the `DICT` in `client.js`, contributed with `ctx.locale.register()` and read through `ctx.locale.bind()` |
| The plugin list's **name and description** | package metadata | `locale/en.json` + `locale/zh.json`, each a `meta.title` / `meta.description` |

The second has hard rules, and violating any one silently falls back to the English technical name (no error is shown):

- The English file must be named **exactly** `en.json`; the harness finds it first, then scans that directory for other `.json` files as further languages.
- `package.json` must export `"./locale/*.json": "./locale/*.json"`, or the harness cannot resolve it.
- Filenames must be bare language ids (`zh.json` is fine, so is `zh-CN.json`; `zh_CN.json` is not).
- Every language file must sit in the **same directory** as `en.json`.
- `package.json`'s `description` participates in fallback alongside the English dictionary; when the two disagree the list shows one text and other surfaces the other, so `test-meta.mjs` asserts they match.

To add a third language, drop a file into `locale/` (say `ja.json`); no code changes are needed.

## Known limitations

- MySQL / MariaDB only. Adding PostgreSQL means a second pool and read-only path in `src/registry.js`, plus a `SUPPORTED_DRIVERS` entry in `src/config.js`.
- The read-only guarantee relies on the server supporting `START TRANSACTION READ ONLY` (MySQL 5.6+, MariaDB 10.0+). On older servers, use a read-only account instead.
- Schema answers are read live from `information_schema` on every `db_schema` call — no cache, so what you see is current.
- The client-plugin roster is scanned at **app startup**, so a first install (or any new client half) needs one desktop restart before the page appears. Later configuration and code changes hot-reload without a restart.
- The page's **Test** button probes the *saved* connection, because pools are built from saved names. Change the settings, save, then test.
- The profile references this directory by link, so **do not delete, rename, or move it**; to relocate, `dsh plugin remove` and `add` again.

Install

dsh plugin --profile web add github:lb290/dsh-db-connections

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source