Guarded delete with InUseError
apps/backend/internal/secrets/delete.go ↗The service checks references before delete and returns 409 with the blocker list unless force is true.
Reference type and guard
type Reference struct {
Kind string `json:"kind"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Key string `json:"key,omitempty"`
}
type InUseError struct {
References []Reference `json:"references"`
}
func (e *InUseError) Error() string {
labels := make([]string, 0, len(e.References))
for _, ref := range e.References {
label := strings.ReplaceAll(ref.Kind, "_", " ")
if ref.Name != "" {
label += fmt.Sprintf(" %q", ref.Name)
}
if ref.Key != "" {
label += fmt.Sprintf(" (%s)", ref.Key)
}
labels = append(labels, label)
}
return "secret is in use by " + strings.Join(labels, ", ") + ". Remove or replace these references before deleting it."
}
func (s *Service) deleteChecked(ctx context.Context, id, workspaceID string, force bool) error {
if force {
return s.deleteStored(ctx, id, workspaceID)
}
refs, err := s.listReferences(ctx, id)
if err != nil {
return err
}
if len(refs) > 0 {
return &InUseError{References: refs}
}
return s.deleteStored(ctx, id, workspaceID)
}
func (s *Service) listReferences(ctx context.Context, id string) ([]Reference, error) {
if s.referenceChecker == nil {
return nil, fmt.Errorf("secret reference checking is unavailable")
}
refs, err := s.referenceChecker(ctx, id)
if err != nil {
return nil, fmt.Errorf("check secret references: %w", err)
}
return refs, nil
}
Cross-store reference discovery
apps/backend/internal/backendapp/secret_references.go ↗The checker scans agent profiles, executor profiles, and repositories and redacts details for workspaces the caller cannot access.
Discovery and redaction
func (c secretReferenceChecker) list(ctx context.Context, id string) ([]Reference, error) {
refs, err := c.agentReferences(ctx, id)
if err != nil {
return nil, err
}
profiles, err := c.tasks.ListAllExecutorProfiles(ctx)
if err != nil {
return nil, fmt.Errorf("list executor profiles: %w", err)
}
for _, profile := range profiles {
refs = appendEnvironmentReferences(refs, id, "executor_profile", profile.ID, profile.Name, profile.EnvVars)
}
repositories, err := c.repositoryReferences(ctx, id)
if err != nil {
return nil, err
}
return append(refs, repositories...), nil
}
func (c secretReferenceChecker) agentProfileReferenceRedacted(ctx context.Context, workspaceID string, workspaceAccess map[string]error) (bool, error) {
if workspaceID == "" {
return false, nil
}
if c.authorizeWorkspace == nil {
return false, errors.New("workspace reference authorization is unavailable")
}
accessErr, cached := workspaceAccess[workspaceID]
if !cached {
accessErr = c.authorizeWorkspace(ctx, workspaceID)
workspaceAccess[workspaceID] = accessErr
}
if accessErr != nil && !errors.Is(accessErr, repoerrors.ErrWorkspaceNotFound) {
return false, accessErr
}
return accessErr != nil, nil
}
func (c secretReferenceChecker) repositoryReferences(ctx context.Context, id string) ([]Reference, error) {
workspaces, err := c.tasks.ListWorkspaces(ctx)
if err != nil {
return nil, fmt.Errorf("list workspaces: %w", err)
}
var refs []Reference
for _, workspace := range workspaces {
accessErr := c.authorizeWorkspace(ctx, workspace.ID)
if accessErr != nil && !errors.Is(accessErr, repoerrors.ErrWorkspaceNotFound) {
return nil, accessErr
}
repositories, err := c.tasks.ListRepositories(ctx, workspace.ID)
if err != nil {
return nil, fmt.Errorf("list repository references: %w", err)
}
for _, repository := range repositories {
for _, binding := range repository.SecretBindings {
if binding.SecretID != id {
continue
}
ref := secrets.Reference{Kind: "repository"}
if accessErr == nil {
ref.ID, ref.Name, ref.Key = repository.ID, repository.Name, binding.Key
}
refs = append(refs, ref)
}
}
}
return refs, nil
}
HTTP and WS conflict handling
apps/backend/internal/secrets/handlers.go ↗The handlers map InUseError to 409 with code secret_in_use and expose a read-only references endpoint.
Handlers
func (h *Handler) httpListSecretReferences(c *gin.Context) {
id := c.Param("id")
var refs []Reference
var err error
if workspaceID := c.Query("workspace_id"); workspaceID != "" {
refs, err = h.service.WorkspaceSecretReferences(c.Request.Context(), id, workspaceID)
} else {
refs, err = h.service.References(c.Request.Context(), id)
}
if err != nil {
if errors.Is(err, ErrNotFound) || errors.Is(err, ErrWorkspaceAccessDenied) {
c.JSON(http.StatusNotFound, gin.H{"error": "secret not found"})
return
}
h.logger.Error("failed to list secret references", zap.String("id", id), zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list secret references"})
return
}
if refs == nil {
refs = []Reference{}
}
c.JSON(http.StatusOK, gin.H{"references": refs})
}
func (h *Handler) httpDeleteSecret(c *gin.Context) {
id := c.Param("id")
if err := h.deleteSecret(c, id); err != nil {
status, _, message, details := classifyDeleteError(err)
if status == http.StatusInternalServerError {
h.logger.Error("failed to delete secret", zap.String("id", id), zap.Error(err))
}
details["error"] = message
c.JSON(status, details)
return
}
c.Status(http.StatusNoContent)
}
func classifyDeleteError(err error) (int, string, string, map[string]any) {
var inUse *InUseError
if errors.As(err, &inUse) {
return http.StatusConflict, ws.ErrorCodeConflict, inUse.Error(), map[string]any{
"code": "secret_in_use", "references": inUse.References,
}
}
if errors.Is(err, ErrNotFound) || errors.Is(err, ErrWorkspaceAccessDenied) {
return http.StatusNotFound, ws.ErrorCodeNotFound, "secret not found", map[string]any{}
}
return http.StatusInternalServerError, ws.ErrorCodeInternalError, "failed to delete secret", map[string]any{}
}
Preflight and conflict dialog in UI
apps/web/components/settings/secrets-settings.tsx ↗The UI lists references before delete and shows a dialog with each blocker when the server returns 409.
Preflight hook
function useSecretDeleteActions(state, requestOptions, deleteRequest) {
const { t } = useTranslation();
const { toast } = useToast();
const requestGeneration = useRef(0);
const { deleteTarget, deleteReferences, setDeleteTarget, setDeleteReferences } = state;
const closeDelete = () => {
requestGeneration.current += 1;
setDeleteTarget(null);
setDeleteReferences(null);
};
const openDelete = async (secret: SecretListItem) => {
const generation = requestGeneration.current + 1;
requestGeneration.current = generation;
setDeleteTarget(secret);
setDeleteReferences(null);
try {
const references = await listSecretReferences(secret.id, requestOptions);
if (requestGeneration.current !== generation) return;
setDeleteReferences(references);
} catch {
if (requestGeneration.current !== generation) return;
setDeleteTarget(null);
setDeleteReferences(null);
toast({ description: t("settings:secretReferencesLoadFailed"), variant: "error" });
}
};
const confirmDelete = () => {
if (!deleteTarget || deleteReferences === null || deleteReferences.length > 0) return;
const target = deleteTarget;
setDeleteTarget(null);
setDeleteReferences(null);
return deleteRequest.run(target.id).catch((error: unknown) => {
const references = secretReferencesFromError(error);
if (references !== null) {
setDeleteTarget(target);
setDeleteReferences(references);
return;
}
toast({ description: secretDeleteErrorMessage(error, t), variant: "error" });
});
};
return { referencesLoading: deleteTarget !== null && deleteReferences === null, openDelete, closeDelete, confirmDelete };
}
Error parsing
export function secretReferencesFromError(error: unknown): SecretReference[] | null {
if (!(error instanceof ApiError) || error.status !== 409) return null;
const body = error.body;
if (!body || typeof body !== "object" || !("code" in body) || body.code !== "secret_in_use") {
return null;
}
if (!("references" in body) || !Array.isArray(body.references)) return null;
return body.references.filter(isSecretReference);
}
export function secretReferenceLabel(ref: SecretReference, t: TFunction): string {
const name = ref.name ?? "";
const key = ref.key ?? "";
if (!name && !key) return t("settings:secretReferenceHidden");
switch (ref.kind) {
case "agent_profile":
return t("settings:secretReferenceAgent", { name, key });
case "executor_profile":
return t("settings:secretReferenceExecutor", { name, key });
case "repository":
return t("settings:secretReferenceRepository", { name, key });
}
}
Repair hint on missing secret
apps/backend/internal/agent/runtime/environment/environment.go ↗Resolve now tells the user which key and origin failed and where to re-select the secret, without leaking the secret value.
Error message
return fmt.Sprintf("environment key %q from %s could not be resolved", e.Key, e.Origin)
return fmt.Sprintf("environment key %q from %s references an unavailable secret. Re-select the secret in the %s environment settings", e.Key, e.Origin, e.Origin)
}
Agent profile prefix
resolved, records, err := runtimeenv.Resolve(ctx, definitions, m.resolveEnvironmentDefinition)
if err != nil {
var secretErr *runtimeenv.SecretError
if errors.As(err, &secretErr) && secretErr.Origin == runtimeenv.OriginAgentProfile && profileInfo != nil && profileInfo.ProfileName != "" {
err = fmt.Errorf("agent profile %q: %w", profileInfo.ProfileName, err)
}
return nil, fmt.Errorf("resolve task environment: %w", err)
}
Wiring the checker at startup
apps/backend/internal/backendapp/main.go ↗The server wires the reference checker so every delete goes through the same discovery path.
Wiring
secretsSvc := secrets.NewService(userSecretStore, log)
secretsSvc.SetReferenceChecker(secretReferenceChecker{
agents: repos.AgentSettings, tasks: repos.Task,
authorizeWorkspace: services.Task.AuthorizeWorkspaceAccess,
}.list)
secretsSvc.SetWorkspaceAuthorizer(func(ctx context.Context, workspaceID string) error {