Skip to content

Commit 799ea12

Browse files
committed
fix(evaluation): validate OceanBase targets and resume identity
1 parent 828ad2a commit 799ea12

7 files changed

Lines changed: 370 additions & 19 deletions

File tree

‎evaluation/coding/swebench_pro/README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ processing configuration in a database-wide manifest, so separate Scopes in the
105105
isolate these configurations. The runner rejects OFF/ON configurations that point to the same OceanBase host,
106106
port, and database, even when they use different credentials.
107107

108+
OceanBase URLs must put credentials and the host/port in the authority and the database name in the path.
109+
Query options such as `charset=utf8mb4` remain supported. The runner rejects query parameters that override
110+
connection identity (`host`, `port`, `user`, `username`, `password`, `passwd`, `db`, `database`, `unix_socket`),
111+
load external connection defaults (`read_default_file`, `read_default_group`), or run initialization SQL
112+
(`init_command`, `sql_mode`). Query keys are checked after URL decoding, regardless of case. Percent-encode
113+
`@` inside passwords and any `#`, tab, carriage return, or newline characters because URL parsers interpret
114+
their raw forms differently. Usernames such as `user@tenant` remain supported.
115+
108116
When deriving the configurations from `.env`, serialize each actual URL, not the masked `SecretStr`
109117
representation, and replace its database name with the corresponding newly created evaluation database. Keep the
110118
file private (mode `0600`) and delete it after the run. The runner does not create or drop external databases.

‎evaluation/coding/swebench_pro/src/powercontext_eval_swebench_pro/powercontext_sut.py‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
from pathlib import Path
5353
from types import MappingProxyType
5454
from typing import Any, Protocol
55-
from urllib.parse import quote, quote_plus, unquote, urlsplit
55+
from urllib.parse import parse_qsl, quote, quote_plus, unquote, urlsplit
5656

5757
from powercontext_eval_swebench_pro import docker_pressure
5858
from powercontext_eval_swebench_pro.artifacts import ArtifactStore
@@ -89,6 +89,23 @@
8989
PLUGIN_ID = "powercontext@powercontext"
9090
_SAFE_RUN_ID = re.compile(r"[a-z0-9][a-z0-9-]{0,62}")
9191
_SAFE_DOCKER_NETWORK = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}")
92+
_OCEANBASE_QUERY_OVERRIDES = frozenset(
93+
{
94+
"host",
95+
"port",
96+
"user",
97+
"username",
98+
"password",
99+
"passwd",
100+
"db",
101+
"database",
102+
"unix_socket",
103+
"read_default_file",
104+
"read_default_group",
105+
"init_command",
106+
"sql_mode",
107+
}
108+
)
92109
_SHA = re.compile(r"[0-9a-f]{40}")
93110
_INVALID_DOCKER_COPY_SYMLINK = re.compile(r'invalid symlink "[^"\r\n]+" -> "[^"\r\n]+"')
94111
_DOCKER_NETWORK_CONTROL_LOCK = threading.Lock()
@@ -2940,8 +2957,24 @@ def validated_database_config(config: Mapping[str, object] | None) -> Mapping[st
29402957
raise UnsafeSutConfiguration("Evaluation database configuration is invalid")
29412958
try:
29422959
json.dumps(dict(config), allow_nan=False)
2943-
if kind == "oceanbase" and (not urlsplit(url).hostname or not urlsplit(url).path.strip("/")):
2944-
raise ValueError
2960+
if kind == "oceanbase":
2961+
parsed = urlsplit(url)
2962+
# Keep urllib's authority/query interpretation aligned with the
2963+
# database driver's URL parser; encoded password bytes are safe.
2964+
if (
2965+
not parsed.hostname
2966+
or not parsed.path.strip("/")
2967+
or any(character in url for character in "#\r\n\t")
2968+
or "@" in (parsed.password or "")
2969+
):
2970+
raise ValueError
2971+
# Query overrides, option files and initial SQL can change the
2972+
# credentials or target beyond the authority/path we audit.
2973+
if any(
2974+
key.casefold() in _OCEANBASE_QUERY_OVERRIDES
2975+
for key, _ in parse_qsl(parsed.query, keep_blank_values=True)
2976+
):
2977+
raise ValueError
29452978
except (TypeError, ValueError):
29462979
raise UnsafeSutConfiguration("Evaluation database configuration is invalid") from None
29472980
return MappingProxyType(dict(config))

‎evaluation/coding/swebench_pro/tests/unit/test_cli.py‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,22 @@
3131
from powercontext_eval_swebench_pro.runner import MinimalRunResult, RunConfig
3232

3333

34-
def test_swebench_pro_run_derives_portable_paths_from_explicit_root(monkeypatch, tmp_path: Path) -> None:
34+
@pytest.mark.parametrize(
35+
("username", "password"),
36+
[("eval", "private-test-password"), ("eval@tenant", "private-test-password%40%23%0A%0D%09")],
37+
)
38+
def test_swebench_pro_run_derives_portable_paths_from_explicit_root(
39+
monkeypatch, tmp_path: Path, username: str, password: str
40+
) -> None:
3541
calls: list[tuple[object, object]] = []
3642
instance = object()
3743
databases = {
3844
arm: {
3945
"kind": "oceanbase",
40-
"url": f"mysql+aoceanbase://eval:private-test-password@localhost/test_{arm.value}",
46+
"url": (
47+
f"mysql+aoceanbase://{username}:{password}@localhost/test_{arm.value}"
48+
"?charset=utf8mb4&connect_timeout=10"
49+
),
4150
}
4251
for arm in Arm
4352
}
@@ -120,6 +129,33 @@ def require(self, instance_id: str) -> object:
120129
},
121130
}
122131
),
132+
*(
133+
json.dumps(
134+
{
135+
arm.value: {
136+
"kind": "oceanbase",
137+
"url": f"mysql+aoceanbase://eval@localhost/test_{arm.value}?charset=utf8mb4&{query}",
138+
}
139+
for arm in Arm
140+
}
141+
)
142+
for query in (
143+
"password=private-test-password",
144+
"db=shared-private-test-password",
145+
)
146+
),
147+
*(
148+
json.dumps(
149+
{
150+
arm.value: {
151+
"kind": "oceanbase",
152+
"url": f"mysql+aoceanbase://eval:private-test-password{character}suffix@localhost/test_{arm.value}",
153+
}
154+
for arm in Arm
155+
}
156+
)
157+
for character in ("@", "\n", "\r", "\t")
158+
),
123159
],
124160
ids=[
125161
"missing-file",
@@ -130,6 +166,12 @@ def require(self, instance_id: str) -> object:
130166
"invalid-arm",
131167
"invalid-kind",
132168
"same-db",
169+
"query-password",
170+
"query-shared-db",
171+
"raw-at-password",
172+
"raw-newline-password",
173+
"raw-carriage-return-password",
174+
"raw-tab-password",
133175
],
134176
)
135177
def test_swebench_pro_run_rejects_invalid_database_config_without_exposing_secrets(
@@ -148,7 +190,7 @@ def unexpected_catalog_load(_path: Path) -> None:
148190
[
149191
"run",
150192
"--root",
151-
"/srv/evaluation",
193+
str(tmp_path / "evaluation"),
152194
"--instance-id",
153195
"instance_owner__repo-b",
154196
"--database-config",
@@ -162,6 +204,7 @@ def unexpected_catalog_load(_path: Path) -> None:
162204
assert "Database configuration" in output
163205
assert "private-test-password" not in output
164206
assert "another-private-password" not in output
207+
assert not (tmp_path / "evaluation").exists()
165208

166209

167210
def test_swebench_pro_run_defaults_optional_integrations_off(monkeypatch) -> None:

‎evaluation/coding/swebench_pro/tests/unit/test_runner_phases.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,50 @@ def test_run_configuration_rejects_shared_oceanbase_before_gold(tmp_path: Path)
291291
assert not (tmp_path / "eval").exists()
292292

293293

294+
@pytest.mark.parametrize(
295+
"option",
296+
[
297+
"host",
298+
"port",
299+
"user",
300+
"username",
301+
"password",
302+
"passwd",
303+
"db",
304+
"database",
305+
"unix_socket",
306+
"read_default_file",
307+
"read_default_group",
308+
"init_command",
309+
"sql_mode",
310+
"PASSWORD",
311+
"%70assword",
312+
"%64%62",
313+
"Read_Default_File",
314+
"%69nit_command",
315+
"unused=value#fragment&password",
316+
"password=first&password",
317+
"PaSsWd",
318+
"UNIX_SOCKET",
319+
],
320+
)
321+
def test_run_configuration_rejects_oceanbase_query_overrides_before_gold(tmp_path: Path, option: str) -> None:
322+
password = "private-query-value"
323+
on_url = f"mysql+aoceanbase://eval@database.test/on?charset=utf8mb4&{option}={password}"
324+
with pytest.raises(UnsafeSutConfiguration) as raised:
325+
replace(
326+
_config(tmp_path),
327+
database_configs={
328+
Arm.OFF: {"kind": "oceanbase", "url": "mysql+aoceanbase://eval:off-secret@database.test/off"},
329+
Arm.ON: {"kind": "oceanbase", "url": on_url},
330+
},
331+
)
332+
333+
assert password not in str(raised.value)
334+
assert on_url not in str(raised.value)
335+
assert not (tmp_path / "eval").exists()
336+
337+
294338
def _run_with_fakes(
295339
tmp_path: Path,
296340
monkeypatch: pytest.MonkeyPatch,

‎evaluation/memory/locomo_plus/README.md‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,14 @@ so separate runs remain isolated even when they share a database and the same `-
101101
that database for resume; dispose of a dedicated evaluation database only after its runs are no longer needed.
102102

103103
`run.json` and the summaries record the actual backend and a database target fingerprint. Connection credentials and
104-
the raw database URL are excluded. The fingerprint binds the run to the host, port, tenant/user, database and routing
105-
parameters (or the local database path); changing the password alone does not change it. Reusing a results directory
104+
the raw database URL are excluded. OceanBase fingerprints use the installed dialect's effective host, port,
105+
tenant/user and database, including query-string overrides such as `db`, `user`, `host` and `port`. When a Unix socket
106+
is configured, its resolved path replaces the TCP host and port. Passwords in either the URL authority or query string,
107+
TLS/authentication material and connection tuning options do not affect the fingerprint. The runtime overrides
108+
`init_command` with its fixed transaction initialization command. Duplicate query parameters, `read_default_file`,
109+
`read_default_group` and `sql_mode` are rejected before opening services because their routing is ambiguous or can
110+
depend on external configuration or SQL. SQLite and seekdb fingerprints continue to identify the local database path.
111+
Reusing a results directory
106112
with a different database target is rejected before opening the database or calling models. Pending Memory runs
107113
also verify that their saved Scopes still exist, including judge-only retries; missing Scopes are not recreated.
108114
Completed runs can regenerate their summary without opening the database; use `replay` for explicitly offline checks.
@@ -205,6 +211,9 @@ location with the same settings to resume. Successful cases are retained; failed
205211
failure reuses the frozen generated answer. Configuration or dataset changes require a separate run.
206212
Runs without a saved database fingerprint and Scope namespace cannot be resumed; their saved results remain
207213
available through `replay`. Preserve `ingestion.json` alongside `run.json` to retain the original Scope identities.
214+
OceanBase manifests must also record `database_fingerprint_version: oceanbase-target-v2`. Unversioned OceanBase
215+
fingerprints are not reinterpreted or migrated: their saved results remain replayable, but a new run needs a new
216+
output directory. This version requirement does not apply to SQLite or seekdb manifests.
208217
The run command exits with status `1` if cases fail to execute or remain unobserved. A completed evaluation with an
209218
incorrect model answer still exits with status `0`; answer quality is recorded in the report.
210219

‎evaluation/memory/locomo_plus/runner.py‎

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@
3232
from pydantic import ValidationError
3333
from pydantic_ai import capture_run_messages
3434
from pydantic_ai.messages import ModelMessagesTypeAdapter
35-
from sqlalchemy.engine import make_url
35+
from pyobvector import AsyncOceanBaseDialect
36+
from sqlalchemy.engine import URL, make_url
37+
from sqlalchemy.exc import ArgumentError
3638

3739
from evaluation.memory.locomo.dataset import LoCoMoSession
3840
from evaluation.memory.locomo.metrics import retrieval_metrics
@@ -280,28 +282,68 @@ def _database_config(settings: ServerSettings, output_directory: Path) -> Databa
280282
return settings.database
281283

282284

285+
def _oceanbase_target(url: URL) -> dict[str, Any]:
286+
if any(not isinstance(value, str) for value in url.query.values()):
287+
raise ValueError("OceanBase URL query parameters must not be repeated") # noqa: TRY003
288+
if {key.casefold() for key in url.query} & {"read_default_file", "read_default_group", "sql_mode"}:
289+
raise ValueError("OceanBase external defaults and SQL mode overrides are not supported for resumable runs") # noqa: TRY003
290+
try:
291+
# URL query values override the translated authority/path in the installed dialect.
292+
_, options = AsyncOceanBaseDialect().create_connect_args(url)
293+
username = options.get("user")
294+
database = options.get("db")
295+
host = options.get("host")
296+
port = int(options.get("port", 3306))
297+
if (
298+
not isinstance(host, str)
299+
or not host
300+
or not all(isinstance(value, str) and value for value in (username, database))
301+
or not 1 <= port <= 65535
302+
):
303+
raise ValueError # noqa: TRY301
304+
except (ArgumentError, TypeError, ValueError):
305+
raise ValueError( # noqa: TRY003
306+
"OceanBase connection target must explicitly identify its host, port, user and database"
307+
) from None
308+
socket = options.get("unix_socket")
309+
# Passwords, TLS/authentication material and tuning options do not identify the database.
310+
# init_command is replaced by the runtime's fixed SET autocommit = 0 command.
311+
return {
312+
"driver": url.drivername,
313+
"username": username,
314+
"database": database,
315+
"host": None if socket else host.lower(),
316+
"port": None if socket else port,
317+
"unix_socket": str(Path(socket).resolve()) if socket else None,
318+
}
319+
320+
283321
def _database_identity(database: DatabaseConfig) -> dict[str, str]:
322+
if database.kind == "oceanbase":
323+
target = _oceanbase_target(make_url(database.url.get_secret_value()))
324+
return {
325+
"database_kind": database.kind,
326+
"database_fingerprint_version": "oceanbase-target-v2",
327+
"database_fingerprint": _digest(json.dumps(target, sort_keys=True)),
328+
}
284329
target: dict[str, Any]
285330
if database.kind == "seekdb":
286331
target = {"path": str(database.path.resolve()), "database": database.database}
287332
else:
288-
value = database.url if database.kind == "sqlite" else database.url.get_secret_value()
289-
url = make_url(value)
290-
if database.kind == "sqlite":
291-
name = url.database
292-
if name and name != ":memory:" and not name.startswith("file:"):
293-
name = str(Path(name).resolve())
294-
url = url.set(database=name)
295-
# The OceanBase username can select a tenant. Password rotation does not change database identity.
333+
url = make_url(database.url)
334+
name = url.database
335+
if name and name != ":memory:" and not name.startswith("file:"):
336+
name = str(Path(name).resolve())
337+
url = url.set(database=name)
296338
target = {
297339
"driver": url.drivername,
298340
"host": (url.host or "").lower(),
299-
"port": url.port or (3306 if database.kind == "oceanbase" else None),
341+
"port": url.port or None,
300342
"username": url.username,
301343
"database": url.database,
302344
"query": dict(url.query),
303345
}
304-
if database.kind == "sqlite" and (url.database or "").startswith("file:"):
346+
if (url.database or "").startswith("file:"):
305347
target["working_directory"] = str(Path.cwd().resolve())
306348
return {
307349
"database_kind": database.kind,

0 commit comments

Comments
 (0)