Initial research, requested in review of #5670. This is a findings-and-requirements write-up, not a design. Nothing here is decided.
What exists today
Storage is complete. The vault has a custom_secret kind (api/oss/src/core/secrets/enums.py:9). Its entire schema is:
CustomSecretSettingsDTO: format: "text" | "json"; content: str | Dict[str, primitive]
plus, on the wrapper, slug (URL-safe, unique per project, immutable after create) and header.name / header.description. That is all of it. There is no destination field, no host, no URL, and no environment-variable-name field. format changes validation only: text stores a string verbatim, json requires a flat map of primitives.
The UI collects four things (ConfigureSecretModal/index.tsx): Name, Slug, Format, Content. Name is a display label whose placeholder is e.g. GITHUB_TOKEN, which reads exactly like an environment variable name but is not used as one anywhere.
There is one live consumer: MCP headers. A user picks a vault secret by slug and types a header name, and the config stores {header name -> secret slug} (McpServerFormView.tsx:76-84). The SDK resolves the slug to a value and emits a typed credential (mcp/resolver.py:72-80).
Nothing reaches a skill or a bash command. A skill rides the wire as {name, description, body, files} with no environment of its own (skills/models.py:77-117), and its bash inherits only the daemon environment: PATH, HOME, the harness config directories, the Pi extension variables, and the run's model provider key. There is no path from the vault to that environment.
There is also a dead path worth knowing about: CodeToolConfig.secrets: List[str] resolved a list of names into {name: value} environment variables, where the same string was both the vault slug and the environment variable name. The runner refuses every run carrying a code tool (CODE_TOOL_UNSUPPORTED_MESSAGE), so it never executes. That conflation is a mistake worth not repeating.
The host question
@MahmoudMabrouk is right that custom secrets are not bound to hosts, and the reason it matters is sharper than it first looks.
Today the destination never comes from the secret. It comes from the consumer. An MCP credential's allowed host is the MCP server's own URL. A model credential's allowed host is modelConnection.endpoint.baseUrl. The vault secret contributes a value and nothing else. That works because both consumers are things that, by definition, have an address.
A skill's bash has no address. It is an arbitrary program. There is no consumer field to read a host from, so if a custom secret is to be hideable, the host has to come from the secret itself. That is the whole reason this becomes a new requirement rather than a reuse of the existing one.
The corollary is worth stating plainly, because it frames the product decision: custom secrets do not need a host in order to work. They need a host in order to be hidden. We could ship them today with no host, delivered as plain environment variables, and every skill would work. What we would be shipping is the exact property #5670 exists to remove: an agent that can read its own credentials. So the host is the price of the security guarantee, not a technical prerequisite.
Requirements
R1. The destination belongs on the secret, not on the attachment.
An agent author must not be able to widen the boundary. If the host lives on the agent config, whoever writes that config chooses where the key may be sent, which defeats the point. Putting it on the vault entry means the person who owns the credential sets its boundary once, and every agent that uses it inherits that.
R2. The environment variable name belongs on the attachment, not on the secret.
Two agents may legitimately want the same credential under different names, and the dead code-tool path shows what happens when the two are conflated: the slug becomes load-bearing as an environment variable name and can never be renamed. Note that header.name today already looks like it plays this role and does not, which is a trap for the next reader.
R3. Allow a set of hosts, each exact.
A GITHUB_TOKEN legitimately goes to api.github.com and possibly github.com. Daytona's Secret API already takes a host list; our plan currently asserts exactly one (daytona-secrets.ts assertCreatedSecret). That assertion needs to relax to "one or more, each an exact DNS name, no wildcards". The no-wildcard rule must not relax.
R4. A secret with no host must remain expressible, and must look unprotected.
Some credentials genuinely cannot be hidden: ones used to sign locally, ones written to a file the agent reads, ones whose destination is chosen at runtime. Those must still be usable, marked local_use on the wire, and the UI must say plainly that the sandbox can read them. Making the unprotected case impossible would just push people to paste keys into prompts.
R5. Decide what format: json means before wiring it, or exclude it.
A JSON secret expands into several environment variables. Does one host cover all of them, or does each key get its own? There is no obviously correct answer, and format: json has zero consumers anywhere in the codebase today. Recommend supporting text only in the first slice.
R6. The runner needs a third consumer kind, and that is a small change.
DaytonaSecretCandidate.consumer is a closed union of {kind: "model"} and {kind: "http_mcp", server}, with binding.kind of environment or header and a mandatory allowedHost. A custom secret is {kind: "custom", slug} with an environment binding. The abstraction already fits; only the union is closed.
R7. Rotating a custom secret must not rewind the conversation. Same problem as #5701; solving it there covers this.
Open question for product
R1 means a user cannot save a hideable custom secret without naming the hosts it may reach. That is a real ask, and people will not always know the answer. The alternatives are to make the host optional and default to unprotected, which most people will accept silently, or to require it and add friction to every secret.
I lean toward requiring it for a secret marked hideable and allowing an explicit "the agent can read this" choice alongside, so the friction lands only on people who want the protection. But this is a product call, not an engineering one.
Incidental findings
Found while tracing this. Each is separable from the work above.
- A JSON custom secret can be selected for an MCP header and always fails the run. The resolver accepts
format: "text" only (platform/secrets.py:75-84), but the picker filters on slug presence alone (McpServerFormView.tsx:191-196). The config saves and the run dies with MissingMCPSecretError. Either filter the picker or support json.
GET /secrets/ returns every secret's decrypted value and caches the response (router.py:113-135, dbs/postgres/secrets/mappings.py:64). The column is encrypted at rest (PGPString), so this is an API-surface choice rather than a storage flaw, and the edit UI arguably needs it. It deserves a deliberate decision rather than being inherited, especially if custom secrets get used more widely.
get_by_slug does not filter by kind, and slug is settable on any kind, so a provider key given a slug is readable through the named-secret path.
- The design doc is stale.
docs/design/vault-named-secrets/context.md:41-43 says "do not inject these secrets into the agent runtime, sandbox, or any invocation", which the MCP header path already does.
McpServerFormView has a state bug (:52-56): the header and secret fields initialize from value once and never resync, so they do not repopulate when the drawer's value changes without a remount.
header.description exists in the DTO and the database and the UI never collects it.
Related
Initial research, requested in review of #5670. This is a findings-and-requirements write-up, not a design. Nothing here is decided.
What exists today
Storage is complete. The vault has a
custom_secretkind (api/oss/src/core/secrets/enums.py:9). Its entire schema is:plus, on the wrapper,
slug(URL-safe, unique per project, immutable after create) andheader.name/header.description. That is all of it. There is no destination field, no host, no URL, and no environment-variable-name field.formatchanges validation only:textstores a string verbatim,jsonrequires a flat map of primitives.The UI collects four things (
ConfigureSecretModal/index.tsx): Name, Slug, Format, Content. Name is a display label whose placeholder ise.g. GITHUB_TOKEN, which reads exactly like an environment variable name but is not used as one anywhere.There is one live consumer: MCP headers. A user picks a vault secret by slug and types a header name, and the config stores
{header name -> secret slug}(McpServerFormView.tsx:76-84). The SDK resolves the slug to a value and emits a typed credential (mcp/resolver.py:72-80).Nothing reaches a skill or a bash command. A skill rides the wire as
{name, description, body, files}with no environment of its own (skills/models.py:77-117), and its bash inherits only the daemon environment:PATH,HOME, the harness config directories, the Pi extension variables, and the run's model provider key. There is no path from the vault to that environment.There is also a dead path worth knowing about:
CodeToolConfig.secrets: List[str]resolved a list of names into{name: value}environment variables, where the same string was both the vault slug and the environment variable name. The runner refuses every run carrying a code tool (CODE_TOOL_UNSUPPORTED_MESSAGE), so it never executes. That conflation is a mistake worth not repeating.The host question
@MahmoudMabrouk is right that custom secrets are not bound to hosts, and the reason it matters is sharper than it first looks.
Today the destination never comes from the secret. It comes from the consumer. An MCP credential's allowed host is the MCP server's own URL. A model credential's allowed host is
modelConnection.endpoint.baseUrl. The vault secret contributes a value and nothing else. That works because both consumers are things that, by definition, have an address.A skill's bash has no address. It is an arbitrary program. There is no consumer field to read a host from, so if a custom secret is to be hideable, the host has to come from the secret itself. That is the whole reason this becomes a new requirement rather than a reuse of the existing one.
The corollary is worth stating plainly, because it frames the product decision: custom secrets do not need a host in order to work. They need a host in order to be hidden. We could ship them today with no host, delivered as plain environment variables, and every skill would work. What we would be shipping is the exact property #5670 exists to remove: an agent that can read its own credentials. So the host is the price of the security guarantee, not a technical prerequisite.
Requirements
R1. The destination belongs on the secret, not on the attachment.
An agent author must not be able to widen the boundary. If the host lives on the agent config, whoever writes that config chooses where the key may be sent, which defeats the point. Putting it on the vault entry means the person who owns the credential sets its boundary once, and every agent that uses it inherits that.
R2. The environment variable name belongs on the attachment, not on the secret.
Two agents may legitimately want the same credential under different names, and the dead code-tool path shows what happens when the two are conflated: the slug becomes load-bearing as an environment variable name and can never be renamed. Note that
header.nametoday already looks like it plays this role and does not, which is a trap for the next reader.R3. Allow a set of hosts, each exact.
A
GITHUB_TOKENlegitimately goes toapi.github.comand possiblygithub.com. Daytona's Secret API already takes a host list; our plan currently asserts exactly one (daytona-secrets.tsassertCreatedSecret). That assertion needs to relax to "one or more, each an exact DNS name, no wildcards". The no-wildcard rule must not relax.R4. A secret with no host must remain expressible, and must look unprotected.
Some credentials genuinely cannot be hidden: ones used to sign locally, ones written to a file the agent reads, ones whose destination is chosen at runtime. Those must still be usable, marked
local_useon the wire, and the UI must say plainly that the sandbox can read them. Making the unprotected case impossible would just push people to paste keys into prompts.R5. Decide what
format: jsonmeans before wiring it, or exclude it.A JSON secret expands into several environment variables. Does one host cover all of them, or does each key get its own? There is no obviously correct answer, and
format: jsonhas zero consumers anywhere in the codebase today. Recommend supportingtextonly in the first slice.R6. The runner needs a third consumer kind, and that is a small change.
DaytonaSecretCandidate.consumeris a closed union of{kind: "model"}and{kind: "http_mcp", server}, withbinding.kindofenvironmentorheaderand a mandatoryallowedHost. A custom secret is{kind: "custom", slug}with anenvironmentbinding. The abstraction already fits; only the union is closed.R7. Rotating a custom secret must not rewind the conversation. Same problem as #5701; solving it there covers this.
Open question for product
R1 means a user cannot save a hideable custom secret without naming the hosts it may reach. That is a real ask, and people will not always know the answer. The alternatives are to make the host optional and default to unprotected, which most people will accept silently, or to require it and add friction to every secret.
I lean toward requiring it for a secret marked hideable and allowing an explicit "the agent can read this" choice alongside, so the friction lands only on people who want the protection. But this is a product call, not an engineering one.
Incidental findings
Found while tracing this. Each is separable from the work above.
format: "text"only (platform/secrets.py:75-84), but the picker filters on slug presence alone (McpServerFormView.tsx:191-196). The config saves and the run dies withMissingMCPSecretError. Either filter the picker or supportjson.GET /secrets/returns every secret's decrypted value and caches the response (router.py:113-135,dbs/postgres/secrets/mappings.py:64). The column is encrypted at rest (PGPString), so this is an API-surface choice rather than a storage flaw, and the edit UI arguably needs it. It deserves a deliberate decision rather than being inherited, especially if custom secrets get used more widely.get_by_slugdoes not filter by kind, andslugis settable on any kind, so a provider key given a slug is readable through the named-secret path.docs/design/vault-named-secrets/context.md:41-43says "do not inject these secrets into the agent runtime, sandbox, or any invocation", which the MCP header path already does.McpServerFormViewhas a state bug (:52-56): the header and secret fields initialize fromvalueonce and never resync, so they do not repopulate when the drawer's value changes without a remount.header.descriptionexists in the DTO and the database and the UI never collects it.Related
docs/design/agent-workflows/scratch/open-issues.mdhas tracked the consumption path as open since 2026-06-19.