Skip to content

feat(settings): onboard hub provider credentials for dictation and voice - #1392

Merged
tiann merged 16 commits into
tiann:mainfrom
heavygee:feat/settings-provider-onboard
Aug 7, 2026
Merged

feat(settings): onboard hub provider credentials for dictation and voice#1392
tiann merged 16 commits into
tiann:mainfrom
heavygee:feat/settings-provider-onboard

Conversation

@heavygee

@heavygee heavygee commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Closes Settings: onboard dictation (and voice) provider credentials in UI #1384: Settings → Voice can add / edit / clear hub-side credentials for dictation providers and voice-assistant backends without SSH-editing hub.env.
  • Hub persists providerCredentials in settings.json (masked reads; authenticated writes; process env still wins when set).
  • After save, pickers refresh so providers appear without a restart for UI-managed keys.

Motivation

Env-only configuration for these keys was a shipping shortcut, not a design win. HAPI is local-first / single-operator; empty "No transcription provider is configured on the hub" with no UI path made the feature look broken. Env remains a valid ops override.

Test plan

  • bun typecheck
  • Focused hub/web tests (providerCredentials, voice routes, client, voice settings)
  • Operator dogfood: Settings onboard → provider appears in picker
  • Review: env-set keys show as locked / non-editable and win over settings.json
  • Review: clearing a UI credential removes the provider from the picker
  • Review: no full secrets returned on GET credentials

Made with Cursor

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Alias env overrides can be shadowed by a saved canonical alias — the startup loop locks individual names, so a saved GEMINI_API_KEY is copied into process.env even when GOOGLE_API_KEY came from the startup environment; runtime consumers prefer GEMINI_API_KEY, contradicting the documented env precedence. The same applies to DASHSCOPE_API_KEY/QWEN_API_KEY. Evidence hub/src/config/providerCredentials.ts:189.
    Suggested fix:
    function isLogicallyEnvLocked(key: ProviderCredentialEnvKey): boolean {
        if (key === 'GEMINI_API_KEY' || key === 'GOOGLE_API_KEY') {
            return envLockedKeys.has('GEMINI_API_KEY') || envLockedKeys.has('GOOGLE_API_KEY')
        }
        if (key === 'DASHSCOPE_API_KEY' || key === 'QWEN_API_KEY') {
            return envLockedKeys.has('DASHSCOPE_API_KEY') || envLockedKeys.has('QWEN_API_KEY')
        }
        return envLockedKeys.has(key)
    }
  • [Major] Blank Save values are encoded as deletion — Save sends null for an untouched password, and OpenAI-compatible fields are never initialized from the returned status, so replacing one field can clear the endpoint, model, or existing API key. A separate Clear action already exists. Evidence web/src/components/settings/TranscriptionProviderOnboard.tsx:116.
    Suggested fix:
    const value = clear ? null : (apiKey.trim() || undefined)
    // Initialize baseUrl/model from status, and send undefined for untouched fields.
    baseUrl: baseUrl.trim() || undefined,
    model: model.trim() || undefined,
    apiKey: value,
  • [Major] Provider secrets are persisted with default filesystem permissions — this new write stores long-lived third-party API keys through writeSettings(), whose temp file has no explicit mode; HAPI_HOME is also created without a restrictive mode. With a common umask 022, settings.json becomes 0644 and is readable by other local users. Evidence hub/src/config/providerCredentials.ts:338.
    Suggested fix:
    await mkdir(dir, { recursive: true, mode: 0o700 })
    await chmod(dir, 0o700)
    await writeFile(tmpFile, JSON.stringify(settings, null, 2), { mode: 0o600 })
    await chmod(tmpFile, 0o600)
    await rename(tmpFile, settingsFile)

Summary
Review mode: initial
Three Major issues found: alias precedence violation, destructive blank-save behavior, and insecure on-disk permissions for newly persisted provider credentials.

Testing
Not run (review automation; PR content was not executed). Missing coverage: saved primary alias plus secondary env override, editing credentials while preserving untouched fields, and POSIX settings file/directory modes.

HAPI Bot

Comment thread hub/src/config/providerCredentials.ts Outdated
Comment thread web/src/components/settings/TranscriptionProviderOnboard.tsx Outdated
Comment thread hub/src/config/providerCredentials.ts Outdated
heavygee added a commit to heavygee/hapi that referenced this pull request Aug 6, 2026
Alias env locks, non-destructive Save (omit empty fields), and
owner-only settings.json permissions for hub-stored provider secrets.

Co-authored-by: Cursor <cursoragent@cursor.com>
@heavygee

heavygee commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the three Major findings in 9091224:

  1. Alias env locksisLogicallyEnvLocked treats GEMINI/GOOGLE and DASHSCOPE/QWEN as one lock so a settings alias cannot shadow the env sibling on apply.
  2. Save ≠ Clear — empty fields send undefined (omit); only Clear sends null. OpenAI-compatible base URL/model are seeded from status so Save does not wipe them.
  3. Owner-only settings.jsonwriteSettings now uses 0o600 file + 0o700 dir with post-write chmod (umask-safe).

Added regression tests for alias shadowing, omit-vs-clear, and POSIX mode 0o600.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Restrict hub-global credential management to the owner namespace — these routes are mounted behind authentication, but unlike other hub-global endpoints they do not check c.get('namespace'). Any member of a shared-team hub can therefore inspect credential metadata and replace or clear credentials used by every namespace. Evidence hub/src/web/routes/voice.ts:490.
    Suggested fix:
    if (c.get('namespace') !== 'default') {
        return c.json({ error: 'Provider credentials are only available to the hub owner' }, 403)
    }
  • [Major] Stage updates before changing live process.env — the patch helpers mutate runtime credentials before all validation and before writeSettings() succeeds. A multi-field request that later hits an env lock returns 409 after earlier keys changed; a disk error returns 500 while the new credential remains active until restart. Evidence hub/src/config/providerCredentials.ts:271.
    Suggested fix:
    const nextStored = { ...stored }
    applyPatchToStored(nextStored, key, value) // stored map only; validate every field
    await writeSettings(settingsFile, { ...settings, providerCredentials: nextStored })
    syncSettingsCredentialsToEnv(nextStored)
  • [Major] Preserve per-field editing for mixed OpenAI-compatible configuration — baseUrlEditable, modelEditable, and apiKey.editable are collapsed with &&, then that aggregate disables every input and both actions. If only one field comes from env (for example an env API token with a Settings-managed local URL/model), the UI cannot edit or clear the remaining Settings fields, defeating this onboarding path. Evidence web/src/components/settings/TranscriptionProviderOnboard.tsx:54.
    Suggested fix:
    disabled={busy || !status?.openaiCompatible.baseUrlEditable}
    // model: use modelEditable; password: use apiKey.editable
    // Clear should send null only for fields whose editable flag is true.

Summary
Review mode: follow-up after new commits
Three Major issues remain at the current head: cross-namespace mutation of hub-global credentials, non-atomic live credential updates, and unusable mixed env/Settings OpenAI-compatible configurations.

Testing
Not run (review automation; PR content was not executed). Missing coverage: non-default namespace GET/PUT rejection, rollback/no runtime change on validation or persistence failure, and mixed per-field env locks in the settings UI.

HAPI Bot

Comment thread hub/src/web/routes/voice.ts
Comment thread hub/src/config/providerCredentials.ts Outdated
Comment thread web/src/components/settings/TranscriptionProviderOnboard.tsx Outdated
@heavygee

heavygee commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Second-pass Majors addressed:

  1. Owner namespace — GET/PUT /credentials return 403 unless ns === 'default'.
  2. Atomic persist — patches stage into a copy; process.env syncs only after writeSettings succeeds.
  3. Independent OpenAI-compatible editability — base URL / model / API key use separate disabled flags; Clear only nulls editable fields.

Tests cover non-owner 403, locked multi-field no env mutation, write failure no env mutation, and mixed-lock editability flags.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Preserve incomplete OpenAI-compatible entries as clearable state - the Clear action is rendered only when selected.configured is true, but that flag requires both base URL and model. The API and Save flow allow partial values, including an API key alone; after reopening, that stored secret is not considered configured, blank Save means "unchanged," and no Clear button is available. Evidence web/src/components/settings/TranscriptionProviderOnboard.tsx:280.
    Suggested fix:
    const canClearCompatible = provider === 'openai-compatible' && Boolean(
        (baseUrlEditable && compatible?.baseUrl)
        || (modelEditable && compatible?.model)
        || (apiKeyEditable && compatible?.apiKey.configured)
    )
    const canClear = canClearCompatible || Boolean(
        provider !== 'openai-compatible' && selected?.configured && selected.editable
    )
    // Render Clear when canClear is true.
  • [Major] Serialize the credential read-modify-write transaction - each PUT reads the whole settings file, writes through the shared settings.json.tmp, then syncs its private snapshot into process.env. Two concurrent saves (or a save racing relay-key persistence) can lose unrelated fields, fail one rename with ENOENT, or leave disk containing one snapshot while runtime uses another. Evidence hub/src/config/providerCredentials.ts:354.
    Suggested fix:
    return updateSettings(settingsFile, async (settings) => {
        const nextStored = { ...readProviderCredentials(settings) }
        applyCredentialPatch(nextStored, update)
        return {
            settings: { ...settings, providerCredentials: nextStored },
            afterCommit: () => syncSettingsCredentialsToEnv(nextStored),
        }
    }) // updateSettings locks the full read/modify/write per file

Summary
Review mode: follow-up after new commits
The previously reported namespace, atomic-failure, and mixed-lock issues are addressed at this head. Two Major issues remain: partial OpenAI-compatible credentials can become impossible to remove through the UI, and concurrent settings writes can lose or desynchronize credentials.

Testing
Not run (review automation; PR content was not executed). Missing coverage: clearing base-URL-only/model-only/API-key-only OpenAI-compatible state, and concurrent credential/settings writes.

HAPI Bot

Comment thread web/src/components/settings/TranscriptionProviderOnboard.tsx Outdated
Comment thread hub/src/config/providerCredentials.ts Outdated
@heavygee

heavygee commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Third-pass Majors addressed:

  1. Partial OpenAI-compatible Clear — Clear appears when any editable field has a stored value (URL / model / API key), not only when both URL+model make configured true.
  2. Serialized settings RMWwithSettingsLock chains concurrent updates per settings file so disjoint PUTs cannot clobber each other or race on .tmp.

Tests cover concurrent openai+groq updates and API-key-only partial status.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Serialize credential updates against every settings writer — the new lock only orders callers that explicitly use withSettingsLock. Runtime relay-key paths still perform unlocked read-modify-write operations through the same fixed settings.json.tmp, so a credential PUT racing relay issuance/refresh can still lose providerCredentials, lose the relay key, or fail a rename. Evidence hub/src/config/providerCredentials.ts:309; related context hub/src/tunnel/relayAuth.ts:46.
    Suggested fix:
    export async function updateSettings<T>(
        settingsFile: string,
        mutate: (settings: Settings) => Promise<{ settings: Settings; result: T }>
    ): Promise<T> {
        return withSettingsLock(settingsFile, async () => {
            const current = await readSettingsOrThrow(settingsFile)
            const { settings, result } = await mutate(current)
            await writeSettings(settingsFile, settings)
            return result
        })
    }
    // Use this helper for credentials and every runtime settings read-modify-write.
  • [Minor] Reset OpenAI-compatible form state after Clear — for URL-only or model-only partial configuration, the provider remains incomplete, so automatic onboarding stays mounted. The server value is cleared, but baseUrl/model state still shows the old value; pressing Save restores the credential that was just cleared. Evidence web/src/components/settings/TranscriptionProviderOnboard.tsx:182.
    Suggested fix:
    setStatus(next)
    setApiKey('')
    setBaseUrl(next.openaiCompatible.baseUrlEditable ? next.openaiCompatible.baseUrl ?? '' : '')
    setModel(next.openaiCompatible.modelEditable ? next.openaiCompatible.model ?? '' : '')

Summary
Review mode: follow-up after new commits
Two findings remain at the latest head: settings serialization does not cover cross-writer races, and clearing partial OpenAI-compatible fields can leave stale form state that re-saves the cleared value. No other high-confidence issues found in the full current diff.

Testing
Not run (review automation; PR content was not executed). Missing coverage: a credential update racing relay-key persistence, plus URL-only/model-only component state after Clear.

HAPI Bot

Comment thread hub/src/config/providerCredentials.ts Outdated
Comment thread web/src/components/settings/TranscriptionProviderOnboard.tsx
@heavygee

heavygee commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Fourth-pass findings addressed:

  1. Serialize every settings writer — added updateSettings + locked writeSettings; migrated credentials, relay auth RMW, getOrCreateSettingsValue, loadServerSettings, and CLI token persist. Race test covers credential PUT vs relay-key write.
  2. Clear resets compatible fields — after Clear/Save, base URL / model / API key inputs resync from the returned status so partial clears cannot be re-saved from stale React state.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Join the CLI's cross-process settings lock - the new promise chain only serializes writers inside the hub process. The CLI writes the same ~/.hapi/settings.json under settings.json.lock and uses the same fixed .tmp path, but the hub ignores that lock. A credential PUT racing hapi auth login/logout can therefore lose credentials or CLI fields, rename the other process's temp file, or fail with ENOENT. Evidence hub/src/config/settings.ts:71; related context cli/src/persistence.ts:106.
    Suggested fix:
    // Shared by hub and CLI; retain the CLI retry/stale-lock handling.
    return withSettingsFileLock(settingsFile, async () => {
        const current = await readSettingsOrThrow(settingsFile)
        const outcome = await mutate(current)
        if (outcome.write !== false) await writeSettingsUnlocked(settingsFile, outcome.settings)
        return outcome.result
    })
  • [Major] Keep secret-bearing settings owner-only after CLI rewrites - the hub now writes mode 0600, but the CLI's atomic updater recreates settings.json.tmp with Node's default 0666 & umask and renames it over this file. With a common 022 umask, any later CLI settings update turns the file containing provider API keys into 0644. Evidence hub/src/config/settings.ts:89; related context cli/src/persistence.ts:148.
    Suggested fix:
    await writeFile(tmpFile, JSON.stringify(updated, null, 2), { mode: 0o600 })
    await chmod(tmpFile, 0o600)
    await rename(tmpFile, configuration.settingsFile)
    await chmod(configuration.settingsFile, 0o600)

Summary
Review mode: follow-up after new commits
Two Major shared-file risks remain at the latest head: hub/CLI updates are not mutually serialized across processes, and CLI rewrites can widen permissions on the newly secret-bearing settings file. The previous stale OpenAI-compatible Clear state is fixed in the current component.

Testing
Not run (review automation; PR code was not executed). GitHub test check passed. Missing coverage: a real hub-writer/CLI-writer race against one settings file, and a POSIX mode assertion after a CLI update rewrites settings containing provider credentials.

HAPI Bot

Comment thread hub/src/config/settings.ts
Comment thread hub/src/config/settings.ts Outdated
@heavygee

heavygee commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Fifth-pass Majors addressed:

  1. Cross-process lock — shared @hapi/protocol/settingsFileLock (wx + stale retry, same constants as the old CLI lock). Hub withSettingsLock = in-process chain + file lock; CLI writeSettings/updateSettings use the same helper.
  2. Owner-only after CLI rewrite — CLI atomic writes now use 0o600 + post-chmod (and 0o700 home dir).

Integration test races hub credential PUT against a CLI-style lock writer and asserts both fields survive with mode 0600.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Keep UI-managed provider secrets out of the global process environment — both startup loading and live saves copy settings-backed API keys into process.env. The hub subsequently inherits that environment into unrelated subprocesses, including the tunnel binary and Cursor/Codex helpers, so adding a key in Settings exposes it to processes that do not need voice credentials. Evidence hub/src/config/providerCredentials.ts:202; related context hub/src/tunnel/tunnelManager.ts:112, hub/src/cursor/acpVerifyProbe.ts:282, hub/src/web/routes/codexDesktop.ts:1621.
    Suggested fix:
    let settingsBackedCredentials: ProviderCredentialsMap = {}
    
    function setSettingsBackedCredentials(stored: ProviderCredentialsMap): void {
        settingsBackedCredentials = Object.fromEntries(
            Object.entries(stored).filter(([key]) =>
                !isLogicallyEnvLocked(key as ProviderCredentialEnvKey)
            )
        ) as ProviderCredentialsMap
    }
    
    export function getProviderEnvironment(): NodeJS.ProcessEnv {
        return { ...process.env, ...settingsBackedCredentials }
    }
    Use this effective provider environment only in voice discovery/request paths; do not mutate process.env.
  • [Major] Do not expire a settings lock solely by file age — after laptop suspend, debugger pause, or any holder delay beyond 10 seconds, another process removes the still-live lock. The original holder then unconditionally removes the successor's lock in finally, allowing a third writer to enter and reintroducing lost fields/temp-file races for secret-bearing settings.json. Evidence shared/src/settingsFileLock.ts:35 and shared/src/settingsFileLock.ts:57.
    Suggested fix:
    const owner = JSON.stringify({ pid: process.pid, token: randomUUID() })
    fileHandle = await open(lockFile, 'wx', 0o600)
    await fileHandle.writeFile(owner)
    
    // Break only locks whose recorded process is no longer alive.
    if (!isProcessAlive(existing.pid)) await unlink(lockFile)
    
    // Never delete a lock acquired by a successor.
    await fileHandle.close()
    if (await readFile(lockFile, 'utf8').catch(() => null) === owner) {
        await unlink(lockFile)
    }

Summary
Review mode: follow-up after new commits
Two Major risks remain at the latest head: settings-managed provider secrets cross the hub subprocess boundary, and the new shared lock can steal a live lock after a long pause, permitting settings clobbering.

Testing
Not run (automation; PR code was not executed). GitHub test check passed. Missing coverage: asserting provider keys are absent from spawned child environments, and a lock-ownership test where a holder survives beyond the stale threshold.

HAPI Bot

Comment thread hub/src/config/providerCredentials.ts Outdated
Comment thread shared/src/settingsFileLock.ts Outdated
@heavygee

heavygee commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Sixth-pass Majors addressed in ee9e5b5:

  1. UI secrets stay out of process.env — settings-backed credentials live in an in-memory overlay via getProviderEnvironment(); voice/dictation paths read that overlay. Tunnel/ACP/Codex children no longer inherit Settings-managed API keys.
  2. PID + token settings lockswithSettingsFileLock records {pid,token}; reclaim only when the recorded PID is dead (or the sidecar is legacy/empty); release unlinks only when the token still matches ours. EPERM on kill(pid,0) counts as alive.

Tests cover overlay vs process.env, dead-PID reclaim, legacy empty locks, and successor-safe release.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Blocker] Do not reclaim an unreadable lock during acquisition — open(..., 'wx') creates the sidecar before the owner JSON is written, so a concurrent writer can observe null, unlink the live lock, and enter the critical section too. Both writers then use settings.json.tmp; the current GitHub test check reproduces this as ENOENT at hub/src/config/settings.ts:102. Evidence shared/src/settingsFileLock.ts:68.
    Suggested fix:
    const existing = await readLockOwner(lockFile)
    if (existing && !isPidAlive(existing.pid)) {
        await unlink(lockFile).catch(() => {})
        continue
    }
    // null may be a just-created lock whose payload is still being written.
    await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_INTERVAL_MS))

Summary
Review mode: follow-up after new commits
One Blocker remains at the latest head: the revised settings lock still permits concurrent writers during the owner-payload publication window, and CI currently fails on that race.

Testing
Not run locally (automation; PR code was not executed). Current GitHub test check failed in providerCredentials > preserves hub credentials when raced with CLI-style settings lock writer; missing coverage: pause the first writer between exclusive create and owner-payload write, then assert a second writer waits.

HAPI Bot

Comment thread shared/src/settingsFileLock.ts Outdated
@heavygee

heavygee commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Blocker addressed in the tip commit:

Do not reclaim ownerless lock sidecars — acquisition now uses sync openSync('wx') + writeSync for the owner payload, and on EEXIST only unlinks when readLockOwner() returns a parsed owner whose PID is dead. A transient empty/partial file (publication window) is waited out, not stolen. That was the CI ENOENT on settings.json.tmp rename in the hub-vs-CLI race test.

Coverage: empty-sidecar fail-closed, dead-PID reclaim, successor-safe release, 8-way concurrent writers.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Serialize stale-lock reclamation before unlinking — two contenders can both read the same dead owner. After the first removes the stale sidecar and acquires a new lock, the second unconditional unlink can delete that live successor, allowing both callbacks to use settings.json.tmp concurrently and lose settings. Evidence shared/src/settingsFileLock.ts:87.
    Suggested fix:

    if (existing && !isPidAlive(existing.pid)) {
        // Fail closed until stale reclamation is protected by a separate
        // exclusive reaper lock and re-validates the same owner under it.
        throw new Error(`Stale settings lock requires cleanup: ${lockFile}`)
    }
  • [Major] Clean up a sidecar when owner publication fails — openSync(..., 'wx') creates the visible lock before writeSync. A thrown or short write leaves an empty/partial sidecar, and the new fail-closed reader will never reclaim it; every later settings mutation then times out even after the underlying I/O problem clears. Evidence shared/src/settingsFileLock.ts:69.
    Suggested fix:

    const fd = openSync(lockFile, 'wx', 0o600)
    try {
        const payload = Buffer.from(ownerPayload)
        let offset = 0
        while (offset < payload.length) {
            const written = writeSync(fd, payload, offset, payload.length - offset)
            if (written === 0) throw new Error('Failed to publish settings lock owner')
            offset += written
        }
        acquired = true
    } catch (error) {
        unlinkSync(lockFile)
        throw error
    } finally {
        closeSync(fd)
    }

Summary
Review mode: follow-up after new commits
The prior ownerless-publication-window finding is addressed, but two Major failure paths remain in the shared settings lock: concurrent stale-lock reapers can still break exclusivity, and owner publication failure can leave the hub/CLI unable to mutate settings.

Testing
Not run (automation; PR code was not executed). GitHub test was pending at review time. Add deterministic tests that synchronize two contenders after both read the same dead owner, and that inject a thrown/short owner write then verify the sidecar is removed and a subsequent acquisition succeeds.

HAPI Bot

Comment thread shared/src/settingsFileLock.ts Outdated
Comment thread shared/src/settingsFileLock.ts Outdated
@heavygee

heavygee commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Seventh-pass Majors addressed:

  1. Serialize stale reclaim — dead owners are moved aside with renameSync to a unique .break.* path, re-verified (pid+token, still dead), then unlinked. Contenders that lose the rename never touch the live lock path. Owner read during reclaim is synchronous so waiters cannot all snapshot one dead owner across an await and stampede the exclusive create.
  2. Failed publication cleanup — if writing the owner payload fails after wx, the sidecar is unlinked so settings writers are not wedged on an empty lock.

Tests cover write-failure cleanup, multi-contender dead-owner reclaim (max concurrency 1), and the existing empty-sidecar / successor-release cases.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Serialize stale-lock reclamation before moving the live path — unique break names do not stop a delayed contender from renaming a newly acquired successor lock, so concurrent settings writers can still enter together and lose fields. Evidence shared/src/settingsFileLock.ts:93.
    Suggested fix:
    // Fail closed until stale reclamation is serialized by one fixed reaper lock.
    if (existing && !isPidAlive(existing.pid)) {
        await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_INTERVAL_MS))
        continue
    }
  • [Minor] Add providerCredentials to the published strict settings schema — HAPI now writes this top-level field, but docs/public/schemas/settings.schema.json has additionalProperties: false, so editor/schema validation rejects a settings file generated by the feature. Evidence hub/src/config/settings.ts:30.
    Suggested fix:
    "providerCredentials": {
      "type": "object",
      "additionalProperties": { "type": "string" }
    }

Questions

  • None.

Summary
Review mode: follow-up after new commits
The owner-publication cleanup from the prior review is addressed. The stale-reclamation race remains through a different interleaving, and the new persisted field is missing from the repository's strict JSON schema.

Testing
Not run (automation; PR code was not executed). GitHub test and pr-review checks were pending. Add a deterministic race test that pauses one contender after reading the dead owner while another reclaims and acquires the successor lock; also validate a generated settings file containing providerCredentials against the published schema.

HAPI Bot

Comment thread shared/src/settingsFileLock.ts Outdated
Comment thread hub/src/config/settings.ts
@heavygee

heavygee commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Eighth-pass findings addressed:

  1. Reaper-serialized stale reclaim — dead owners are only unlinked while holding a fixed ${lock}.reap exclusive sidecar, after re-validating pid+token. Delayed contenders that still remember the old dead owner cannot rename/unlink a successor's live lock. Added a barrier test for that interleaving.
  2. Minor schemaproviderCredentials added to docs/public/schemas/settings.schema.json.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Fail closed when the CLI cannot read the shared settings document — the new locked CLI update still treats any read/JSON failure as an empty object and then replaces settings.json, which can erase hub-only fields including providerCredentials, relayAuthKey, and vapidKeys. Evidence cli/src/persistence.ts:111.
    Suggested fix:
    async function readSettingsForUpdate(): Promise<Settings> {
        if (!existsSync(configuration.settingsFile)) return { ...defaultSettings }
        const content = await readFile(configuration.settingsFile, 'utf8')
        const parsed: unknown = JSON.parse(content)
        if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
            throw new Error(`Invalid settings file: ${configuration.settingsFile}`)
        }
        return parsed as Settings
    }
    
    const current = await readSettingsForUpdate()
  • [Major] Back off when another process owns the stale-lock reaper — tryReclaimDeadOwner() returns false while ${lock}.reap exists, but this branch immediately retries without sleeping. A process scheduled while another contender holds the reaper can consume all 50 attempts synchronously and fail hub startup or a credential save instead of waiting the advertised five seconds. Evidence shared/src/settingsFileLock.ts:192.
    Suggested fix:
    const reclaimed = tryReclaimDeadOwner(lockFile, existing)
    if (!reclaimed) {
        await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_INTERVAL_MS))
    }
    continue

Questions

  • None.

Summary
Review mode: follow-up after new commits
The previous strict-schema issue is resolved, and the original delayed-renamer race was replaced by a fixed reaper. The current head still has one settings data-loss path and one cross-process stale-recovery failure.

Testing
Not run (automation; PR code was not executed). GitHub test passed; git diff --check passed. Add a CLI test asserting invalid settings bytes remain unchanged after an attempted update, plus a cross-process or barrier test that pauses one process after acquiring .reap while another retries.

HAPI Bot

Comment thread cli/src/persistence.ts Outdated
Comment thread shared/src/settingsFileLock.ts Outdated
@heavygee

heavygee commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Ninth-pass Majors addressed:

  1. CLI fail-closed read — locked updateSettings uses readSettingsForUpdate() which throws on unreadable/invalid JSON instead of synthesizing {} and wiping hub fields. Corrupt-file test asserts bytes unchanged.
  2. Reaper backoff — when ${lock}.reap is held, reclaim returns false and the waiter sleeps before retrying instead of spinning attempts synchronously.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Make settings-lock acquisition crash-recoverable — openSync(..., 'wx') exposes the fixed lock path before the owner JSON is published. Exception cleanup handles a thrown write, but a SIGKILL between those operations leaves an empty/partial lock; readLockOwnerSync() then returns null on every retry, so hub startup and all later settings writes fail until the sidecar is manually deleted. Evidence shared/src/settingsFileLock.ts:165.
    Suggested fix:
    function publishLockAtomically(lockFile: string, ownerPayload: string): void {
        const candidate = `${lockFile}.${randomUUID()}.candidate`
        writeFileSync(candidate, ownerPayload, { flag: 'wx', mode: 0o600 })
        try {
            linkSync(candidate, lockFile) // EEXIST, or a fully initialized lock
        } finally {
            unlinkSync(candidate)
        }
    }
  • [Major] Fix the hoisted configuration mock so the required test job can run — Vitest hoists the vi.mock factory above the top-level dir initialization, producing ReferenceError: Cannot access 'dir' before initialization. The current GitHub test check fails at this line before the new regression test executes, and hub/web/shared tests never run. Evidence cli/src/persistence.test.ts:10.
    Suggested fix:
    const dir = mkdtempSync(join(tmpdir(), 'hapi-cli-settings-'))
    
    vi.doMock('@/configuration', () => ({
        configuration: {
            happyHomeDir: dir,
            settingsFile: join(dir, 'settings.json'),
            // ...
        },
    }))
    
    const { updateSettings } = await import('./persistence')

Questions

  • None.

Summary
Review mode: follow-up after new commits
The two findings from the previous HAPI Bot pass are addressed. The current head still has one crash-recovery path that can wedge the shared settings file and one deterministic failure in the required test workflow.

Testing
Not run locally (automation; PR code was not executed). Current GitHub test check failed in cli/src/persistence.test.ts with the hoisted-mock TDZ error; 211 CLI test files passed before the job stopped. Add a child-process test that terminates after exclusive creation but before owner publication, then verifies a later acquisition can recover.

HAPI Bot

Comment thread shared/src/settingsFileLock.ts Outdated
Comment thread cli/src/persistence.test.ts
@heavygee

heavygee commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Tenth-pass Majors addressed:

  1. Crash-safe lock publish — acquisition writes a fully-formed .candidate then linkSyncs onto the fixed lock path. A kill mid-publish leaves an orphan candidate at worst, not an empty live sidecar. Orphan-candidate test included.
  2. Vitest hoist — CLI persistence test creates its temp dir inside vi.hoisted so the required test job can import the suite.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Make the reaper sidecar crash-recoverable — the main lock is now atomically published, but tryReclaimDeadOwner() creates ${lock}.reap with openSync(..., 'wx') and only removes it in normal exception/finally paths. If the process is killed after line 111, the fixed reaper remains forever; every later contender gets EEXIST, returns false, and eventually times out, so hub startup/settings writes remain wedged until manual deletion. Evidence shared/src/settingsFileLock.ts:111.
    Suggested fix:
    import lockfile from 'proper-lockfile'
    
    const release = await lockfile.lock(settingsFile, {
        realpath: false,
        stale: 30_000,
        update: 10_000,
        retries: { retries: 50, minTimeout: 100, maxTimeout: 100 },
    })
    try {
        return await work()
    } finally {
        await release()
    }
    Replace both bespoke sidecars with an equivalent crash-recoverable lease/advisory lock, and add a child-process test that dies while holding the reclaim guard.
  • [Minor] Hide the owner-only credential editor from tenant namespaces — the API deliberately returns 403 outside default, but this page renders and even auto-opens the editor for every authenticated client because it gates only on api. Tenant users can paste a secret and receive a failure after submission. Evidence web/src/routes/settings/voice.tsx:96.
    Suggested fix:
    const { api, token } = useAppContext()
    const canManageCredentials = getNamespace(token) === 'default'
    const credentialsOpen = canManageCredentials
        && (showCredentials || needsDictationOnboard || needsAssistantOnboard)
    
    {canManageCredentials ? <TranscriptionProviderOnboard /* ... */ /> : null}
    Reuse/factor the JWT namespace helper already used by SettingsNav, and cover the tenant rendering case.

Questions

  • None.

Summary
Review mode: follow-up after new commits
The current head still has a permanent settings-lock wedge after a crash in the reclaim guard, plus an owner-only control exposed to tenant UIs.

Testing
Not run locally (automation; PR code was not executed). GitHub test check passed for 4c8e06e93026d8209028262c124242efff9cceba. Add the reaper-crash child-process test and a tenant voice-settings rendering test.

HAPI Bot

Comment thread shared/src/settingsFileLock.ts Outdated
Comment thread web/src/routes/settings/voice.tsx Outdated
@heavygee

heavygee commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Eleventh-pass findings addressed by taking Codex's escape hatch:

  1. proper-lockfile — replaced the bespoke wx/reaper sidecars with proper-lockfile (mkdir + mtime lease, stale: 30s, retries). Crash-recoverable without an unreclaimable empty file. Lock path is settings.json.hapi.lock so leftover file-shaped *.lock artifacts from earlier revisions cannot block mkdir.
  2. Minor — voice settings credentials editor/link only render for the owner namespace (getNamespaceFromToken shared with SettingsNav).

@heavygee

heavygee commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Eleventh-pass findings addressed by taking Codex's escape hatch (fe71c2f):

  1. proper-lockfile — replaced the bespoke wx/reaper sidecars with proper-lockfile (mkdir + mtime lease, stale: 30s, retries). Crash-recoverable without an unreclaimable empty file. Lock path is settings.json.hapi.lock so leftover file-shaped *.lock artifacts from earlier revisions cannot block mkdir.
  2. Minor — voice settings credentials editor/link only render for the owner namespace (getNamespaceFromToken shared with SettingsNav).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • None.

Questions

  • None.

Summary
Review mode: follow-up after new commits
No high-confidence issues found in the full latest diff. The prior HAPI Bot findings about crash-recoverable settings locking and tenant visibility are addressed at this head. Residual risk is concentrated in unexercised browser rendering and real process-crash recovery paths.

Testing
Not run (automation; PR code was not executed). The GitHub test check was still in progress at review time. Suggested coverage: kill a child process while it holds the proper-lockfile lease and verify a later writer recovers; render Voice settings with a non-default namespace JWT and verify the credential editor is absent.

HAPI Bot

heavygee added a commit to heavygee/hapi that referenced this pull request Aug 6, 2026
Alias env locks, non-destructive Save (omit empty fields), and
owner-only settings.json permissions for hub-stored provider secrets.

Co-authored-by: Cursor <cursoragent@cursor.com>
@heavygee
heavygee force-pushed the feat/settings-provider-onboard branch from fe71c2f to 37c8980 Compare August 6, 2026 11:08

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • None.

Questions

  • None.

Summary
Review mode: follow-up after new commits
No high-confidence issues found in the full latest diff. The rebase-specific settings API adaptation preserves the prior credential-locking and owner-namespace protections. Residual risk remains in browser rendering/state transitions for the new credential editor and real process-crash recovery of the shared settings lock.

Testing
Not run (automation; PR code was not executed). The GitHub test check passed. Suggested coverage: render the credential editor with environment-locked and mixed OpenAI-compatible fields, exercise save/clear refresh behavior, and kill a child process while it holds the settings lease to verify recovery.

HAPI Bot

heavygee and others added 15 commits August 6, 2026 20:18
Env-only keys made dictation invisible; Settings can now add/edit/clear
hub-side credentials (masked), with env still winning as override.
Refs tiann#1384.

Co-authored-by: Cursor <cursoragent@cursor.com>
Same Settings credential surface now covers ElevenLabs, Gemini Live, and
Qwen Realtime (alias env pairs), not only transcription providers.

Co-authored-by: Cursor <cursoragent@cursor.com>
Alias env locks, non-destructive Save (omit empty fields), and
owner-only settings.json permissions for hub-stored provider secrets.

Co-authored-by: Cursor <cursoragent@cursor.com>
Owner-namespace gate, stage-then-sync env after persist, and
per-field OpenAI-compatible editability under mixed env locks.

Co-authored-by: Cursor <cursoragent@cursor.com>
Per-file settings lock for concurrent credential PUTs, and Clear shown
for partial OpenAI-compatible entries (key/url/model alone).

Co-authored-by: Cursor <cursoragent@cursor.com>
Route credentials, relay auth, generators, server settings, and CLI
token persistence through a locked RMW helper; reset Clear form state.

Co-authored-by: Cursor <cursoragent@cursor.com>
Extract withSettingsFileLock for hub+CLI, keep owner-only 0o600
rewrites, and race hub credential updates against CLI-style writers.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ocks

Settings-backed provider credentials now live in an in-memory overlay
(getProviderEnvironment) so tunnel/ACP/Codex children do not inherit them.
Settings file locks record pid+token and only reclaim dead or legacy locks.

Co-authored-by: Cursor <cursoragent@cursor.com>
wx creates the lock path before the owner JSON is visible; unlinking
null owners let a waiter steal a live acquisition and collide on
settings.json.tmp (CI ENOENT). Only reclaim parsed owners with dead PIDs.

Co-authored-by: Cursor <cursoragent@cursor.com>
Stale reclaim renames the sidecar to a unique break path and re-verifies
the expected dead owner before deleting it, so a loser cannot unlink a
successor's live lock. Failed owner writes unlink the wx sidecar.
Reclaim uses a sync owner read so contenders do not all observe one
dead owner across an await and race the exclusive create.

Co-authored-by: Cursor <cursoragent@cursor.com>
Stale reclaim now takes a fixed settings.json.lock.reap lock, re-validates
pid+token, then unlinks — so a delayed contender cannot move a successor's
live lock aside. Also document providerCredentials in settings.schema.json.

Co-authored-by: Cursor <cursoragent@cursor.com>
CLI updateSettings now uses a strict read that rejects invalid JSON
instead of treating errors as {}, which could wipe providerCredentials.
Settings lock reclaim sleeps when another process holds .reap so retries
are not burned synchronously.

Co-authored-by: Cursor <cursoragent@cursor.com>
Acquire settings locks by writing a complete candidate then linkSync to
the fixed path so a crash cannot leave an empty live sidecar. Fix the
CLI persistence regression test to create its temp dir inside vi.hoisted.

Co-authored-by: Cursor <cursoragent@cursor.com>
… creds UI

Codex kept finding crash windows in hand-rolled lock sidecars. Switch the
shared settings lock to proper-lockfile's mkdir + mtime lease. Hide the
owner-only credentials editor from non-default namespaces on the voice page.

Co-authored-by: Cursor <cursoragent@cursor.com>
Rebase onto main brought tiann#1376 unique tmp + outcome-shaped writers;
wire sessionSummaryContract and the write-failure credential test to match.

Co-authored-by: Cursor <cursoragent@cursor.com>
@heavygee
heavygee force-pushed the feat/settings-provider-onboard branch from 37c8980 to da0c6c2 Compare August 6, 2026 20:21
Empty commit — Meta reported no checks on da0c6c2 after tip-forward rebase.

Co-authored-by: Cursor <cursoragent@cursor.com>
@heavygee

heavygee commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

CI kick: no check-suites on tip after rebase (GitHub Actions lag/outage). Reopening to retrigger workflows.

@heavygee heavygee closed this Aug 6, 2026
@heavygee heavygee reopened this Aug 6, 2026
@tiann
tiann merged commit 28df974 into tiann:main Aug 7, 2026
@heavygee
heavygee deleted the feat/settings-provider-onboard branch August 7, 2026 06:03
heavygee added a commit to heavygee/hapi that referenced this pull request Aug 7, 2026
Co-authored-by: Cursor <cursoragent@cursor.com>
heavygee added a commit to heavygee/hapi that referenced this pull request Aug 7, 2026
Tip-forward can land new deps (tiann#1392 proper-lockfile) while live
driver/node_modules stays stale; typecheck ran against DRIVER and rolled back a good tip.

Co-authored-by: Cursor <cursoragent@cursor.com>
heavygee added a commit to heavygee/hapi that referenced this pull request Aug 7, 2026
tiann#1392 upstream take of hub server.ts dropped createFeaturesRoutes; PR chips
depend on GET /api/features. Heal re-applies the mount on remat.

Co-authored-by: Cursor <cursoragent@cursor.com>
heavygee added a commit to heavygee/hapi that referenced this pull request Aug 7, 2026
Same tip-forward tiann#1392 class as features (heal 99). Restores Session Log
and overseer HTTP mounts on remat.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Settings: onboard dictation (and voice) provider credentials in UI

2 participants