MoruTeaven/dsh-key-panel ↗★ 0

@moruteaven/dsh-key-panel

Managed secret store for DSH Desktop. Operator-owned keys, injected into the assistant's shell as $DSH_* variables, with a settings-page panel and three operator-controlled access modes. 适合需要安全存储敏感凭证并将其提供给AI执行部署等任务的用户。

패키지
@moruteaven/dsh-key-panel
호환성
미검증
Harness peer 범위
>=0.1.5-rc.1
Cordis peer 범위
>=4.0.0
버전
1.0.2-dev.2
라이선스
Apache-2.0
최근 업데이트
2026. 9. 27.

설치

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:MoruTeaven/dsh-key-panel

Usage

Add a key

Settings → Keys → Add key.

FieldNotes
NameMust match DSH_[A-Z][A-Z0-9_]* — e.g. DSH_CLOUDFLARE_TOKEN
PurposeOptional. Shown to the assistant as the variable's description.
ValueThe 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 enterYou get
platform CF, account WORKDSH_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:

RecordWritten byContains
declaredthe assistant, calling key_panel_intenta timestamp it says a purpose, and the names it means to use
handed to commandthe plugin itself, every time a shell command is resolveda 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

ModeAssistant canAssistant cannot
readonly (default)use keyschange anything — no model-facing tool is registered at all
writeadd keys; replace keys it createddelete anything; edit your keys
editadd, 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:

ValueEffect
(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

SettingWhereDefault
Access modePanelreadonly
Name scopeStore 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"]}
FieldMeaning
tepoch milliseconds
kindintent (assistant-declared) or use (handed to a command)
namesDSH_* names involved, deduplicated. Never a value.
noteintent 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.