sql-harness 0.3.2__tar.gz → 0.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. {sql_harness-0.3.2 → sql_harness-0.3.3}/PKG-INFO +1 -1
  2. {sql_harness-0.3.2 → sql_harness-0.3.3}/SKILL.md +2 -2
  3. {sql_harness-0.3.2 → sql_harness-0.3.3}/docs/connections-toml-example.toml +17 -2
  4. {sql_harness-0.3.2/skills/sql-harness/references → sql_harness-0.3.3/docs}/install.md +12 -2
  5. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/ssh/auth-and-tunnels.md +23 -8
  6. {sql_harness-0.3.2 → sql_harness-0.3.3}/pyproject.toml +1 -1
  7. {sql_harness-0.3.2/docs → sql_harness-0.3.3/skills/sql-harness/references}/install.md +12 -2
  8. {sql_harness-0.3.2 → sql_harness-0.3.3}/sql-harness +1 -1
  9. {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/cli.py +51 -16
  10. {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/config.py +15 -2
  11. {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/drivers/__init__.py +7 -2
  12. {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/drivers/mysql.py +2 -1
  13. {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/drivers/postgres.py +2 -1
  14. {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/drivers/redis.py +1 -1
  15. {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/drivers/sqlite.py +2 -1
  16. {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/drivers/ssh.py +68 -17
  17. {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/manager.py +3 -1
  18. {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/unit/test_cli.py +111 -1
  19. {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/unit/test_config.py +58 -0
  20. sql_harness-0.3.3/tests/unit/test_ssh.py +228 -0
  21. {sql_harness-0.3.2 → sql_harness-0.3.3}/uv.lock +1 -1
  22. sql_harness-0.3.2/.claude-plugin/marketplace.json +0 -26
  23. sql_harness-0.3.2/.claude-plugin/plugin.json +0 -13
  24. sql_harness-0.3.2/tests/unit/test_ssh.py +0 -54
  25. {sql_harness-0.3.2 → sql_harness-0.3.3}/.env.example +0 -0
  26. {sql_harness-0.3.2 → sql_harness-0.3.3}/.github/ISSUE_TEMPLATE/bug-report.yml +0 -0
  27. {sql_harness-0.3.2 → sql_harness-0.3.3}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  28. {sql_harness-0.3.2 → sql_harness-0.3.3}/.github/ISSUE_TEMPLATE/feature-request.yml +0 -0
  29. {sql_harness-0.3.2 → sql_harness-0.3.3}/.gitignore +0 -0
  30. {sql_harness-0.3.2 → sql_harness-0.3.3}/AGENTS.md +0 -0
  31. {sql_harness-0.3.2 → sql_harness-0.3.3}/agent-workspace/README.md +0 -0
  32. {sql_harness-0.3.2 → sql_harness-0.3.3}/agent-workspace/agent_helpers.example.py +0 -0
  33. {sql_harness-0.3.2 → sql_harness-0.3.3}/agent-workspace/agent_helpers.py +0 -0
  34. {sql_harness-0.3.2 → sql_harness-0.3.3}/agent-workspace/zones/example_pg/scripts/sh_demo_crud.py +0 -0
  35. {sql_harness-0.3.2 → sql_harness-0.3.3}/agent-workspace/zones/example_pg/skills/sh_demo-schema.md +0 -0
  36. {sql_harness-0.3.2 → sql_harness-0.3.3}/agent-workspace/zones/meta/skills/pool.md +0 -0
  37. {sql_harness-0.3.2 → sql_harness-0.3.3}/agent-workspace/zones/meta/skills/workspace.md +0 -0
  38. {sql_harness-0.3.2 → sql_harness-0.3.3}/docs/AGENTS.md +0 -0
  39. {sql_harness-0.3.2 → sql_harness-0.3.3}/docs/README.md +0 -0
  40. {sql_harness-0.3.2 → sql_harness-0.3.3}/docs/headless-notes.md +0 -0
  41. {sql_harness-0.3.2 → sql_harness-0.3.3}/docs/maintainer.md +0 -0
  42. {sql_harness-0.3.2 → sql_harness-0.3.3}/docs/standalone-install.md +0 -0
  43. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/aggregations-advanced.md +0 -0
  44. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/aggregations.md +0 -0
  45. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/encoding-and-charset.md +0 -0
  46. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/joins-advanced.md +0 -0
  47. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/joins.md +0 -0
  48. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/large-result-sets.md +0 -0
  49. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/migrations.md +0 -0
  50. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/mysql/json-columns.md +0 -0
  51. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/pooling.md +0 -0
  52. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/postgres/btree-indexes.md +0 -0
  53. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/postgres/indexes-and-explain.md +0 -0
  54. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/postgres/json-columns.md +0 -0
  55. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/postgres/migrations.md +0 -0
  56. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/postgres/plan-reading.md +0 -0
  57. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/postgres/slow-queries-joins.md +0 -0
  58. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/postgres/specialized-indexes.md +0 -0
  59. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/postgres/table-optimization.md +0 -0
  60. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/recursive-ctes-advanced.md +0 -0
  61. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/recursive-ctes.md +0 -0
  62. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/save-run-cycle.md +0 -0
  63. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/schema-introspection.md +0 -0
  64. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/ssh/docker-via-ssh.md +0 -0
  65. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/ssh/ssh.md +0 -0
  66. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/timeouts-and-cancellation.md +0 -0
  67. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/transactions.md +0 -0
  68. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/window-functions-advanced.md +0 -0
  69. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/window-functions.md +0 -0
  70. {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/zone-skill-auto-surface.md +0 -0
  71. {sql_harness-0.3.2 → sql_harness-0.3.3}/skills/sql-harness/README.txt +0 -0
  72. {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/__init__.py +0 -0
  73. {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/agent_loader.py +0 -0
  74. {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/analytics.py +0 -0
  75. {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/helpers.py +0 -0
  76. {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/paths.py +0 -0
  77. {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/run.py +0 -0
  78. {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/__init__.py +0 -0
  79. {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/conftest.py +0 -0
  80. {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/integration/__init__.py +0 -0
  81. {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/integration/test_mysql.py +0 -0
  82. {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/integration/test_pg_optimization.py +0 -0
  83. {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/integration/test_pgexercises_practice.py +0 -0
  84. {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/integration/test_postgres.py +0 -0
  85. {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/integration/test_ssh.py +0 -0
  86. {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/unit/__init__.py +0 -0
  87. {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/unit/test_agent_loader.py +0 -0
  88. {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/unit/test_analytics.py +0 -0
  89. {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/unit/test_helpers.py +0 -0
  90. {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/unit/test_manager.py +0 -0
  91. {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/unit/test_paths.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sql-harness
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Single-process SQL + SSH CLI for LLM agents. PostgreSQL/MySQL/SSH via SQLAlchemy + paramiko. Plaintext credentials in one TOML file. Helpers auto-injected into the heredoc namespace.
5
5
  Project-URL: Source, https://github.com/zhaoliuxue/much_bigpy/tree/master/lab/sql_harness
6
6
  Project-URL: Issues, https://github.com/zhaoliuxue/much_bigpy/issues
@@ -48,7 +48,7 @@ PY
48
48
 
49
49
  ## Drivers & capabilities
50
50
 
51
- Five drivers, one heredoc namespace. URL scheme must match the backend: MySQL requires `mysql+pymysql://`; PostgreSQL accepts `postgres://` / `postgresql://` (normalized to `postgresql+psycopg://`); SSH accepts `ssh://`, `ssh+password://`, `ssh+key://`.
51
+ Five drivers, one heredoc namespace. URL scheme must match the backend: MySQL requires `mysql+pymysql://`; PostgreSQL accepts `postgres://` / `postgresql://` (normalized to `postgresql+psycopg://`); SSH accepts `ssh://`, `ssh+password://`, `ssh+key://`. SSH password auth may come from the connection's standalone `password` key instead of the URL (precedence: URL > `password` > `$BH_SSH_PASSWORD`); a connection that sets a password uses password auth exclusively, ignoring local `~/.ssh` keys.
52
52
 
53
53
  ### Postgres (`postgres://`)
54
54
 
@@ -184,7 +184,7 @@ Reached via `apply_skill("pool")` etc. from any active zone — `zones/meta/skil
184
184
  ## Gotchas
185
185
 
186
186
  - Driver label in TOML must match the URL scheme (`postgres` ⇄ `postgresql://`, `mysql` ⇄ `mysql+pymysql://`, `ssh` ⇄ `ssh://` / `ssh+password://` / `ssh+key://`).
187
- - Passwords in TOML are plaintext; use `${env:VAR}` indirection for prod secrets.
187
+ - Passwords in TOML are plaintext; use `${env:VAR}` indirection for prod secrets. SSH accepts a standalone `password` key (keeps the URL clean); when one is set, local `~/.ssh` keys are ignored.
188
188
  - `query()` is for SELECTs only. For INSERTs, use `execute()`.
189
189
  - For tables > 10k rows, use `engine.connect().execution_options(stream_results=True)` and iterate manually — `query()` loads everything into memory.
190
190
  - SSH SFTP subsystem may be disabled on the remote host. `ssh_upload/ssh_download` raise `RuntimeError("SFTP subsystem not available")`; fall back to `tar -czf - | ssh host tar -xzf -` via `ssh_exec`.
@@ -18,7 +18,9 @@ echo = false
18
18
  # Connections
19
19
  # ---------------------------------------------------------------------------
20
20
  # Each [[connections]] block defines a named profile. Drivers: postgres, mysql,
21
- # redis (stub), sqlite. URL must match the driver.
21
+ # redis (stub), sqlite, ssh / ssh+password / ssh+key. URL must match the driver.
22
+ # Optional keys: description, password, pool_size, pool_recycle, pre_ping, echo,
23
+ # application_name. `${env:VAR}` is expanded in both `url` and `password`.
22
24
 
23
25
  [[connections]]
24
26
  name = "local_pg"
@@ -51,11 +53,24 @@ driver = "ssh"
51
53
  url = "ssh://app@10.0.0.5:2222"
52
54
  description = "Production application host (key auth via $BH_SSH_KEY or ~/.ssh/id_ed25519)"
53
55
 
56
+ # Password auth, credentials kept out of the URL (recommended). Each connection
57
+ # carries its own password, so switching workspaces needs no env fiddling.
58
+ # Precedence: URL-embedded password > this field > $BH_SSH_PASSWORD.
59
+ # Note: a connection with a password uses password auth exclusively — local
60
+ # ~/.ssh keys are not consulted.
61
+ [[connections]]
62
+ name = "iot_server"
63
+ driver = "ssh"
64
+ url = "ssh://113.44.193.72:22"
65
+ password = "${env:IOT_SSH_PASSWORD}"
66
+ description = "IoT host (password auth)"
67
+
68
+ # URL-embedded password also works (driver label may be ssh or ssh+password).
54
69
  [[connections]]
55
70
  name = "lab-server"
56
71
  driver = "ssh+password"
57
72
  url = "ssh+password://lab:lab@192.168.1.10:22"
58
- description = "Lab box (password auth)"
73
+ description = "Lab box (password auth, password in URL)"
59
74
 
60
75
  # ---------------------------------------------------------------------------
61
76
  # Optional per-connection pool overrides (alternative syntax)
@@ -79,9 +79,19 @@ name = "prod_pg"
79
79
  driver = "postgres"
80
80
  url = "${env:PROD_PG_URL}" # env-var indirection
81
81
  pool_size = 10
82
+
83
+ [[connections]]
84
+ name = "iot_server"
85
+ driver = "ssh"
86
+ url = "ssh://113.44.193.72:22"
87
+ password = "${env:IOT_SSH_PASSWORD}" # standalone secret — keeps the URL clean
82
88
  ```
83
89
 
84
- `${env:VAR}` placeholders inside `url` are expanded at load time from the process environment — keep prod secrets out of the file.
90
+ `${env:VAR}` placeholders inside `url` and `password` are expanded at load time from the process environment — keep prod secrets out of the file.
91
+
92
+ Optional per-connection keys: `description`, `password`, `pool_size`, `pool_recycle`, `pre_ping`, `echo`, `application_name`.
93
+
94
+ SSH password precedence (highest first): password embedded in `url` > the `password` key > `$BH_SSH_PASSWORD`. A connection that sets a password uses password auth exclusively — local `~/.ssh` keys are not consulted (see `interaction-skills/ssh/auth-and-tunnels.md`).
85
95
 
86
96
  ## Configuration env vars
87
97
 
@@ -110,7 +120,7 @@ pool_size = 10
110
120
  | `ssh` / `ssh+password` / `ssh+key` | remote shell + SFTP (paramiko) | `paramiko>=3.4` | ✅ |
111
121
 
112
122
  To enable Redis: `uv add "redis>=5.0,<6"` then implement `drivers/redis.py`.
113
- To add another SSH host: append a `[[connections]]` block with `driver = "ssh"` and an `ssh://...` URL.
123
+ To add another SSH host: append a `[[connections]]` block with `driver = "ssh"` and an `ssh://...` URL. Use the `password` key for password auth, or `?key=/abs/path` for a key outside the default locations. `sql-harness test <name>` probes SSH with a remote `echo` instead of `SELECT 1`.
114
124
 
115
125
  ## Troubleshooting
116
126
 
@@ -16,15 +16,29 @@
16
16
 
17
17
  ### 1.1 三种 scheme 对照
18
18
 
19
- `drivers/ssh.py:_parse_url` 支持的三种 URL 形式:
19
+ `drivers/ssh.py` 认这三种 URL 形式:
20
20
 
21
- | URL 形式 | 何时用 | key 顺序 |
21
+ | URL 形式 | 何时用 | 认证方式 |
22
22
  |---|---|---|
23
- | `ssh://user@host:port` | 默认 key auth | `?key=...``$BH_SSH_KEY` → `~/.ssh/id_ed25519` `id_rsa` → `id_ecdsa`(`KEY_FALLBACK`) |
24
- | `ssh+key://user@host:port?key=/abs/path` | key 不在默认路径 | URL 里的 `?key=` 单独覆盖 |
25
- | `ssh+password://user:pw@host:port` | 跳板 / 临时主机,没配 key | URL 嵌入密码;也可用 `$BH_SSH_PASSWORD` |
23
+ | `ssh://user@host:port` | 默认 | 显式 key(`?key=`/`$BH_SSH_KEY`)→ key;否则配了密码纯密码;都没有自动找 `~/.ssh` key |
24
+ | `ssh+key://user@host:port?key=/abs/path` | key 不在默认路径 | key 认证,密码作后备 |
25
+ | `ssh+password://user[:pw]@host:port` | 跳板 / 临时主机 | 纯密码认证;密码可嵌 URL,也可放字段/env |
26
26
 
27
- **注意**:`ssh+password` 要求密码嵌入 URL。不想明文落 TOML,把密码放 `$BH_SSH_PASSWORD` 并用 `ssh://user@host`,但驱动**不会**自动回退到 env 密码(仅 key 走 env fallback)。这是常见混淆点。
27
+ **密码来源优先级**(高 低):URL 内嵌 > 连接里的 `password` 字段 > `$BH_SSH_PASSWORD`。
28
+
29
+ **`password` 字段**(推荐——密码按连接存,切 workspace 不用动环境变量):
30
+
31
+ ```toml
32
+ [[connections]]
33
+ name = "iot_server"
34
+ driver = "ssh"
35
+ url = "ssh://113.44.193.72:22"
36
+ password = "${env:IOT_SSH_PASSWORD}" # 也可直接写明文
37
+ ```
38
+
39
+ **认证策略**(`_open_client`):显式给 key(`?key=`/`$BH_SSH_KEY`)→ key 优先、密码兜底;没给 key 但配了密码 → **纯密码**,不翻 `~/.ssh`;都没有 → 自动发现 `~/.ssh/id_{ed25519,rsa,ecdsa}`。
40
+
41
+ > 第三条很关键:paramiko 开着 `look_for_keys` 会逐个试 `~/.ssh` 里的 key,只要里面有一把解析不了的(旧 DSA key 是常见雷),认证线程直接抛异常、**在密码之前**就中断握手。所以配了密码就不会被本地散装 key 劫持。
28
42
 
29
43
  ### 1.2 用哪个连接串
30
44
 
@@ -32,7 +46,7 @@
32
46
  有 ed25519 key 在 ~/.ssh/ 且 authorized_keys 已加?
33
47
  ├─ 是 → ssh://user@host (默认 key path)
34
48
  └─ 否:
35
- 有密码但没 key? → ssh+password://user:pw@host
49
+ 有密码?连接里加 password = "..."(driver 用 ssh 即可)
36
50
  key 在非默认路径? → ssh://user@host?key=/path/to/k
37
51
  key 在 ssh-agent? → §1.3
38
52
  ```
@@ -131,7 +145,8 @@ sql-harness 不内置 SSH SOCKS 代理;本机 `psql`/`TablePlus` 直连躲在
131
145
  ## Gotchas
132
146
 
133
147
  - **paramiko 不会自动用 ssh-agent**——见 §1.3。
134
- - **`ssh+password://` 必须嵌入密码**——`BH_SSH_PASSWORD` env 不会回退(仅 key env fallback)。
148
+ - **密码优先级**:URL 内嵌 > `password` 字段 > `$BH_SSH_PASSWORD`。配了密码即纯密码认证,本地 key 不再参与(见 §1.1)。
149
+ - **服务端禁密码登录**:报 `Authentication failed` 时先确认服务端开了 `PasswordAuthentication`,别急着改本地配置。
135
150
  - **堡垒机转发时 `ssh -fN` 在某些堡垒机受限**——`AllowTcpForwarding no`。失败就 §2.2 走 paramiko `request_port_forward`。
136
151
  - **`request_port_forward` 返回的对象必须在 finally 里 cancel**,否则下次 `use_workspace` 复用 transport 报端口占用。
137
152
  - **workspace disconnect 不自动清 tunnel**——硬规矩:tunnel 用 try/finally 包起来。
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sql-harness"
3
- version = "0.3.2"
3
+ version = "0.3.3"
4
4
  description = "Single-process SQL + SSH CLI for LLM agents. PostgreSQL/MySQL/SSH via SQLAlchemy + paramiko. Plaintext credentials in one TOML file. Helpers auto-injected into the heredoc namespace."
5
5
  readme = "docs/README.md"
6
6
  license = { text = "MIT" }
@@ -79,9 +79,19 @@ name = "prod_pg"
79
79
  driver = "postgres"
80
80
  url = "${env:PROD_PG_URL}" # env-var indirection
81
81
  pool_size = 10
82
+
83
+ [[connections]]
84
+ name = "iot_server"
85
+ driver = "ssh"
86
+ url = "ssh://113.44.193.72:22"
87
+ password = "${env:IOT_SSH_PASSWORD}" # standalone secret — keeps the URL clean
82
88
  ```
83
89
 
84
- `${env:VAR}` placeholders inside `url` are expanded at load time from the process environment — keep prod secrets out of the file.
90
+ `${env:VAR}` placeholders inside `url` and `password` are expanded at load time from the process environment — keep prod secrets out of the file.
91
+
92
+ Optional per-connection keys: `description`, `password`, `pool_size`, `pool_recycle`, `pre_ping`, `echo`, `application_name`.
93
+
94
+ SSH password precedence (highest first): password embedded in `url` > the `password` key > `$BH_SSH_PASSWORD`. A connection that sets a password uses password auth exclusively — local `~/.ssh` keys are not consulted (see `interaction-skills/ssh/auth-and-tunnels.md`).
85
95
 
86
96
  ## Configuration env vars
87
97
 
@@ -110,7 +120,7 @@ pool_size = 10
110
120
  | `ssh` / `ssh+password` / `ssh+key` | remote shell + SFTP (paramiko) | `paramiko>=3.4` | ✅ |
111
121
 
112
122
  To enable Redis: `uv add "redis>=5.0,<6"` then implement `drivers/redis.py`.
113
- To add another SSH host: append a `[[connections]]` block with `driver = "ssh"` and an `ssh://...` URL.
123
+ To add another SSH host: append a `[[connections]]` block with `driver = "ssh"` and an `ssh://...` URL. Use the `password` key for password auth, or `?key=/abs/path` for a key outside the default locations. `sql-harness test <name>` probes SSH with a remote `echo` instead of `SELECT 1`.
114
124
 
115
125
  ## Troubleshooting
116
126
 
@@ -16,7 +16,7 @@ set -euo pipefail
16
16
  # browser-harness keeps the launcher at repo root; sql-harness's "repo root" is lab/sql_harness/).
17
17
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18
18
  ROOT="$SCRIPT_DIR"
19
- SRC="$ROOT/src"r
19
+ SRC="$ROOT/src"
20
20
 
21
21
  # Isolate dev state.
22
22
  export BH_SQL_HOME="$ROOT/.sql-harness-dev"
@@ -83,6 +83,7 @@ def cmd_list(_args, harness: SqlHarness) -> int:
83
83
  "driver": c.driver,
84
84
  "url": c.masked_url(),
85
85
  "description": c.description,
86
+ "password_set": bool(c.password),
86
87
  "pool_size": c.pool.size,
87
88
  })
88
89
  _emit({"default_workspace": cfg.default_workspace, "connections": rows})
@@ -111,6 +112,7 @@ def cmd_add(args, harness: SqlHarness) -> int:
111
112
  return 2
112
113
 
113
114
  description = args.description or ""
115
+ password = getattr(args, "password", None) or ""
114
116
  pool = PoolConfig(size=args.pool_size or cfg.pool_defaults.size)
115
117
 
116
118
  cfg.connections.append(
@@ -119,11 +121,12 @@ def cmd_add(args, harness: SqlHarness) -> int:
119
121
  driver=driver,
120
122
  url=url,
121
123
  description=description,
124
+ password=password,
122
125
  pool=pool,
123
126
  )
124
127
  )
125
128
  save_config(cfg, config_file())
126
- _emit({"added": name, "driver": driver, "url": _mask(url)})
129
+ _emit({"added": name, "driver": driver, "url": _mask(url), "password_set": bool(password)})
127
130
  return 0
128
131
 
129
132
 
@@ -171,6 +174,8 @@ def cmd_show(args, harness: SqlHarness) -> int:
171
174
  "driver": c.driver,
172
175
  "url": c.masked_url(),
173
176
  "description": c.description,
177
+ "password_set": bool(c.password),
178
+ "password": c.masked_password(),
174
179
  "pool": {
175
180
  "size": c.pool.size,
176
181
  "recycle": c.pool.recycle,
@@ -182,6 +187,34 @@ def cmd_show(args, harness: SqlHarness) -> int:
182
187
  return 0
183
188
 
184
189
 
190
+ def _probe_workspace(ws) -> dict:
191
+ """Liveness-probe an open workspace.
192
+
193
+ SQL workspaces answer `SELECT 1`; SSH workspaces expose no `.engine.connect()`,
194
+ so they're probed with a remote `echo`. Returns a result dict whose `ok` flag
195
+ is the verdict (raises only on transport/library errors).
196
+ """
197
+ t0 = time.monotonic()
198
+ if ws.driver.name == "ssh":
199
+ result = ws.engine.exec("echo sql-harness-ok")
200
+ return {
201
+ "ok": result.ok,
202
+ "probe": result.command,
203
+ "stdout": result.stdout.strip(),
204
+ "stderr": result.stderr.strip(),
205
+ "exit_code": result.exit_code,
206
+ "sftp_available": ws.engine.sftp is not None,
207
+ "latency_ms": round((time.monotonic() - t0) * 1000, 2),
208
+ }
209
+ with ws.engine.connect() as conn:
210
+ row = conn.execute(text("SELECT 1")).scalar()
211
+ return {
212
+ "ok": True,
213
+ "select_1": row,
214
+ "latency_ms": round((time.monotonic() - t0) * 1000, 2),
215
+ }
216
+
217
+
185
218
  def cmd_test(args, harness: SqlHarness) -> int:
186
219
  name = args.name
187
220
  try:
@@ -193,16 +226,13 @@ def cmd_test(args, harness: SqlHarness) -> int:
193
226
  _emit({"name": name, "ok": False, "error": str(e)})
194
227
  return 1
195
228
 
196
- t0 = time.monotonic()
197
229
  try:
198
- with ws.engine.connect() as conn:
199
- row = conn.execute(text("SELECT 1")).scalar()
200
- dt = (time.monotonic() - t0) * 1000
201
- _emit({"name": name, "ok": True, "select_1": row, "latency_ms": round(dt, 2)})
202
- return 0
230
+ result = _probe_workspace(ws)
203
231
  except Exception as e:
204
232
  _emit({"name": name, "ok": False, "error": str(e)})
205
233
  return 1
234
+ _emit({"name": name, "driver": ws.driver.name, **result})
235
+ return 0 if result["ok"] else 1
206
236
 
207
237
 
208
238
  def cmd_workspace(args, harness: SqlHarness) -> int:
@@ -528,18 +558,14 @@ def cmd_version(_args, _harness: SqlHarness) -> int:
528
558
 
529
559
 
530
560
  def cmd_doctor(_args, harness: SqlHarness) -> int:
531
- """Sanity-check: list connections + try SELECT 1 on each."""
561
+ """Sanity-check: list connections + probe each one."""
532
562
  rows = []
533
563
  for c in harness.config.connections:
534
564
  try:
535
565
  ws = harness.workspace(c.name)
536
- t0 = time.monotonic()
537
- with ws.engine.connect() as conn:
538
- conn.execute(text("SELECT 1")).scalar()
539
- dt = (time.monotonic() - t0) * 1000
540
- rows.append({"name": c.name, "ok": True, "latency_ms": round(dt, 2)})
566
+ rows.append({"name": c.name, "driver": c.driver, **_probe_workspace(ws)})
541
567
  except Exception as e:
542
- rows.append({"name": c.name, "ok": False, "error": str(e)})
568
+ rows.append({"name": c.name, "driver": c.driver, "ok": False, "error": str(e)})
543
569
  _emit({"results": rows})
544
570
  return 0 if all(r["ok"] for r in rows) else 1
545
571
 
@@ -870,9 +896,16 @@ def _build_parser() -> argparse.ArgumentParser:
870
896
 
871
897
  p_add = sub.add_parser("add", help="add a connection")
872
898
  p_add.add_argument("--name")
873
- p_add.add_argument("--driver", choices=["postgres", "mysql", "redis", "sqlite", "ssh"])
899
+ p_add.add_argument(
900
+ "--driver",
901
+ choices=["postgres", "mysql", "redis", "sqlite", "ssh", "ssh+password", "ssh+key"],
902
+ )
874
903
  p_add.add_argument("--url")
875
904
  p_add.add_argument("--description")
905
+ p_add.add_argument(
906
+ "--password",
907
+ help="standalone secret (SSH password); keeps the URL credential-free",
908
+ )
876
909
  p_add.add_argument("--pool-size", type=int)
877
910
 
878
911
  p_edit = sub.add_parser("edit", help="edit connections.toml in $EDITOR")
@@ -884,7 +917,9 @@ def _build_parser() -> argparse.ArgumentParser:
884
917
  p_show = sub.add_parser("show", help="show connection details (password masked)")
885
918
  p_show.add_argument("name")
886
919
 
887
- p_test = sub.add_parser("test", help="connect + SELECT 1")
920
+ p_test = sub.add_parser(
921
+ "test", help="connect + probe (SELECT 1 for SQL, remote echo for SSH)"
922
+ )
888
923
  p_test.add_argument("name")
889
924
 
890
925
  p_ws = sub.add_parser("workspace", help="manage workspaces")
@@ -14,13 +14,18 @@ Schema (lives at ~/.config/sql-harness/connections.toml by default):
14
14
  driver = "postgres"
15
15
  url = "postgresql+psycopg://user:pw@host:5432/db"
16
16
  description = "..."
17
+ password = "${env:PG_PASSWORD}" # optional; overrides URL password
17
18
  pool_size = 5
18
19
  pool_recycle = 3600
19
20
  pre_ping = true
20
21
  echo = false
21
22
  application_name = "sql-harness"
22
23
 
23
- ${env:VAR} in `url` is expanded from the process environment.
24
+ ${env:VAR} in `url` and in `password` is expanded from the process environment.
25
+
26
+ `password` is a standalone secret for drivers that take one separately from the
27
+ URL (currently SSH). Precedence, highest first: password embedded in `url` >
28
+ this field > driver-specific env var (SSH: $BH_SSH_PASSWORD).
24
29
  """
25
30
 
26
31
  from __future__ import annotations
@@ -60,9 +65,10 @@ class ConnectionConfig:
60
65
  """A single named connection profile."""
61
66
 
62
67
  name: str
63
- driver: str # postgres | mysql | redis
68
+ driver: str # postgres | mysql | redis | ssh
64
69
  url: str
65
70
  description: str = ""
71
+ password: str = "" # standalone secret (SSH password)
66
72
  pool: PoolConfig = field(default_factory=PoolConfig)
67
73
  application_name: str = "sql-harness"
68
74
 
@@ -70,6 +76,10 @@ class ConnectionConfig:
70
76
  """Return the URL with password redacted."""
71
77
  return _ENV_RE.sub(lambda m: os.environ.get(m.group(1), ""), _mask_password(self.url))
72
78
 
79
+ def masked_password(self) -> str:
80
+ """Return the standalone password redacted (empty if unset)."""
81
+ return "***" if self.password else ""
82
+
73
83
 
74
84
  @dataclass
75
85
  class ConnectionsConfig:
@@ -149,6 +159,8 @@ def save(cfg: ConnectionsConfig, path: Path) -> None:
149
159
  lines.append(f'url = "{_toml_str(c.url)}"')
150
160
  if c.description:
151
161
  lines.append(f'description = "{_toml_str(c.description)}"')
162
+ if c.password:
163
+ lines.append(f'password = "{_toml_str(c.password)}"')
152
164
  if c.pool.size != cfg.pool_defaults.size:
153
165
  lines.append(f"pool_size = {c.pool.size}")
154
166
  if c.pool.recycle != cfg.pool_defaults.recycle:
@@ -205,6 +217,7 @@ def _from_dict(raw: dict[str, Any]) -> ConnectionsConfig:
205
217
  driver=str(entry["driver"]),
206
218
  url=_expand_env(str(entry["url"])),
207
219
  description=str(entry.get("description", "")),
220
+ password=_expand_env(str(entry.get("password", ""))),
208
221
  pool=pool,
209
222
  application_name=str(entry.get("application_name", "sql-harness")),
210
223
  )
@@ -17,8 +17,13 @@ class Driver(Protocol):
17
17
 
18
18
  name: str
19
19
 
20
- def make_engine(self, url: str, pool: "PoolConfig") -> Engine:
21
- """Create a SQLAlchemy Engine with the given pool config."""
20
+ def make_engine(self, url: str, pool: "PoolConfig", password: str | None = None) -> Engine:
21
+ """Create a SQLAlchemy Engine with the given pool config.
22
+
23
+ `password` is the connection's standalone secret (ConnectionConfig.password).
24
+ Drivers whose URL already carries credentials (SQL backends) ignore it;
25
+ SSH uses it when no password is embedded in the URL.
26
+ """
22
27
 
23
28
  def list_tables(self, engine: Engine, schema: str | None) -> list[str]:
24
29
  """Return table names (and views) visible in the given schema."""
@@ -8,7 +8,8 @@ from sqlalchemy import Engine, create_engine, inspect, text
8
8
  class MysqlDriver:
9
9
  name = "mysql"
10
10
 
11
- def make_engine(self, url: str, pool) -> Engine:
11
+ def make_engine(self, url: str, pool, password: str | None = None) -> Engine:
12
+ # `password` is ignored: credentials live in the URL for SQL backends.
12
13
  if not url.startswith("mysql+pymysql://"):
13
14
  raise ValueError(
14
15
  f"MySQL URL must start with mysql+pymysql:// (got {url[:30]!r})"
@@ -8,7 +8,8 @@ from sqlalchemy import Engine, create_engine, inspect, text
8
8
  class PostgresDriver:
9
9
  name = "postgres"
10
10
 
11
- def make_engine(self, url: str, pool) -> Engine:
11
+ def make_engine(self, url: str, pool, password: str | None = None) -> Engine:
12
+ # `password` is ignored: credentials live in the URL for SQL backends.
12
13
  # Accept all three SQLAlchemy-recognized PostgreSQL URL prefixes:
13
14
  # postgresql+psycopg:// (explicit dialect — preferred)
14
15
  # postgresql:// (SQLAlchemy maps to default psycopg driver)
@@ -17,7 +17,7 @@ from sqlalchemy import Engine
17
17
  class RedisDriver:
18
18
  name = "redis"
19
19
 
20
- def make_engine(self, url: str, pool) -> Engine:
20
+ def make_engine(self, url: str, pool, password: str | None = None) -> Engine:
21
21
  raise NotImplementedError(
22
22
  "Redis driver is not yet implemented. "
23
23
  "Add redis-py to deps and fill in drivers/redis.py."
@@ -12,8 +12,9 @@ from sqlalchemy import Engine, create_engine, inspect
12
12
  class SqliteDriver:
13
13
  name = "sqlite"
14
14
 
15
- def make_engine(self, url: str, pool) -> Engine:
15
+ def make_engine(self, url: str, pool, password: str | None = None) -> Engine:
16
16
  # SQLite ignores most pool settings; StaticPool is fine for in-memory.
17
+ # `password` is ignored too — SQLite has no auth.
17
18
  if not url.startswith("sqlite:"):
18
19
  raise ValueError(f"SQLite URL must start with sqlite: (got {url[:30]!r})")
19
20
  return create_engine(url, future=True)
@@ -6,14 +6,27 @@ lazy open, cached for the process lifetime, disposed on close.
6
6
 
7
7
  URL format:
8
8
  ssh://user[:password]@host[:port][?key=/path/to/private_key]
9
- ssh+password://user:password@host:port
9
+ ssh+password://user[:password]@host:port
10
10
  ssh+key://user@host:port?key=/path/to/private_key
11
- ssh://user@host:port (uses BH_SSH_KEY env or ~/.ssh/id_rsa default)
11
+ ssh://user@host:port (uses the connection's `password` field, $BH_SSH_PASSWORD, or a default key)
12
12
 
13
- If `?key=...` is omitted, paramiko falls back to:
13
+ Password precedence (highest first):
14
+ 1. password embedded in the URL (ssh://user:pw@host)
15
+ 2. the connection's `password` field (connections.toml: password = "...")
16
+ 3. $BH_SSH_PASSWORD
17
+
18
+ Key resolution (when `?key=...` is absent):
14
19
  1. $BH_SSH_KEY env var
15
- 2. ~/.ssh/id_rsa
16
- 3. password-less (no auth — likely to fail)
20
+ 2. ~/.ssh/id_ed25519, id_rsa, id_ecdsa (skipped when a password is set)
21
+ 3. none — password-only auth
22
+
23
+ Which auth wins is decided by what the connection states explicitly:
24
+ - an explicit key (?key= / $BH_SSH_KEY) → key auth, password as fallback
25
+ - otherwise, a password → password-only auth
26
+ - otherwise → auto-discovered ~/.ssh key
27
+
28
+ So each connection carries its own credential and switching workspaces needs no
29
+ env fiddling; a configured password is never shadowed by an unrelated key.
17
30
 
18
31
  Use the `Workspace.client` attribute (and `.sftp`) for shell + file ops.
19
32
  """
@@ -24,6 +37,7 @@ import os
24
37
  import shlex
25
38
  import time
26
39
  from dataclasses import dataclass
40
+ from pathlib import Path
27
41
  from typing import Any
28
42
 
29
43
  import paramiko
@@ -69,18 +83,26 @@ class SshDriver:
69
83
 
70
84
  name = "ssh"
71
85
 
72
- def make_engine(self, url: str, pool): # noqa: D401 mirror DB Driver API
86
+ def make_engine(self, url: str, pool, password: str | None = None):
73
87
  """Return a live `SshClient` (opened). Caller owns it.
74
88
 
89
+ `password` is the connection's standalone secret
90
+ (ConnectionConfig.password), so each connection carries its own
91
+ credentials and switching workspaces needs no env fiddling.
92
+ Precedence: URL-embedded > `password` > $BH_SSH_PASSWORD.
93
+
75
94
  PoolConfig is ignored for SSH (no pooling). We accept it for API parity
76
95
  with DB drivers.
77
96
  """
78
- host, port, username, password, key_path = _parse_url(url)
97
+ fallback = password or os.environ.get(PASSWORD_ENV)
98
+ host, port, username, resolved_password, key_path = _parse_url(
99
+ url, fallback_password=fallback
100
+ )
79
101
  client = _open_client(
80
102
  host=host,
81
103
  port=port,
82
104
  username=username,
83
- password=password,
105
+ password=resolved_password,
84
106
  key_path=key_path,
85
107
  )
86
108
  # Attach a long-lived shell channel (paramiko's transport keeps it
@@ -172,13 +194,19 @@ class _SshHandle:
172
194
  # --- URL parsing + connection bootstrap ----------------------------------
173
195
 
174
196
 
175
- def _parse_url(url: str) -> tuple[str, int, str, str | None, str | None]:
197
+ def _parse_url(
198
+ url: str, fallback_password: str | None = None
199
+ ) -> tuple[str, int, str, str | None, str | None]:
176
200
  """Extract (host, port, username, password, key_path) from ssh:// URL.
177
201
 
202
+ `fallback_password` supplies the password when the URL carries none —
203
+ callers pass the connection's `password` field (then $BH_SSH_PASSWORD),
204
+ so a URL can stay credential-free while still being password-authenticated.
205
+
178
206
  Accepts:
179
207
  ssh://user:pw@host:port?key=/path
180
- ssh://user@host:port (auth from env / default key)
181
- ssh+password://user:pw@host:port
208
+ ssh://user@host:port (auth from fallback_password / default key)
209
+ ssh+password://user[:pw]@host:port
182
210
  ssh+key://user@host:port?key=...
183
211
  """
184
212
  from urllib.parse import urlparse, parse_qs
@@ -192,7 +220,7 @@ def _parse_url(url: str) -> tuple[str, int, str, str | None, str | None]:
192
220
  raise ValueError(f"missing host in URL: {url!r}")
193
221
  port = p.port or SSH_DEFAULT_PORT
194
222
  username = p.username or "root"
195
- password = p.password
223
+ password = p.password or fallback_password
196
224
  query = parse_qs(p.query)
197
225
  key_path = query.get("key", [None])[0]
198
226
  # Scheme hints
@@ -228,13 +256,35 @@ def _resolve_key_path(explicit: str | None) -> str | None:
228
256
  def _open_client(
229
257
  *, host: str, port: int, username: str, password: str | None, key_path: str | None
230
258
  ) -> paramiko.SSHClient:
231
- """Open a paramiko SSHClient, with policy that trusts the user's known_hosts."""
259
+ """Open a paramiko SSHClient, with policy that trusts the user's known_hosts.
260
+
261
+ `password` must already be resolved (URL > connection `password` > env) —
262
+ this function does no further fallback.
263
+
264
+ Auth policy, in order of intent:
265
+ - explicit key (?key= / $BH_SSH_KEY) → key auth, password as fallback
266
+ - else, a password is set → password-only, ~/.ssh keys ignored
267
+ - else → auto-discover ~/.ssh/{ed25519,rsa,ecdsa}
268
+
269
+ That middle rule matters: with look_for_keys enabled paramiko walks every key
270
+ in ~/.ssh, and one unparseable key there (a legacy DSA key, say) raises out of
271
+ the transport thread and aborts the handshake before the password is ever
272
+ tried. A connection that names a password gets password auth, full stop.
273
+ """
232
274
  client = paramiko.SSHClient()
233
275
  # Auto-add host keys (like `ssh` does on first connect, when
234
276
  # StrictHostKeyChecking=accept-new is set).
235
277
  client.load_system_host_keys()
236
278
  client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
237
- key_file = _resolve_key_path(key_path)
279
+
280
+ explicit_key = key_path or os.environ.get(KEY_ENV)
281
+ if explicit_key:
282
+ key_file: str | None = _resolve_key_path(explicit_key)
283
+ elif password:
284
+ key_file = None
285
+ else:
286
+ key_file = _resolve_key_path(None)
287
+
238
288
  try:
239
289
  if key_file is not None:
240
290
  client.connect(
@@ -252,7 +302,7 @@ def _open_client(
252
302
  hostname=host,
253
303
  port=port,
254
304
  username=username,
255
- password=password or os.environ.get(PASSWORD_ENV),
305
+ password=password,
256
306
  timeout=15.0,
257
307
  allow_agent=False,
258
308
  look_for_keys=False,
@@ -260,8 +310,9 @@ def _open_client(
260
310
  except paramiko.AuthenticationException as e:
261
311
  raise RuntimeError(
262
312
  f"SSH auth failed for {username}@{host}:{port}: {e}\n"
263
- f"(provide ?key= or set $BH_SSH_KEY for key auth, "
264
- f"or $BH_SSH_PASSWORD / embed password in URL for password auth)"
313
+ f"(set `password` on the connection in connections.toml, embed it in "
314
+ f"the URL, or set $BH_SSH_PASSWORD for password auth; "
315
+ f"provide ?key= or set $BH_SSH_KEY for key auth)"
265
316
  ) from e
266
317
  return client
267
318
 
@@ -52,7 +52,9 @@ class SqlHarness:
52
52
  return self._workspaces[name]
53
53
  conn = self.config.get(name)
54
54
  driver = get_driver(conn.driver)
55
- engine = driver.make_engine(conn.url, conn.pool)
55
+ # `password` is the connection's standalone secret; SQL drivers ignore
56
+ # it (their URL carries credentials), SSH uses it when the URL doesn't.
57
+ engine = driver.make_engine(conn.url, conn.pool, conn.password)
56
58
  ws = Workspace(WorkspaceConfig(conn), engine, driver)
57
59
  self._workspaces[name] = ws
58
60
  return ws