Skip to content

feat(vault): manage links for read-through external vaults - #419

Merged
jahvon merged 5 commits into
mainfrom
feat/vault-links
Jul 27, 2026
Merged

feat(vault): manage links for read-through external vaults#419
jahvon merged 5 commits into
mainfrom
feat/vault-links

Conversation

@jahvon

@jahvon jahvon commented Jul 26, 2026

Copy link
Copy Markdown
Member

Draft: does not build yet. It needs flowexec/vault v0.4.0, which is not tagged. See Blocked on below.

Companion to flowexec/vault#21, which turns external vaults into read-through reference registries: they hold links — a key you choose, paired with a path the provider understands — rather than secret material. This is the flow side.

Commands

flow secret link NAME REFERENCE and flow secret unlink NAME.

flow secret link aws-access-key 'op://Team/AWS/access_key_id'
flow secret link db-password 'team/db/password'

set refuses on a linked vault and names link in the message. It checks before collecting a value — prompting someone to type a secret only to reject it, or taking one already typed, is a poor way to deliver that news.

remove still works and now says what it actually does. Both the confirmation prompt and the success message state that the secret itself is not deleted, which meant opening the vault before prompting rather than after: what removal means depends on the vault.

Listing no longer resolves every secret

NewSecretList called GetSecret per entry even when the values were about to be masked. On a linked vault that is one provider command per row — for 1Password, potentially one biometric prompt per row — to render a column of identical dots. It now takes a resolve flag and shows the reference instead, which is the part worth reading.

Verified by listing with GPG deliberately unreachable: it succeeds.

That same loop also swallowed GetSecret errors and skipped the entry, so a link whose target had been deleted in the provider silently vanished from the listing rather than showing as something to fix. Broken links now stay in the list and render as broken in the detail view.

Views

The secret detail view drops rename and edit for linked secrets — both wrote secret material — and offers re-link instead. Delete is labelled unlink. The list view shows Name → Reference.

The vault view reports that an external vault is read-only, how many links it holds, which preset produced it and with what values, and names any inert write-era commands left in an older config.

Storage path

NewExternalVault fills in a storage path for the link registry, matching where every other provider keeps its state. A config authored elsewhere — rendered from a preset, or written by hand — has no business knowing flow's cache layout, so it arrives without one.

Deliberately unchanged

SecretRef, RefToParts, ValidateIdentifier, ValidateSecretKey.

Because the key is a local alias and the path lives in the link, a key never needs to contain /. That matters: RefToParts rejects a 3-segment ref outright, and SecretRef("v/a/b/c").Key() silently returns "a". An earlier iteration of this work was heading toward loosening both. It turned out to be unnecessary.

Verification

go build ./... and the full go test ./internal/... ./cmd/... suite pass locally against the vault branch.

End to end against a real pass store: created an external vault, linked team/db/password (a nested path the old design could not express as a key), read it back, listed it, unlinked it, and confirmed the .gpg file and its value were untouched. A reference containing $(id) is refused.

Blocked on

  1. feat(external)!: make external vaults read-through reference registries vault#21 merged and tagged v0.4.0
  2. A follow-up commit bumping go.mod off the local replace this was developed against

go.mod is deliberately absent from these commits — gomoddirectives forbids committing the replace, and the dependency does not exist yet. The bump is the next commit, and this leaves draft when it lands.

🤖 Generated with Claude Code

jahvon and others added 3 commits July 26, 2026 19:28
External vaults hold links -- a key you choose, paired with a path the
provider understands -- rather than secret material. This adds the
commands and views for working with them.

`flow secret link NAME REFERENCE` and `flow secret unlink NAME`. `set`
refuses on a linked vault and names `link` in the message, and it checks
before collecting a value: prompting someone to type a secret only to
reject it, or taking one already typed, is a poor way to deliver that
news. `remove` still works and now says what it actually does -- the
prompt and the success message both say the secret itself is not deleted,
which meant opening the vault before prompting rather than after.

Listing a read-through vault no longer resolves every key. It used to call
GetSecret per entry even when the values were about to be masked, which on
a linked vault is one provider command per row -- for 1Password, one
biometric prompt per row. NewSecretList now takes a resolve flag and shows
the reference instead, which is the useful part anyway. Verified by
listing with GPG deliberately unreachable: it succeeds.

That listing also used to swallow GetSecret errors and skip the entry, so
a link whose target had been deleted in the provider silently vanished
rather than showing as something to fix. Broken links now stay in the list
and render as broken in the detail view.

The secret detail view drops rename and edit for linked secrets -- both
wrote secret material -- and offers re-link instead. Delete is labelled
unlink.

NewExternalVault fills in a storage path for the link registry, matching
where every other provider keeps its state. A config authored elsewhere
has no business knowing flow's cache layout.

go.mod is deliberately not in this commit: it needs vault v0.4.0, which is
not tagged yet, and the branch carries a local replace meanwhile. This
commit does not build on its own -- the dependency bump is the next one.

Verified end to end against a real pass store: created an external vault,
linked team/db/password (a nested path the old design could not express as
a key), read it back, listed it, unlinked it, and confirmed the .gpg file
and its value were untouched. A reference containing $(id) is refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A UI that wants to browse the provider again later needs the preset the
vault was created from and the values it was created with -- its region,
its 1Password vault name. Without them it has to ask the user to restate
things they already entered once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 29.35323% with 142 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/io/secret/linked.go 0.00% 74 Missing ⚠️
cmd/internal/secret.go 36.55% 53 Missing and 6 partials ⚠️
internal/vault/secret.go 75.86% 5 Missing and 2 partials ⚠️
internal/vault/vault.go 60.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

jahvon and others added 2 commits July 26, 2026 20:41
NewLinkedSecret decorated NewSecret's result by asserting back to the
concrete type, which errcheck flags as an unchecked assertion. It now
builds the struct directly and NewSecret delegates to it.

views.go no longer needs the cyclop suppression -- moving the
read-through views into linked.go took the branching with them.

The secrets guide still described external vaults as writable stores.
Rewritten around links: how references look per provider, that one
1Password item becomes several links, and that set/remove no longer do
what they used to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jahvon
jahvon marked this pull request as ready for review July 27, 2026 00:46
@jahvon
jahvon merged commit a80b14b into main Jul 27, 2026
21 checks passed
@jahvon
jahvon deleted the feat/vault-links branch July 27, 2026 00:48
jahvon added a commit that referenced this pull request Jul 27, 2026
Follow-up to #419. Two bugs that together made the desktop's vault
dropdown come up empty and disabled.

## An external vault created before v0.4.0 cannot be opened

Those configs have no `storage_path`, and `NewExternalVaultProvider` now
requires one. The default was applied when *creating* a vault but not
when *opening* one, so every pre-existing external vault broke on
upgrade

## One unopenable vault hid every other one

`PrintVaultList` called `Fatalf` on the first vault it could not open,
so a single bad vault produced no output at all rather than one bad row.
The TUI list view did the same via `NewErrorView`.

A vault that will not open is now listed with an `error` field, and the
rest still load. This matters beyond the bug above — a missing key env
var or a revoked age identity would have blanked the whole list the same
way.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant