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.
- {sql_harness-0.3.2 → sql_harness-0.3.3}/PKG-INFO +1 -1
- {sql_harness-0.3.2 → sql_harness-0.3.3}/SKILL.md +2 -2
- {sql_harness-0.3.2 → sql_harness-0.3.3}/docs/connections-toml-example.toml +17 -2
- {sql_harness-0.3.2/skills/sql-harness/references → sql_harness-0.3.3/docs}/install.md +12 -2
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/ssh/auth-and-tunnels.md +23 -8
- {sql_harness-0.3.2 → sql_harness-0.3.3}/pyproject.toml +1 -1
- {sql_harness-0.3.2/docs → sql_harness-0.3.3/skills/sql-harness/references}/install.md +12 -2
- {sql_harness-0.3.2 → sql_harness-0.3.3}/sql-harness +1 -1
- {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/cli.py +51 -16
- {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/config.py +15 -2
- {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/drivers/__init__.py +7 -2
- {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/drivers/mysql.py +2 -1
- {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/drivers/postgres.py +2 -1
- {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/drivers/redis.py +1 -1
- {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/drivers/sqlite.py +2 -1
- {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/drivers/ssh.py +68 -17
- {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/manager.py +3 -1
- {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/unit/test_cli.py +111 -1
- {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/unit/test_config.py +58 -0
- sql_harness-0.3.3/tests/unit/test_ssh.py +228 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/uv.lock +1 -1
- sql_harness-0.3.2/.claude-plugin/marketplace.json +0 -26
- sql_harness-0.3.2/.claude-plugin/plugin.json +0 -13
- sql_harness-0.3.2/tests/unit/test_ssh.py +0 -54
- {sql_harness-0.3.2 → sql_harness-0.3.3}/.env.example +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/.github/ISSUE_TEMPLATE/bug-report.yml +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/.github/ISSUE_TEMPLATE/feature-request.yml +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/.gitignore +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/AGENTS.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/agent-workspace/README.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/agent-workspace/agent_helpers.example.py +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/agent-workspace/agent_helpers.py +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/agent-workspace/zones/example_pg/scripts/sh_demo_crud.py +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/agent-workspace/zones/example_pg/skills/sh_demo-schema.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/agent-workspace/zones/meta/skills/pool.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/agent-workspace/zones/meta/skills/workspace.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/docs/AGENTS.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/docs/README.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/docs/headless-notes.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/docs/maintainer.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/docs/standalone-install.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/aggregations-advanced.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/aggregations.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/encoding-and-charset.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/joins-advanced.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/joins.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/large-result-sets.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/migrations.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/mysql/json-columns.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/pooling.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/postgres/btree-indexes.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/postgres/indexes-and-explain.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/postgres/json-columns.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/postgres/migrations.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/postgres/plan-reading.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/postgres/slow-queries-joins.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/postgres/specialized-indexes.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/postgres/table-optimization.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/recursive-ctes-advanced.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/recursive-ctes.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/save-run-cycle.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/schema-introspection.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/ssh/docker-via-ssh.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/ssh/ssh.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/timeouts-and-cancellation.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/transactions.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/window-functions-advanced.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/window-functions.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/interaction-skills/zone-skill-auto-surface.md +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/skills/sql-harness/README.txt +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/__init__.py +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/agent_loader.py +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/analytics.py +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/helpers.py +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/paths.py +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/src/sql_harness/run.py +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/__init__.py +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/conftest.py +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/integration/__init__.py +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/integration/test_mysql.py +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/integration/test_pg_optimization.py +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/integration/test_pgexercises_practice.py +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/integration/test_postgres.py +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/integration/test_ssh.py +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/unit/__init__.py +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/unit/test_agent_loader.py +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/unit/test_analytics.py +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/unit/test_helpers.py +0 -0
- {sql_harness-0.3.2 → sql_harness-0.3.3}/tests/unit/test_manager.py +0 -0
- {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.
|
|
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
|
|
19
|
+
`drivers/ssh.py` 认这三种 URL 形式:
|
|
20
20
|
|
|
21
|
-
| URL 形式 | 何时用 |
|
|
21
|
+
| URL 形式 | 何时用 | 认证方式 |
|
|
22
22
|
|---|---|---|
|
|
23
|
-
| `ssh://user@host:port` | 默认
|
|
24
|
-
| `ssh+key://user@host:port?key=/abs/path` | key 不在默认路径 |
|
|
25
|
-
| `ssh+password://user:pw@host:port` | 跳板 /
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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.
|
|
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"
|
|
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
|
-
|
|
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 +
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
11
|
+
ssh://user@host:port (uses the connection's `password` field, $BH_SSH_PASSWORD, or a default key)
|
|
12
12
|
|
|
13
|
-
|
|
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-
|
|
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
|
|
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
|
-
|
|
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=
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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"(
|
|
264
|
-
f"or $BH_SSH_PASSWORD
|
|
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
|
-
|
|
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
|