@moruteaven/dsh-key-panel
管理密钥并以环境变量注入助手终端 适合需要安全存储敏感凭证并将其提供给AI执行部署等任务的用户。
安装
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:MoruTeaven/dsh-key-panel说明文档
阅读完整 README ↗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_intentis 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_UIflag inlib/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 |
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.