Skip to content

feat(connections): resolve connection passwords from file, env, or command (#1254)#1478

Merged
datlechin merged 4 commits into
mainfrom
feat/1254-password-source
May 29, 2026
Merged

feat(connections): resolve connection passwords from file, env, or command (#1254)#1478
datlechin merged 4 commits into
mainfrom
feat/1254-password-source

Conversation

@datlechin
Copy link
Copy Markdown
Member

Closes #1254.

Problem

Connection passwords live only in the Keychain. A script that provisions connections.json (for example, one Docker database per git worktree) can write every field except the password, so the first connect needs a manual password entry.

Solution

A connection can declare a passwordSource that is resolved at connect time, instead of reading the Keychain:

{ "passwordSource": { "kind": "file", "path": "~/.config/tablepro/secrets/feature-x.pw" } }
{ "passwordSource": { "kind": "env", "variable": "STAGING_DB_PASSWORD" } }
{ "passwordSource": { "kind": "command", "shell": "op read op://vault/feature-x/password" } }
  • file: reads the file, trims a trailing newline, warns (does not refuse) on loose permissions.
  • env: reads the named variable. The error names the macOS gotcha that a Dock-launched app does not inherit shell exports.
  • command: runs through /bin/bash, reads stdout, trims a trailing newline. Non-zero exit fails the connection, stderr is surfaced in the error, 30 second timeout, no stdin. Works with op, vault, pass, sops.

When passwordSource is set it replaces the Keychain lookup. On failure the connection reports the error rather than silently falling back.

Design notes

  • New PasswordSource enum (model) with custom Codable that produces the exact kind-tagged JSON. Stored as a readable nested object so a script can write it by hand.
  • New PasswordSourceResolver, modeled on PreConnectHookRunner: off the main thread, both pipes drained to avoid the 64 KiB deadlock, PATH augmented so op/vault resolve under launchd.
  • Hooks into the existing DatabaseDriverFactory.resolvePassword chain, between the password prompt and ~/.pgpass.
  • Not synced to iCloud. The path, variable, or command is device-local, so it is excluded from SyncRecordMapper the same way the SSH and Cloudflare tunnel modes are.
  • Not Pro gated, matching how $VAR substitution and ~/.pgpass already resolve at connect time.
  • Scoped to the internal connections.json file, not the .tablepro share/import format.
  • Feasible because TablePro ships Developer ID with the hardened runtime and no App Sandbox, so spawning a process and reading files needs no entitlement change. If TablePro is ever sandboxed, command and arbitrary-path file must be gated off.

Tests

  • PasswordSourceResolverTests: each source plus error paths (missing file, empty value, loose permissions, trailing newline, non-zero exit, stderr capture, NUL rejection, timeout).
  • PasswordSourceCodableTests: the JSON shape contract and round-trip through DatabaseConnection.

Docs

New "Password Sources" section in docs/features/connection-sharing.mdx.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7a1e6c6031

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +482 to +483
if let passwordSource = connection.passwordSource {
return try await PasswordSourceResolver.resolve(passwordSource)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve password sources through tunnel rewriting

When a connection uses SSH or Cloudflare tunneling, DatabaseManager.tunneledConnection(from:localPort:) rebuilds a DatabaseConnection for 127.0.0.1 before createDriver calls this password lookup, but that rebuilt connection does not carry passwordSource. In that environment this new branch is skipped and the driver falls back to Keychain/empty password, so a connection that works directly with passwordSource fails as soon as a tunnel is enabled; propagate the source onto the tunneled connection before resolving the password.

Useful? React with 👍 / 👎.

localOnly: Bool = false,
isSample: Bool = false,
isFavorite: Bool = false,
passwordSource: PasswordSource? = nil,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve password sources when rebuilding connections

Because this new initializer argument defaults to nil, existing call sites that reconstruct a connection from form state can now silently drop a manually provisioned passwordSource. For example, editing and saving an existing connection in ConnectionFormCoordinator.saveConnection replaces savedConnections[index] with a freshly constructed DatabaseConnection and there is no form field for passwordSource, so the next connect falls back to Keychain/empty password even though the source was present in connections.json; preserve originalConnection?.passwordSource when saving existing connections.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2013836031

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +116 to +117
// passwordSource is also NOT synced: its file path, env var, or command
// is device-local and may not exist or resolve on another Mac.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve local password sources during sync merges

When a Mac already has a connection with a local passwordSource and then receives a CloudKit update for the same connection, this omission means SyncRecordMapper.toConnection creates a remote connection with passwordSource == nil; SyncCoordinator.applyRemoteConnection then replaces the local entry while only copying localOnly. In that iCloud-sync path, any edit from another Mac strips the device-local password source from connections.json, so the next connect falls back to Keychain/empty instead of the configured file/env/command source. Please merge the existing local passwordSource back into the remote record, similar to localOnly, when applying remote updates.

Useful? React with 👍 / 👎.

Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f43176c8b2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +482 to +483
if let passwordSource = connection.passwordSource {
return try await PasswordSourceResolver.resolve(passwordSource)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Resolve sourced passwords for dump commands

This resolves passwordSource only for driver creation, but the PostgreSQL backup/restore path bypasses DatabaseDriverFactory: PostgresDumpService.start still sets PGPASSWORD from the Keychain or session.cachedPassword, and cachedPassword is only populated for prompted passwords. A PostgreSQL/Redshift connection that successfully connects via a file/env/command password will therefore run pg_dump/pg_restore with --no-password and no password, causing backup/restore to fail. Please resolve the same source there or cache the resolved password for these subprocesses.

Useful? React with 👍 / 👎.

guard let contents = try? String(contentsOfFile: expandedPath, encoding: .utf8) else {
throw ResolutionError.fileUnreadable(path: expandedPath)
}
return try nonEmpty(contents.trimmingCharacters(in: .whitespacesAndNewlines))
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve spaces in file-sourced passwords

For file-based password sources, trimming .whitespacesAndNewlines changes the actual secret when a valid database password starts or ends with a space; the docs only promise to trim a trailing newline. In that case the app will connect with a different password than the file contains, so this should only remove line terminators (or at most one final newline) rather than all surrounding whitespace.

Useful? React with 👍 / 👎.

@datlechin datlechin merged commit d8adced into main May 29, 2026
1 check passed
@datlechin datlechin deleted the feat/1254-password-source branch May 29, 2026 06:04
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eb2de910f1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

startupCommands: advanced.startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? nil : advanced.startupCommands,
localOnly: advanced.localOnly,
passwordSource: originalConnection?.passwordSource,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear passwordSource when saving a replacement password

When editing a connection that already has passwordSource, entering a new password or enabling prompt-for-password still saves the old source here. Since DatabaseDriverFactory.resolvePassword checks connection.passwordSource before falling back to the Keychain, the password saved at lines 295-299 is ignored and prompt mode is bypassed for that connection. Preserve the source only when no replacement password/prompt choice was made, as the test path already does.

Useful? React with 👍 / 👎.

Comment on lines +121 to +124
if process.isRunning {
didTimeout.set()
process.terminate()
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Enforce the timeout for TERM-resistant commands

For a password command that traps or ignores SIGTERM, this timeout path sets the flag and calls terminate(), but waitUntilExit() below can still block forever because the process never exits. Since passwordSource.command accepts arbitrary shell snippets and promises a 30-second timeout, a hung or TERM-resistant helper can still hang connection creation indefinitely; kill the process group or escalate to SIGKILL after a grace period.

Useful? React with 👍 / 👎.

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.

Connection CLI w/ password parameter

1 participant