DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

MoruTeaven /

MoruTeaven/dsh-key-panel

Verified

This plugin has no description yet.

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@36fc41d9

dsh-key-panel

@moruteaven/dsh-key-panel on npm · English · 简体中文 · 日本語

A secret store for DSH Desktop. Keep API keys in one place, have them injected into the assistant's shell as $DSH_* variables, and decide — from a settings-page panel — exactly how much the assistant is allowed to do with them.

The problem this solves: an assistant that needs to call Cloudflare, or OpenAI, or your database needs a credential. Pasting it into the conversation puts it in the transcript forever. Sending it as a file means it lands in some directory you forget about. This puts it in one file, exposes it as an environment variable, and never lets the value enter the conversation at all.

Assistant needs to deploy a Worker
  → writes: wrangler deploy --api-token $DSH_CLOUDFLARE_TOKEN
  → the shell has it; the model's context never does

Features

  • Settings-page panel. Add, edit, reveal, copy and delete keys where you already manage the rest of the app. No config file editing.
  • $DSH_* injection. Every key becomes a variable in the assistant's shell. Changes take effect on the next command — no restart.
  • Three access modes, chosen by you and persisted: readonly (default) · write · edit.
  • Platforms and accounts. Most providers need two values (an account id and a token), and one provider often has several accounts. Group keys under a platform and an account; each slot becomes DSH_<PLATFORM>_<ACCOUNT>_ID and DSH_<PLATFORM>_<ACCOUNT>_KEY. Grouping is only a way to organise storage — the variable names stay flat, and so does everything that reads them.
  • Provenance tracking. Keys record whether you or the assistant created them, and a write cannot launder one into the other.
  • Recent activity. A log of what each shell command was handed, alongside the purposes the assistant chose to declare. The two are shown together, never paired. Names and timestamps only — no value is ever written to it.
  • Name scope (not exposed yet). The host can restrict the assistant to names matching DSH_AGENT_*, and does enforce a scope already in the store, but the panel field is hidden in this release.
  • Two-phase deletion. Deleting needs a second, confirmed call with a short-lived token — a single stray tool call cannot remove a key.
  • Values never reach the model. No tool returns a key value, in any mode.
  • Idempotent, hot-reload safe. The panel re-reads policy on every call.

Install

Settings → Plugins → search "dsh-key-panel" → Install → restart DSH Desktop

The panel then appears in Settings → Keys.

Other channels:

  • npm — npm i @moruteaven/dsh-key-panel, then add the bare package name @moruteaven/dsh-key-panel to dsh.profile.bundles in your profile's package.json. The bundles list takes bare package names only; a file: or path spec is rejected there (file: is fine in dependencies).
  • From source — see CONTRIBUTING.md.

Usage

Add a key

Settings → Keys → Add key.

Field Notes
Name Must match DSH_[A-Z][A-Z0-9_]* — e.g. DSH_CLOUDFLARE_TOKEN
Purpose Optional. Shown to the assistant as the variable's description.
Value The secret. Stored plaintext; shown masked afterwards.

Then tell the assistant to use it:

Deploy the worker, the token is in $DSH_CLOUDFLARE_TOKEN.

Platform and account

When a provider wants two values — an account id and a token, say — and you have more than one account with them, the flat list gets hard to read. Add platform and Add account group those keys and name them for you:

You enter You get
platform CF, account WORK DSH_CF_WORK_ID and DSH_CF_WORK_KEY

The panel shows both names before you commit, so you can see what will land in the shell. Identifiers are uppercase letters, digits and underscore; lowercase is rejected rather than silently uppercased, because a name you did not ask for is worse than one you have to retype.

Fill in the values from the account itself. Once the account exists, its two variable names are already decided — they are derived from the identifiers, not chosen by you. So the account row carries a Fill in keys button that opens a popup collecting both values at once and files them under that account. The derived names are shown in the popup but never typed, which is the point: making you carry a name the plugin computed up to the flat Add key card, once per field, was transcription.

Each slot shows whether it holds a value, and a slot can be left blank for now. Add key is still there for ungrouped keys, which have no account to be filled in from.

Each platform and account also takes a display name, which is what the panel shows. It is separate from the identifier on purpose: identifiers are baked into variable names and cannot be changed afterwards, while display names are yours to edit at any time. If you want a platform to read as "Cloudflare" while its variables stay DSH_CF_*, that is exactly what the two fields are for.

If the names an account would produce are already taken — you added DSH_CF_WORK_KEY by hand before creating that account — the panel says so and asks before continuing, naming the keys involved. Saving over them would replace their values, so it is not done silently.

Two things worth knowing:

  • Grouping is organisation, not security. The variables are ordinary flat DSH_* names — that is the whole point, since a shell has no nesting. What grouping buys you is a readable panel and, incidentally, a scope that lines up with one account (see below).
  • Deleting a platform or account will not take your secrets with it. If keys are still filed under it the delete is refused; you clear or re-file them first. If you ask it to go ahead anyway, the keys are un-filed — moved back to the ungrouped list — and their values are untouched.

Keys you never filed stay exactly as they were. Nothing has to be reorganised for this feature to be useful, and an existing store keeps working unchanged.

Recent activity

The panel keeps two records, and puts them side by side:

Record Written by Contains
declared the assistant, calling key_panel_intent a timestamp it says a purpose, and the names it means to use
handed to command the plugin itself, every time a shell command is resolved a timestamp and the names that went into that command

They are shown together, not paired. An intent and a use that sit near each other in the list may belong to the same task, but nothing in the data says so, and joining them would be a guess presented as a fact.

What "handed to command" does not mean. The host resolves the whole $DSH_* environment before every shell command and cannot see what the command does with it. So a row means those names were in scope at that moment — never that the command read them, and never what it did with them. Treat the log as a signal about which credentials a workflow depends on, not as an audit trail.

Two practical consequences of that:

  • The assistant may declare nothing. key_panel_intent is optional and skipping it costs nothing, so expect undeclared activity. The plugin asks for intent where it is cheap, but never requires it — a required step in front of every command would turn into a reflex and stop carrying information.
  • Costs are off the command path. Records are buffered in memory and written in batches, so logging never slows down a command. The log is capped (oldest entries are dropped) and a write failure is swallowed — losing the tail of this file costs a trend, not a secret.

The log lives beside the key store in usage.jsonl and holds names and timestamps only. No value is ever written to it.

Access modes

Mode Assistant can Assistant cannot
readonly (default) use keys change anything — no model-facing tool is registered at all
write add keys; replace keys it created delete anything; edit your keys
edit add, change, delete — (delete still needs confirmation)

Set it in the panel. It applies to the next tool call, no restart.

Start at readonly. Move up only when you actually want the assistant adding its own credentials — that is the one workflow the other two modes exist for.

Restrict by name

Not exposed in the panel yet. The scope itself works — it is validated, persisted, and enforced on every model call, and a scope already in the store keeps applying. Only the field that sets it is hidden for now, behind the SHOW_SCOPE_UI flag in lib/client.js. Set it by hand in the store if you need it; flipping the flag brings the field back.

This has a security consequence worth knowing before you pick a mode: with no scope set, the access mode is the only thing limiting which keys the assistant can reach. An unset scope means no restriction. If you want a narrower boundary, set one — in the store by hand for now.

The scope limits the assistant to matching names:

Value Effect
(blank) no restriction
DSH_AGENT_* only names with that prefix
DSH_CF_* only that platform's keys
DSH_CF_WORK_* only one account's credentials

A single * is the only metacharacter. It cannot express a path or a regex.

Configuration

Setting Where Default
Access mode Panel readonly
Name scope Store only (field hidden) unrestricted
Store location $DSH_HOME/key-panel/keys.json ~/.dsh/key-panel/keys.json
Activity log $DSH_HOME/key-panel/usage.jsonl ~/.dsh/key-panel/usage.jsonl

Storage format

{
  "version": 2,
  "policy": { "accessMode": "readonly", "scopePattern": null },
  "platforms": {
    "CF": { "label": "Cloudflare", "createdAt": 1758428400000 }
  },
  "accounts": {
    "CF/WORK": { "platform": "CF", "identifier": "WORK", "label": "Work", "createdAt": 1758428400000 }
  },
  "keys": {
    "DSH_CF_WORK_TOKEN": {
      "value": "…",
      "description": "Cloudflare Workers deploy token",
      "origin": "operator",        // "operator" | "model"
      "platform": "CF",            // optional grouping
      "account": "WORK",
      "field": "key",              // "id" | "key"
      "createdAt": 1758428400000,
      "updatedAt": 1758428400000
    }
  }
}

Writes go through a temp file + fsync + rename, so a crash cannot leave a renamed-but-empty file behind — which would read back as "all keys deleted".

A version 1 file loads as-is: the grouping fields are optional, so keys written before platforms existed simply read back as ungrouped. No migration step.

usage.jsonl

Activity records live in a separate file, one JSON object per line:

{"t":1758428400000,"kind":"intent","names":["DSH_CF_WORK_TOKEN"],"note":"deploy staging"}
{"t":1758428450000,"kind":"use","names":["DSH_CF_WORK_TOKEN"]}
Field Meaning
t epoch milliseconds
kind intent (assistant-declared) or use (handed to a command)
names DSH_* names involved, deduplicated. Never a value.
note intent only — the purpose, trimmed to 500 characters

It is appended in batches and capped at the most recent 1000 entries, so it cannot grow without bound. Unlike keys.json it is not written atomically and not fsynced per line: a hard kill can truncate the final line, which readers skip. That is the correct trade here — this file is a signal, not a secret, and paying for durability would slow down every command.

Because the two kinds are written by different parties at different moments, they are stored as separate entries and correlated by timestamp when displayed. The plugin never joins them.

Security

Values are stored in plaintext. This is deliberate and explained in full in SECURITY.md, along with the trust model, the invariants, and the non-goals.

The short version:

  • Anything running as your OS user can read the store. Treat it like an .env.
  • The assistant can never read a key value into its transcript, in any mode.
  • The assistant can never change the access mode or the scope — those are operator-only.
  • In readonly, the model-facing tools do not exist rather than refusing — the capability is absent, not argued about.

Development

npm test            # both suites — 427 assertions
npm run test:host   # host half: policy, store, tools, gateway (298)
npm run test:client # client bundle: contract, slots, RPC, dictionaries (129)

The client suite loads lib/client.js the way the real frontend does — a fake window.__ModuleLoader__, seed-word-only require — and asserts the bundle registers without side effects, that only seed modules are required, and that every RPC endpoint the panel calls matches the gateway.

No build step. Plain ESM on the host, a CJS factory string in the browser.

lib/
  policy.js   access modes, scope glob, decision function
  store.js    persistence, provenance, atomic write
  tools.js    model-facing tools, confirmation ledger
  index.js    host half — Typert gateway, shellEnv registration
  client.js   browser bundle — settings panel

See CONTRIBUTING.md for the conventions this repo enforces and how to run the suites.

Compatibility

  • DSH Desktop 2.0.11+ (dsh 0.1.5-rc.1)
  • Node 20+
  • Client half: web platform only

License

Apache License 2.0 · attribution in NOTICE

Apache-2.0 was chosen over MIT/BSD for this project because it adds two clauses that matter for a tool handling credentials:

  • A patent grant (§3), which MIT and BSD lack entirely — and it obliges contributors to grant the same.
  • A trademark limitation (§6), so the authors' names cannot be used to endorse a fork. That is the same protection BSD-3-Clause's endorsement clause gives.

Everything else is permissive: use it commercially, modify it, ship it closed.

—/ 5

No ratings yet

Verified DSH bundle

Commit 36fc41d9e784

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout