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>
20 KiB
Secrets
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, Multi-project, Architecture, Settings reference, and MCP 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.
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
SecretCryptoErrorcodes.
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:
autopromptdeny
Resolution helper (resolveSecretAccessPolicy) uses:
- Row-level secret policy (if set)
- Global settings default
secretsAccessPolicy(if set) - Fallback:
prompt
Implementation references:
packages/core/src/secret-access-policy.tspackages/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
scopeis 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:readaudit atextension.ts:1615).prompt→ createsApprovalRequestStorerequest (secret-read:{scope}:{key}:{agentId}dedupe key) and returns{ outcome: "pending_approval", approvalRequestId }(extension.ts:1607-1611).deny→ immediate refusal andsecret:approval-deniedaudit (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(defaulttrue). - Safety guard: when
requireGitignoredis enabled, Fusion runsgit 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.fingerprintcontaining<sha256>\n<filename>\nwith mode0o600in 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 + defaultspackages/core/src/__tests__/store-settings.test.ts—secretsEnvproject round-trippackages/core/src/__tests__/store-settings-sync-passphrase-probe.test.ts— read-onlysecretsSyncPassphraseConfiguredderivation + write-strip behavior
The materialization implementation lives in @fusion/engine and is covered there:
packages/engine/src/secrets-env-writer.ts—writeSecretsEnvFile/cleanupSecretsEnvFilepackages/engine/src/__tests__/secrets-env-writer.test.ts— writer/cleanup unit coveragepackages/engine/src/__tests__/worktree-acquisition-secrets-env.test.ts— acquisition-time writepackages/engine/src/__tests__/worktree-pool-secrets-env-cleanup.test.ts— pool prune cleanuppackages/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 fromGET /api/secrets/sync-exportand applies it locally.POST /api/secrets/sync-receive— inbound apply endpoint (BearerapiKeyrequired).GET /api/secrets/sync-export— inbound export endpoint (BearerapiKeyrequired).
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:
SecretsSyncErrorcodes (wrong-passphrase,version-mismatch,malformed) return HTTP400with{ "error": <code> }.- Missing passphrase returns HTTP
400with{ "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:readsecret:createsecret:updatesecret:deletesecret:approval-requestedsecret:approval-grantedsecret:approval-deniedsecret:sync-pushsecret:sync-pullsecret:env-writesecret:env-write-skippedsecret:env-cleanupsecret: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.