Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/design/agent-custom-secrets/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,48 @@ closed at the next run boundary.
Milestone one is implemented and validated in this PR. See [README](README.md) for the
shipped behavior and [qa.md](qa.md) for the validation record. Milestone two, the vault
policy for readable secrets, is not implemented, so this PR must not close #5703.

## Follow-up fixes, 2026-09-10

Two bugs came out of live use on the OSS team stack after the feature shipped in v0.115.4.
Both are frontend only. They land together on one branch because they touch the same
drawer and the same commit path.

### Slice A: the secret picker shows nothing (#6733)

The **Attach a secret** drawer opens at `zIndex` 1000 so it sits above the Advanced
dialog. The shared `SelectContent` portals to `body` at `z-50`, so the option list paints
under the drawer. The list is in the DOM with every secret in it, and nothing is visible.
The same applies to the two selects inside `SecretForm` when it renders in
`CreateSecretDrawer` at 1100.

Change: the drawer passes `zIndex + 1` down to every `SelectContent` it renders, and
`SecretForm` takes an optional `popupZIndex` for its own selects. The drawer also refetches
the vault list when it opens, because the vault query never refetches on its own once the
page holds a subscriber.

Acceptance: with two text secrets in the vault, open Advanced, Custom secrets, Attach,
click the Secret select. Both names are visible on screen in a screenshot, not only in the
accessibility tree. Create a secret in another tab, reopen the drawer, and the new name is
listed without a reload.

### Slice B: attach fails with a 409 and Retry can never succeed (#6734)

`commitAgentCredentialsAtom` sends `base_revision_id` equal to the revision the panel
displays. The server rejects any base that is not the head. When the panel shows an older
revision, every attach fails, and the drawer prints the raw wire error.

Change: the commit reads the variant head first and builds the bindings-only revision on
top of the head's data. The displayed revision supplies the variant id, the dirty check,
and the attachments the user was looking at. When the head's attachments differ from
those, the atom refuses with a plain message and the user reloads, because the callers
send the full list and a commit would undo the other change. This keeps the review rule
from step 4 of the request flow above. If the head moves between the read and the commit,
the atom re-reads once and retries under the same rule. Edits typed during the request are
carried to the adopted revision only when it was built on the displayed revision. The
lost-response recovery stays.

Acceptance: with the panel on an older revision and a newer head, attach succeeds and the
panel adopts the new head. The unit suite in
`web/packages/agenta-entities/tests/unit/agent-credentials-commit.test.ts` covers the head
anchor, the single retry, and the plain conflict message.
6 changes: 4 additions & 2 deletions docs/design/agent-custom-secrets/qa.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ printing secret values.
environment owners retain their values and policies.
- Missing, deleted, wrong-project, wrong-kind, empty, and unreadable secrets fail before
harness execution. API writes enforce the same permission rule as the editor.
- A moved `base_revision_id` produces a conflict without overwriting another edit. Retry
confirms the selected binding against the current revision.
- An attachment commits on the variant head, whatever revision the panel shows, and keeps
the head's other fields. If the head's attachments differ from the ones the panel showed,
or the head moves twice during the commit, the drawer asks for a reload instead of
overwriting them.

## Card completion and recovery

Expand Down
2 changes: 2 additions & 0 deletions docs/design/agent-custom-secrets/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Current phase

2026-09-10: follow-up fixes for #6733 (picker renders behind the drawer) and #6734 (attach anchored on a stale revision) are on PR #6743 (`fix/custom-secret-attach-drawer`, base `release/v0.116.0`). Both were verified live on an isolated EE dev stack built from the branch: the picker draws above the drawer on desktop and `/m`, an attach from a tab on an older revision lands on the head and keeps the head's edits, a head whose attachments changed refuses with a reload message, and the chat `request_secret` flow attaches, settles, and resumes. See the plan's follow-up section.

Implementation and independent review are complete. Runtime, SDK, runner, shared entity, shared UI, desktop, and mobile paths are present in the isolated feature worktree. The real-application request, resume, and targeted recovery checks passed. The remaining runtime matrix is listed below.

## Shipped decisions
Expand Down
75 changes: 60 additions & 15 deletions web/packages/agenta-entities/src/secret/state/atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ import type {QueryKey} from "@tanstack/react-query"
import {atom} from "jotai"
import {atomWithStorage} from "jotai/utils"
import {atomWithMutation, atomWithQuery} from "jotai-tanstack-query"
import {z} from "zod"

import {safeParseWithLogging} from "../../shared/utils/zodSchema"
import {createVaultSecret, deleteVaultSecret, fetchVaultSecret, updateVaultSecret} from "../api/api"
import {
getEnvNameMap,
Expand Down Expand Up @@ -349,9 +351,9 @@ export const deleteSecretAtom = atom(null, async (get, set, provider: LlmProvide
* migrated. The hook's `useEffect` is responsible for the user-presence
* trigger and the logout reset (re-arm).
*
* On success, sets `{migrating: false, migrated: true}`.
* On failure, rolls back to `{migrating: false, migrated: false}` so the
* next mount can retry.
* Always ends in `{migrating: false, migrated: true}`, on failure too: a legacy payload
* that cannot be parsed stays backed up in localStorage, and the vault UI must never wait
* on it. The server list is the source of truth.
*/
export const migrateVaultKeysAtom = atom(null, async (get, set) => {
const migrationStatus = get(vaultMigrationAtom)
Expand All @@ -366,24 +368,67 @@ export const migrateVaultKeysAtom = atom(null, async (get, set) => {
const localStorageProviders = localStorage.getItem(llmAvailableProvidersToken)

if (localStorageProviders) {
const _providers = JSON.parse(localStorageProviders)
const providers = JSON.parse(_providers)

for (const provider of providers) {
if (provider.key) {
await set(createStandardSecretAtom, provider as LlmProvider)
const providers = parseLegacyProviders(localStorageProviders)
const failed: LlmProvider[] = []
if (providers) {
for (const provider of providers) {
if (!provider.key) continue
try {
await set(createStandardSecretAtom, provider)
} catch (error) {
// One bad entry must not stop the others; it stays for the next load.
console.error("[vault] Legacy provider key was not migrated:", error)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
failed.push(provider)
}
}
} else {
console.error(
"[vault] Legacy provider keys could not be parsed; leaving them backed up.",
)
}

// Create backup and cleanup
// Keep a backup, then leave only the entries that still need a retry in place, in
// the canonical double-encoded form. A later page load picks them up again.
localStorage.setItem(`${llmAvailableProvidersToken}Backup`, localStorageProviders)
localStorage.removeItem(llmAvailableProvidersToken)
if (failed.length > 0) {
localStorage.setItem(
llmAvailableProvidersToken,
JSON.stringify(JSON.stringify(failed)),
)
} else {
localStorage.removeItem(llmAvailableProvidersToken)
}
}

set(vaultMigrationAtom, {migrating: false, migrated: true})
} catch (error) {
// A failed migration must not hold the vault UI in its loading state: the keys stay
// in localStorage for a later attempt, and the app keeps working with the server list.
console.error("Migration failed:", error)
set(vaultMigrationAtom, {migrating: false, migrated: false})
throw error
} finally {
set(vaultMigrationAtom, {migrating: false, migrated: true})
}
})

/** One legacy entry: whatever else it carried, only an object with a string key can migrate. */
const legacyProviderSchema = z.object({key: z.string().nullish()}).passthrough()

/**
* The legacy localStorage payload was double-encoded JSON (a JSON string holding JSON), but
* older builds wrote it once. Accept both; return null when the payload is not a list. Each
* entry is validated on its own, so one malformed entry does not block the valid ones.
*/
function parseLegacyProviders(raw: string): LlmProvider[] | null {
let parsed: unknown
try {
parsed = JSON.parse(raw)
if (typeof parsed === "string") parsed = JSON.parse(parsed)
} catch {
return null
}
if (!Array.isArray(parsed)) return null
const providers: LlmProvider[] = []
for (const entry of parsed) {
const valid = safeParseWithLogging(legacyProviderSchema, entry, "[vault] legacy provider")
if (valid) providers.push(valid as unknown as LlmProvider)
}
return providers
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,12 @@ export const useVaultSecret = () => {
vaultQuery.refetch()
}, [vaultQuery])

// "Not migrated yet" is not a loading state: the migration only moves legacy localStorage
// keys into the vault, and a failed or never-run migration must not hold every secret
// picker on "Loading" (#6733 follow-up). The server list is the source of truth.
const loading = useMemo(() => {
return vaultQuery.isPending || migrationStatus.migrating || !migrationStatus.migrated
}, [vaultQuery.isPending, migrationStatus.migrating, migrationStatus.migrated])
return vaultQuery.isPending || migrationStatus.migrating
}, [vaultQuery.isPending, migrationStatus.migrating])

return {
loading,
Expand Down
5 changes: 4 additions & 1 deletion web/packages/agenta-entities/src/workflow/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,4 +499,7 @@ export type {

export {agentRosterSearchAtom, matchesAgentQuery} from "./state/agentRoster"

export {commitAgentCredentialsAtom} from "./state/agentCredentials"
export {
commitAgentCredentialsAtom,
AGENT_CREDENTIALS_CONFLICT_MESSAGE,
} from "./state/agentCredentials"
Loading
Loading