Skip to content

feat(secrets): managed secrets stored locally and injected into workspaces#647

Draft
skevetter wants to merge 21 commits into
mainfrom
feat/managed-secrets
Draft

feat(secrets): managed secrets stored locally and injected into workspaces#647
skevetter wants to merge 21 commits into
mainfrom
feat/managed-secrets

Conversation

@skevetter

Copy link
Copy Markdown
Contributor

Summary

Adds a persistent, managed secrets feature. Previously secrets were ephemeral (a --secrets-file parsed at up time); users can now store named secrets once and reference them by name.

  • Storage (pkg/secrets): a Store behind a small backend interface with two implementations — the OS keyring (zalando/go-keyring) and an age-encrypted file fallback (filippo.io/age) for headless/CI. Only non-sensitive metadata (names, timestamps) is written to ~/.devsy; values live in the backend, which is the source of truth. A YAML index enables listing since keyrings can't enumerate.
  • Backend selection: SECRETS_BACKEND context option (auto/keyring/file), overridable per-invocation via DEVSY_SECRETS_BACKEND.
  • CLI: devsy secrets set/list/get/delete/attach/detach. Values are read via stdin/hidden prompt to keep them off argv.
  • Injection: devsy up --secret NAME[=ENV_VAR] (repeatable) plus per-context binding via attach. Resolves into the existing lifecycle-env path. A missing requested/bound secret is a hard error, not a silent drop.
  • Docs: new Secrets page.
  • Desktop: IPC + store + SecretsPage + nav entry.

Secrets are per-context; deleting a context cascades to its secrets.

Notes / follow-ups

  • The file backend requires DEVSY_SECRETS_PASSPHRASE; without a passphrase and without a usable keyring, operations fail with a clear error rather than proceeding insecurely.
  • Interactive TTY passphrase prompt for the file backend is not yet implemented (env-var only today).
  • Platform/pro server-side secret sync is out of scope for this PR.

New dependencies (zalando/go-keyring, filippo.io/age) are cgo-free and cross-compile clean for all release targets.

@netlify

netlify Bot commented Jul 10, 2026

Copy link
Copy Markdown

Deploy Preview for images-devsy-sh canceled.

Name Link
🔨 Latest commit e16160a
🔍 Latest deploy log https://app.netlify.com/projects/images-devsy-sh/deploys/6a63bc6ae59d09000746e860

@netlify

netlify Bot commented Jul 10, 2026

Copy link
Copy Markdown

Deploy Preview for devsydev canceled.

Name Link
🔨 Latest commit e16160a
🔍 Latest deploy log https://app.netlify.com/projects/devsydev/deploys/6a63bc6ac749b00008c9c1a9

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dd1f0075-e1dc-4a31-9732-3b505067209b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Add a devsy secret command group (set/get/list/delete/attach/detach) that
stores named secrets locally in the OS keyring, with an age-encrypted file
fallback whose key is auto-generated (or passphrase-derived) and recorded in a
metadata index. Secrets are per-context and can be bound to a context.

Inject stored secrets into workspaces with 'up --secret NAME[,type=env|mount]
[,target=X]': env delivery sets a lifecycle env var; mount delivery writes the
value to a tmpfs at /run/secrets. Secret values travel to the container over
the agent tunnel (not argv) and are redacted from lifecycle logs. A driver
mount capability lets the Kubernetes driver map tmpfs to an in-memory emptyDir
and rejects unsupported providers with a clear error.

--secrets-file now expects flat JSON to match the devcontainer CLI. Includes
CLI, desktop UI, docs, and e2e coverage.
@skevetter
skevetter force-pushed the feat/managed-secrets branch from 54327d3 to 0758ded Compare July 24, 2026 04:26
skevetter added 20 commits July 23, 2026 23:37
Wire --build-secret values into image builds as BuildKit secrets so Dockerfiles
consume them via RUN --mount=type=secret,id=NAME. The internal BuildKit builder
serves them through a session secret provider; the docker buildx strategy passes
them as env-backed --secret references (never argv or layers). Docker compose
builds, which cannot mount build secrets, reject them with a clear error.
Wire the resolved --git-token into the credential path: the tunnel GitCredentials
RPC returns the token only for its own host (never leaked to other hosts git
contacts), served at the up and setup clone sites. Foundation resolves the token
secret and infers the username from the repo host.
Add a per-entry Sensitive flag to the managed-value store: sensitive values go
to the keyring/encrypted-file backend as before; non-sensitive values are stored
inline in the index (no keyring, no encryption). Add a 'devsy env' facade
(set/get/list/delete) over the same store for non-sensitive vars, and an
'up --env NAME[=TARGET]' flag to inject them. 'devsy secret' and 'devsy env' are
thin facades differing only in sensitivity.
UpCmd.BuildSecrets shadowed the embedded CLIOptions.BuildSecrets, so resolved
build secrets were written to the flag field and never reached CLIOptions
(BuildKit saw none). Rename the flag field to BuildSecretNames and write
resolved values to CLIOptions. Also split resolveStoredSecrets into per-type
appliers (cyclop) and add RunWithEnv for buildx build secrets.
- Strip GitToken and BuildSecrets from the CLIOptions embedded in the container
  setup command argv (they were recoverable via ps/proc; now delivered only over
  the tunnel) [security]
- Enforce the sensitive/non-sensitive boundary on reads: secret get/list/delete
  operate on secrets only, env get/delete on env vars only [security]
- Guard the index so a sensitive value can never be serialized inline
- Clear the stale backend value when an entry changes from sensitive to
  non-sensitive
- Only verify the key source when a sensitive entry is actually accessed
- Add Store.Meta for kind-aware facade checks
The CLI command group was renamed secrets->secret with no alias; the desktop
IPC handlers still shelled to 'secrets', breaking every Secrets-page action.
Apply idiomatic-Go and desktop-UI review findings:
- Replace opaque sensitive bool in Store API with a typed Kind enum
  (KindSecret/KindEnv), matching the repo's Backend/DockerBuilder pattern
- Drop hand-rolled splitKeyValue for strings.Cut
- Register secret/env value flags via pkg/flags builder + names constants
- Restructure cmd/env into one-file-per-subcommand with exported *Cmd types
- Add desktop env-var UI: EnvVar type, env_* IPC handlers, invokers, store,
  EnvPage, /env route, sidebar nav (plaintext value UX with reveal toggle)
- Fix stale 'devsy secrets list' comment in ipc.ts
…tore)

- Drop secret_attach/secret_detach IPC handlers and secretAttach/secretDetach
  invokers: never referenced by the renderer. CLI attach/detach stays; it is a
  live feature (bound context.Secrets are read at up via collectSecretRequests).
- Remove secrets.NewStore(): zero callers, all paths use NewStoreForConfig.
- HIGH: redact build-secret values in debug log (build.go dumped BuildOptions
  with %+v, exposing NAME=VALUE plaintext despite out-of-band buildx delivery)
- Harden auto key generation against a first-init TOCTOU: write the key file
  exclusively (O_EXCL, first-wins) and re-read after save so concurrent
  initializers converge on one persisted key instead of undecryptable splits
- Fail --git-token when the source has no HTTP(S) host rather than building an
  unscoped token with Host=""
- Align cmd/secrets Run methods with cmd/env: unused ctx -> _ context.Context
- HIGH: reject a sensitive secret passed via --env. applyEnvVars routed the
  value into WorkspaceEnv, which is NOT stripped from the compressed (gzip+
  base64, reversible) setup argv, exposing it in the host process list. Now
  errors and directs to --secret (tunnel-delivered).
- HIGH: path-traversal in writeSecretFiles. A mount target like
  target=../../etc/x escaped /run/secrets via filepath.Join; add secretMountPath
  containment guard.
- HIGH: unset Kind silently downgraded a secret to a plaintext env var on load
  (empty kind => Sensitive()==false => Get returns ""). Normalize unknown Kind
  to KindSecret (fail-safe).
- MEDIUM: index/backend writes were non-atomic and unlocked. Add atomicWriteFile
  (temp+rename) so a crash cannot corrupt the store, and a cross-process flock
  around store set/delete/last-used so concurrent writes cannot lose entries.
- MEDIUM: key-gen convergence did not hold for the keyring backend; serialize
  auto-key resolution with a cross-process lock (covers both backends).
- LOW: mount-secret values were absent from the lifecycle-hook log redactor;
  add them for redaction only (never injected into hook env).

Adds tests for each. 1313 tests pass; golangci-lint clean.
- MEDIUM: --git-token was silently dropped on the SSH machine-provider up path
  (agent.go devsyUpMachineSSH omitted WithGitToken while all other up paths wire
  it). Add tunnelserver.WithGitToken so the token is delivered there too.
- MEDIUM: harden mount-secret writes against symlink redirection. secretMountPath
  now requires a plain filename (no separators/./..), and writeSecretFile does
  Remove-then-O_EXCL-create so a pre-planted symlink under /run/secrets cannot
  redirect the write+chown to an arbitrary file. Also fixes the nested-target
  wart (nested targets are now rejected at the guard, not silently at write).
- LOW: redact mount-secret values in deferred/post-attach hook logs. Those
  re-exec'd processes lack the mount values in env; MountSecretsForRedaction
  reads them back from /run/secrets for the log redactor only (not injected).
- LOW: normalizeKinds now clears any inline Value when flipping an unset Kind to
  secret, upholding the no-inline-plaintext invariant for hand-edited entries.
- INFO: document that localStore.lock() is non-reentrant.

Adds tests. Touched packages: 1082 tests pass; golangci-lint clean.
Remove comments that restated the code and tighten the rest to non-obvious
rationale only, per the self-documenting-code convention. Keeps security
justifications (#nosec, argv/redaction invariants, lock non-reentrancy) and
exported-symbol docs.
- MEDIUM: multiline secret values (PEM keys, certs) were truncated at the first
  newline when passed to deferred/post-attach hooks via DEVSY_SECRETS_ENV
  (newline-joined). Base64-encode each entry symmetrically so any bytes survive;
  also stopped the dropped tail from escaping the log redactor.
- LOW: reject two mount secrets sharing a target= at parse time with a clear
  message instead of a confusing O_EXCL 'file exists' error mid-setup. Also
  makes collectSecretRequests output deterministic (sorted).
- Idiom: replace the confusing applier closure-slice in resolveStoredSecrets
  with a small secretResolver value type; append(append(...)) -> slices.Concat;
  extract the duplicated flock logic into a shared acquireFlock helper.

Adds tests. 1428 tests pass; golangci-lint clean.
runRawStdin passed { input } to Node's promisified execFile, but that option is
ignored by the async execFile (only execFileSync honors it). The CLI's
'secret set --stdin' then blocked forever on io.ReadAll(os.Stdin) with no value
and no EOF, so the desktop 'Add Secret' spinner never finished; because the call
holds a concurrency slot, retries hung too. Spawn the child and write+close
stdin explicitly instead.
Mirrors the existing --secret injection e2e for feature parity: env set a
var, devsy up --env NAME, assert it lands in the workspace login-shell
environment. Adds a storeEnv helper alongside storeSecret.
Regression coverage for the desktop 'Saving...' hang:
- cmd/secrets: resolveValue reads --stdin (incl. multiline PEM preservation
  and the trailing-newline trim), --value, and rejects multiple sources.
- desktop cli.test.ts: runRawStdin writes the value to the child's stdin and
  resolves; rejects with stderr on non-zero exit. Guards against the
  execFile { input } no-op that left the CLI blocked reading stdin.
The Add dialog silently upserted, so re-using a name replaced a value with no
signal — irreversible for secrets (write-only, no version history). Detect the
name collision against the loaded list and make the overwrite explicit:
- Secrets: title -> 'Replace Secret', amber 'will replace, cannot be undone'
  warning, destructive-styled 'Replace' button.
- Env vars (lower stakes, values visible): title -> 'Update', muted
  'will update its value' note, 'Update' button.
Store semantics unchanged (upsert stays; rotation via CLI still works).
…rumbs

The Cmd+K palette had no Secrets or Env Vars entries, and the breadcrumb nav
had no labels for /env or /ssh-keys. Add:
- Palette Navigation entries for Secrets and Env Vars pages.
- Palette dynamic entries for each stored secret/env var (searchable by name,
  navigates to the page), mirroring workspaces/providers/machines.
- Breadcrumb labels for secrets, env, and ssh-keys.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant