Add opt-in JIRA-backed workspace branch naming while preserving editable manual branch workflows. - add scoped JIRA configuration, secret resolution, and documented inheritance - expose a bounded JIRA issue client and branch-derivation API route - add settings and task-form controls with recoverable errors and coverage - derive sanitized branch names from configurable issue-key and summary templates Files changed: .changeset/fn-9165-jira-branch-name.md | 7 ++ docs/secrets.md | 4 + docs/settings-reference.md | 18 ++++ docs/workspaces.md | 4 + packages/core/src/__tests__/jira-config.test.ts | 6 ++ .../core/src/__tests__/settings-parity.test.ts | 12 +++ packages/core/src/config/settings-schema.ts | 14 +++ packages/core/src/index.ts | 2 + packages/core/src/jira/index.ts | 1 + packages/core/src/jira/jira-config.ts | 34 +++++++ packages/core/src/types/settings/settings-scope.ts | 16 ++++ .../app/__tests__/settings-save-split.test.ts | 43 +++++++++ .../dashboard/app/components/SettingsModal.tsx | 29 +++++- packages/dashboard/app/components/TaskForm.tsx | 23 ++++- .../app/components/__tests__/TaskForm.test.tsx | 31 +++++++ .../settings/__tests__/section-keys.test.ts | 2 + .../app/components/settings/save-split.ts | 64 ++++++++++++- .../app/components/settings/section-keys.ts | 1 + .../sections/SourceControlGlobalSection.search.ts | 9 ++ .../sections/SourceControlGlobalSection.tsx | 20 ++++- .../sections/SourceControlSection.search.ts | 9 ++ .../settings/sections/SourceControlSection.tsx | 12 +++ .../settings-default-descriptions.test.tsx | 7 ++ .../dashboard/src/__tests__/jira-client.test.ts | 58 ++++++++++++ .../src/__tests__/register-jira-routes.test.ts | 70 +++++++++++++++ packages/dashboard/src/jira-auth.ts | 18 ++++ packages/dashboard/src/jira.ts | 75 ++++++++++++++++ packages/dashboard/src/routes.ts | 2 + packages/dashboard/src/routes/README.md | 100 +++++++++++---------- .../src/routes/create-api-routes-mount-sequence.ts | 2 +- packages/dashboard/src/routes/register-jira.ts | 66 ++++++++++++++ packages/engine/src/index.ts | 2 + .../worktree/__tests__/jira-branch-name.test.ts | 7 ++ packages/engine/src/worktree/jira-branch-name.ts | 6 ++ packages/i18n/locales/en/app.json | 23 +++++ 35 files changed, 739 insertions(+), 58 deletions(-) Fusion-Task-Id: FN-9165 Fusion-Task-Lineage: 88ab5fa3-f66e-421d-8139-5c3e8917348d Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
247 lines
20 KiB
Markdown
247 lines
20 KiB
Markdown
# Secrets
|
|
|
|
[← Docs index](./README.md)
|
|
|
|
## Overview
|
|
|
|
Fusion's secrets subsystem provides encrypted-at-rest secret storage in PostgreSQL, with project scope in `project.secrets` and global scope in `central.secrets_global`.
|
|
|
|
## Implementation Status
|
|
|
|
| Surface | Status | Follow-up | Source of truth |
|
|
|---|---|---|---|
|
|
| AES-256-GCM encryption primitives | Shipped | — | `packages/core/src/secrets-crypto.ts` |
|
|
| `SecretsStore` CRUD + `revealSecret` | Shipped | — | `packages/core/src/secrets-store.ts` |
|
|
| Per-secret access policy + global fallback resolver | Shipped | — | `packages/core/src/secret-access-policy.ts` |
|
|
| `MasterKeyManager` (keychain primary, file fallback) | Shipped | — | `packages/core/src/master-key.ts` |
|
|
| `fn_secret_get` pi-extension tool (auto/prompt/deny + missing-key) | Shipped | — | `packages/cli/src/extension.ts` |
|
|
| `secret:read` / `secret:approval-requested` / `secret:approval-denied` audit events | Shipped | — | `packages/cli/src/extension.ts` (`emitSecretAudit`) |
|
|
| Approval API integration for `prompt` policy (`ApprovalRequestStore` + `POST /api/approvals/:id/decision`) | Shipped | — | `packages/cli/src/extension.ts` |
|
|
| `secrets-sync.ts` wrap/unwrap core (scrypt → AES-256-GCM, version 1, typed errors) | Shipped | — | `packages/core/src/secrets-sync.ts` |
|
|
| Dashboard `SecretsView` CRUD UI | Shipped | — | `packages/dashboard/app/components/SecretsView.tsx` |
|
|
| `secretsEnv.*` settings + worktree `.env` materialization + fingerprint cleanup | Shipped | — | `packages/core/src/types.ts`, `packages/engine/src/secrets-env-writer.ts` |
|
|
| `secretsSyncPassphraseConfigured` global read-only probe + reserved secret storage (`__sync_passphrase__`) | Shipped | — | `packages/core/src/types.ts`, `packages/core/src/secrets-sync-passphrase.ts` |
|
|
| Cross-node sync REST endpoints (`/api/nodes/:id/secrets/{push,pull}`, `/api/secrets/sync-receive`, `/api/secrets/sync-export`) | Shipped | — | `packages/dashboard/src/routes/register-secrets-sync-routes.ts`, `packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts` |
|
|
| Audit-event registration on `FilesystemMutationType` for `secret:env-*` and `secret:sync-*` | Shipped | — | `packages/engine/src/run-audit.ts` |
|
|
| Master-key rotation UX | Pending | — | n/a |
|
|
| Per-secret TTL / rotation, KMS/Vault backends, per-node asymmetric sync | Out of scope | — | n/a |
|
|
|
|
Current shipped behavior in this branch includes:
|
|
|
|
- AES-256-GCM encryption primitives (`packages/core/src/secrets-crypto.ts`)
|
|
- CRUD + reveal APIs via `SecretsStore` (`packages/core/src/secrets-store.ts`)
|
|
- Per-secret access policy metadata (`auto` / `prompt` / `deny`)
|
|
- Schema-backed read metadata (`last_read_at`, `last_read_by`)
|
|
|
|
Threat-model baseline:
|
|
|
|
- Secret plaintext is **not** stored in PostgreSQL.
|
|
- Ciphertext + nonce are persisted; plaintext exists only in process memory during create/reveal.
|
|
- Secret values must never be logged.
|
|
- MCP server settings store only secret references for sensitive env/header/token fields; imports surface plaintext as secret-creation descriptors instead of persisting it in settings.
|
|
- MCP server secret references are materialized only at session/probe creation time for MCP-capable AI lanes and `POST /api/mcp/validate`; responses and structured logs include status/count metadata only, never resolved env/header values.
|
|
|
|
See also: [Storage](./storage.md), [Multi-project](./multi-project.md), [Architecture](./architecture.md), [Settings reference](./settings-reference.md), and [MCP](./mcp.md) for MCP-specific secret-reference workflows.
|
|
|
|
## Architecture
|
|
|
|
Fusion stores secrets in two PostgreSQL tables:
|
|
|
|
- Project scope: `project.secrets`, isolated by project identity
|
|
- Global scope: `central.secrets_global`
|
|
|
|
Both tables share the same column contract:
|
|
|
|
| Column | Type | Notes |
|
|
|---|---|---|
|
|
| `id` | `TEXT` | Primary key UUID. |
|
|
| `key` | `TEXT` | Unique secret key (`idxSecretsKey` / `idxSecretsGlobalKey`). |
|
|
| `value_ciphertext` | `BYTEA` | AES-GCM ciphertext payload (includes auth tag). |
|
|
| `nonce` | `BYTEA` | Per-row random nonce. |
|
|
| `description` | `TEXT` | Optional metadata. |
|
|
| `access_policy` | `TEXT` | `CHECK` constrained to `auto`, `prompt`, `deny`. |
|
|
| `env_exportable` | `INTEGER` | `0/1` flag for env-materialization intent metadata. |
|
|
| `env_export_key` | `TEXT` | Optional env variable key metadata. |
|
|
| `created_at` | `TEXT` | ISO timestamp. |
|
|
| `updated_at` | `TEXT` | ISO timestamp. |
|
|
| `last_read_at` | `TEXT` | Last reveal timestamp. |
|
|
| `last_read_by` | `TEXT` | Agent/user identifier recorded on reveal. |
|
|
|
|
For broader database inventory, see [docs/storage.md](./storage.md).
|
|
|
|
## Encryption
|
|
|
|
Secret crypto uses AES-256-GCM with:
|
|
|
|
- 32-byte master key
|
|
- 12-byte random nonce per encrypt operation
|
|
- 16-byte auth tag appended to ciphertext
|
|
|
|
Implementation reference: `packages/core/src/secrets-crypto.ts`.
|
|
|
|
## Master Key Resolution
|
|
|
|
The current implementation exposes a `MasterKeyProvider` abstraction consumed by `createSecretCipher` / `SecretsStore`.
|
|
|
|
- Required contract: async provider that returns a **32-byte** key.
|
|
- Validation failures return non-sensitive `SecretCryptoError` codes.
|
|
|
|
Runtime keychain/filesystem resolution is shipped via `MasterKeyManager` (`packages/core/src/master-key.ts`) with keychain-primary lookup and `~/.fusion/master.key` fallback (mode `0600`); rotation UX remains follow-up work.
|
|
|
|
## Access Policies
|
|
|
|
Per-secret policy values are:
|
|
|
|
- `auto`
|
|
- `prompt`
|
|
- `deny`
|
|
|
|
Resolution helper (`resolveSecretAccessPolicy`) uses:
|
|
|
|
1. Row-level secret policy (if set)
|
|
2. Global settings default `secretsAccessPolicy` (if set)
|
|
3. Fallback: `prompt`
|
|
|
|
Implementation references:
|
|
|
|
- `packages/core/src/secret-access-policy.ts`
|
|
- `packages/core/src/types.ts` (`GlobalSettings.secretsAccessPolicy`)
|
|
|
|
Approval integration is active through `fn_secret_get` policy handling (`packages/cli/src/extension.ts:1581-1611`) and approvals lifecycle APIs.
|
|
|
|
## Dashboard CRUD
|
|
|
|
Dashboard secrets CRUD is shipped via `SecretsView` (`packages/dashboard/app/components/SecretsView.tsx`), backed by the existing secrets API/store surfaces.
|
|
|
|
Dashboard requests carry the currently selected `projectId`. Secrets routes require that explicit request identity **before** resolving project context, so a missing, empty, or whitespace-only id is rejected with HTTP 400 instead of selecting the daemon launch directory's fallback store. The selected id intentionally binds the project store: project-scoped rows use that project's RLS-protected `project.secrets` partition, while global-scoped rows still use shared `central.secrets_global` and remain visible from every selected project.
|
|
|
|
### Recovering pre-fix fallback rows
|
|
|
|
Older dashboard writes made without an explicit project id may be stranded in a `local-*` project partition associated with the daemon launch directory. Fusion does not move these rows automatically: their intended registered-project destination cannot be inferred safely. An operator who has independently identified the destination may reassign only the affected `project.secrets` rows using an audited database recovery procedure. Do not apply this procedure to `central.secrets_global`, and do not treat direct SQL reassignment as runtime architecture.
|
|
|
|
## Agent Access (`fn_secret_get`)
|
|
|
|
`fn_secret_get` is shipped in `packages/cli/src/extension.ts:1542-1629`.
|
|
|
|
Tool contract:
|
|
- Params: `key` (required), `scope?: "project" | "global"`.
|
|
- Resolution: when `scope` is omitted, lookup is project → global; when provided, only that scope is queried. Missing key returns `{ error: "not-found" }`.
|
|
- Policy outcomes:
|
|
- `auto` → reveals and returns plaintext value (`secret:read` audit at `extension.ts:1615`).
|
|
- `prompt` → creates `ApprovalRequestStore` request (`secret-read:{scope}:{key}:{agentId}` dedupe key) and returns `{ outcome: "pending_approval", approvalRequestId }` (`extension.ts:1607-1611`).
|
|
- `deny` → immediate refusal and `secret:approval-denied` audit (`extension.ts:1581-1583`).
|
|
|
|
## `.env` Auto-write into Worktrees
|
|
|
|
Fusion can materialize env-exportable secrets into each acquired task worktree when project settings enable it (`secretsEnv.enabled=true`).
|
|
|
|
- Supported settings: `enabled`, `filename` (default `.env`, validated as local filename only), `overwritePolicy` (`skip`/`merge`/`replace`), `keyPrefix`, `requireGitignored` (default `true`).
|
|
- Safety guard: when `requireGitignored` is enabled, Fusion runs `git check-ignore -- <filename>` and refuses writes unless the file is ignored.
|
|
- Write contract: managed content is canonicalized and written atomically with mode `0o600`; audit metadata includes keys and counts, never values.
|
|
- Fingerprint record: successful writes atomically persist `.fusion-secrets-env.fingerprint` containing `<sha256>\n<filename>\n` with mode `0o600` in the worktree's private Git directory (`git rev-parse --git-dir`), never in project content. This keeps Fusion bookkeeping out of porcelain status while teardown can verify file integrity before deletion.
|
|
- Legacy reconciliation: before a reused worktree refreshes, Fusion recognizes the exact v0.75.1 UTF-8 root wire format: `<64 lowercase SHA-256 hex>\n<valid filename>\n` (normally `<sha>\n.secrets.env\n`), with no marker or envelope. It writes and syncs a temporary private record, atomically renames it, and syncs the private Git directory before unlinking the root record; it then syncs the worktree root directory before declaring execution safe. Retry re-writes and syncs even a private-only record, so a readable record left after a failed post-rename write cannot bypass the private-directory durability barrier. Thus a crash before either barrier remains fail-closed and retries converge without losing the only validated cleanup authority. Identical private and legacy records reduce to the private record; a valid private record supersedes an absent or invalid legacy record. A malformed sole record, unavailable Git directory, directory-sync failure, tracked root record, or different valid records fails the refresh closed without deleting or overwriting records or the env file.
|
|
- Teardown cleanup: Fusion selects only one uniquely validated canonical record. It deletes the managed env file only when its fingerprint still matches and Git proves it is untracked; a missing env removes equivalent validated records, while a tracked or edited env, malformed record, or conflicting records are preserved for safety and diagnosis. Metadata removal or directory-sync failure is reported as a fixed non-success cleanup outcome rather than a false successful cleanup, so retry can reconcile the remaining authority.
|
|
|
|
Settings shape is split by scope: project-level secrets settings include `ProjectSettings.secretsEnv` and MCP secret references in `ProjectSettings.mcpServers`, while cross-node sync passphrase state is stored only as the reserved `__sync_passphrase__` row in `secrets_global` and exposed read-only through `GlobalSettings.secretsSyncPassphraseConfigured` (`packages/core/src/types.ts`). Settings never carry plaintext passphrases or MCP credentials; MCP env/header/token fields use `{ secretRef, scope }` and materialize through `SecretsStore.revealSecret(...)` only at the runtime use seam.
|
|
|
|
### Test locations
|
|
|
|
The settings contract (`SecretsEnvSettings` shape, defaults, project round-trip) is covered in `@fusion/core`:
|
|
|
|
- `packages/core/src/__tests__/secrets-env.test.ts` — type contract + defaults
|
|
- `packages/core/src/__tests__/store-settings.test.ts` — `secretsEnv` project round-trip
|
|
- `packages/core/src/__tests__/store-settings-sync-passphrase-probe.test.ts` — read-only `secretsSyncPassphraseConfigured` derivation + write-strip behavior
|
|
|
|
The materialization implementation lives in `@fusion/engine` and is covered there:
|
|
|
|
- `packages/engine/src/secrets-env-writer.ts` — `writeSecretsEnvFile` / `cleanupSecretsEnvFile`
|
|
- `packages/engine/src/__tests__/secrets-env-writer.test.ts` — writer/cleanup unit coverage
|
|
- `packages/engine/src/__tests__/worktree-acquisition-secrets-env.test.ts` — acquisition-time write
|
|
- `packages/engine/src/__tests__/worktree-pool-secrets-env-cleanup.test.ts` — pool prune cleanup
|
|
- `packages/engine/src/__tests__/reliability-interactions/secrets-env-materialization.test.ts` — cross-layer backstop
|
|
|
|
New FN tasks that need to verify env materialization should target the engine-side files; the core-side test only guards the settings contract.
|
|
|
|
## Cross-node Sync
|
|
|
|
Fusion now exposes four secrets sync endpoints:
|
|
|
|
- `POST /api/nodes/:id/secrets/push` — wraps local secrets into a passphrase-protected envelope and sends it to a remote node.
|
|
- `POST /api/nodes/:id/secrets/pull` — fetches a remote envelope from `GET /api/secrets/sync-export` and applies it locally.
|
|
- `POST /api/secrets/sync-receive` — inbound apply endpoint (Bearer `apiKey` required).
|
|
- `GET /api/secrets/sync-export` — inbound export endpoint (Bearer `apiKey` required).
|
|
|
|
Envelope format is `WrappedSecretsBundle` from `packages/core/src/secrets-sync.ts:33-38`: `{ version, ciphertext, salt, nonce, kdf, kdfParams }` plus transport metadata (`sourceNodeId`, `exportedAt`). Wrapping uses scrypt (`N=32768, r=8, p=1, keyLen=32`, `secrets-sync.ts:17-22`) and AES-256-GCM with base64 `ciphertext`/`salt`/`nonce` (`secrets-sync.ts:68-78`).
|
|
|
|
Sync passphrase storage is local-only: reserved key `__sync_passphrase__` in `secrets_global` with `access_policy="deny"` and `env_exportable=false`, encrypted under the local master key. The passphrase is never transmitted and never returned by HTTP endpoints.
|
|
|
|
Dashboard UX now exposes this through SecretsView → **Cross-Node Sync Passphrase**. The panel uses `GET/PUT/DELETE /api/secrets/sync-passphrase`; the GET route returns only `{ configured: boolean }` (no plaintext readback), and the reserved `__sync_passphrase__` row is filtered from the regular `GET /api/secrets` list.
|
|
|
|
Error mapping:
|
|
|
|
- `SecretsSyncError` codes (`wrong-passphrase`, `version-mismatch`, `malformed`) return HTTP `400` with `{ "error": <code> }`.
|
|
- Missing passphrase returns HTTP `400` with `{ "error": "passphrase-not-configured" }`.
|
|
- Bearer auth failures return HTTP `401`.
|
|
|
|
Inbound auth contract is enforced in route code (`packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts:99-114`, `:181-196`): missing/invalid Bearer `Authorization` or mismatched local `apiKey` returns 401.
|
|
|
|
Audit payloads exclude plaintext values, passphrases, and envelope crypto material (`ciphertext`, `salt`, `nonce`).
|
|
|
|
## Audit Events
|
|
|
|
Filesystem-domain secret audit taxonomy:
|
|
|
|
- `secret:read`
|
|
- `secret:create`
|
|
- `secret:update`
|
|
- `secret:delete`
|
|
- `secret:approval-requested`
|
|
- `secret:approval-granted`
|
|
- `secret:approval-denied`
|
|
- `secret:sync-push`
|
|
- `secret:sync-pull`
|
|
- `secret:env-write`
|
|
- `secret:env-write-skipped`
|
|
- `secret:env-cleanup`
|
|
- `secret:env-cleanup-skipped`
|
|
|
|
All listed events are enumerated in `packages/engine/src/run-audit.ts:261-274` (union at `run-audit.ts:325`). Route/tool emitters include: `secret:sync-push` (`packages/dashboard/src/routes/register-secrets-sync-routes.ts:92`), `secret:sync-pull` (`register-secrets-sync-routes.ts:180`, `register-secrets-sync-inbound-routes.ts:158-164`), `secret:read` + approval events (`packages/cli/src/extension.ts:1581-1615`), env materialization/cleanup (`packages/engine/src/secrets-env-writer.ts:99-217`).
|
|
|
|
Track follow-up: **FN-5031** (missing `packages/core/src/__tests__/secrets-env.test.ts` contract file).
|
|
|
|
**Plaintext prohibition:** audit payload metadata must never include plaintext, decrypted values, ciphertext, or nonce fields. Use `assertNoSecretPlaintext(...)` as the canonical enforcement helper before emitting secret audit events.
|
|
|
|
## Provider credential instances (`auth.json`)
|
|
|
|
Fusion keeps provider credentials in `~/.fusion/agent/auth.json`. A legacy bare key such as `"openrouter"` is the default instance; a named key is `"openrouter[work]"`. Provider and instance ids are non-empty, no-whitespace, no-bracket strings up to 64 characters. Existing bare entries remain readable and are not rewritten merely by reading them.
|
|
|
|
`__fusionDefaultInstances` is reserved metadata, never a credential. Its per-provider pointer wins only when it names an existing valid credential; resolution otherwise falls back to the bare key, then the lexicographically first named instance, then no instance (`getDefaultInstance` returns `undefined`). The metadata is untrusted: malformed records or stale entries are ignored without a read-time repair. Deleting a pointed-to instance removes that pointer in the same locked write.
|
|
|
|
Legacy string APIs parse this grammar rather than accepting raw keys. `set("provider", credential)` updates the resolved default, or creates the bare key when none exists; `remove`, `logout`, `removeInstance`, and `modify` are no-ops when no instance exists. `setDefaultInstance` never creates a credential and rejects a missing target. All mutators, including deletes, reject the reserved metadata key. `list()` and `getAll()` remain logical-provider keyed (one resolved credential per provider); use `listInstances()` for individual instances. On-disk records are untrusted and values are type-filtered as credentials, so metadata and malformed values are never returned. External Claude/Codex hydration intentionally consumes bare keys only and ignores named instance keys.
|
|
|
|
At session creation, an explicitly selected credential instance wins over the provider default only for that provider. A missing named instance falls back to the canonical provider default and is recorded in `session:runtime-resolved`; Fusion never substitutes an arbitrary instance. If there is no default, session resolution fails without exposing credential material. Omitted instance ids retain the legacy default-resolving behavior.
|
|
|
|
Dashboard auth routes accept an optional instance id; omitted or blank ids retain default-instance behavior. `GET /api/auth/status?provider=<id>&instance=<id>` keeps the full provider envelope but describes the requested instance at that provider entry. A valid missing id is an unauthenticated `200` result, never a fallback to another account. Credential-establishing login and API-key writes may create a supplied id; rename, default, logout, and delete require an existing id. OAuth binds the id and optional opaque label to its server-side flow state, so a callback cannot fall back to the default account. Instance listings expose only ids, labels, auth status, and masked key hints; raw key and token material never leaves storage. `removeInstance` deletes the credential row and its default participation, while credential clear only removes its usable credential state.
|
|
|
|
## Operational Notes
|
|
|
|
- Backups: preserve PostgreSQL project/central schemas and the master-key material/provider source used by the deployment. Retain legacy SQLite backups only as controlled migration/recovery inputs; they are not runtime authority.
|
|
- If master key material is lost, encrypted secret values become unrecoverable.
|
|
- Pending advanced capabilities:
|
|
- Master-key rotation UX and key lifecycle tooling
|
|
- TTL/rotation automation, env-set profiles, KMS/Vault backends, per-node asymmetric sync
|
|
|
|
## Organization bundles
|
|
|
|
`fn org-export <file>` creates a portable bundle for one selected project plus global
|
|
settings. Exports scrub credential values by default: provider keys, daemon and remote
|
|
access tokens, webhook secrets, and inline `secretsEnv` values are omitted. MCP and
|
|
other secret references remain key-only, so importing a bundle requires the destination
|
|
operator to provision the referenced secrets. `secretsAccessPolicy` and
|
|
`secretsSyncPassphraseConfigured` remain because they are configuration/state rather
|
|
than secret values.
|
|
|
|
### JIRA API token
|
|
|
|
Store a JIRA API token or PAT as `JIRA_API_TOKEN` (or the configured `jiraAuthTokenSecretKey`). Fusion checks project scope first and falls back to global scope. The token is revealed only in-process when deriving a branch name and is never logged or returned.
|