From 0fd996e1bdd0deceddd837aaa7ab1f9b0c515395 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 23 Oct 2026 13:39:11 -0700 Subject: [PATCH 01/14] Complete async query-job tool contract --- AGENTS.md | 8 +- README.md | 17 +- docs/QUERY_JOBS.md | 77 +++++++- extensions/SHA256SUMS | 2 +- extensions/cdwagent.brxt | Bin 293370 -> 299496 bytes scripts/synthetic_job_server.py | 149 ++++++++++++++ skills/cdwagent-query-jobs/SKILL.md | 44 +++-- src/cdwagent/job_worker.py | 30 ++- src/cdwagent/jobs.py | 279 ++++++++++++++++++++++---- src/cdwagent/query_backend.py | 11 +- src/cdwagent/server.py | 6 +- tests/test_driver_timeout.py | 31 +++ tests/test_job_contract.py | 294 ++++++++++++++++++++++++++++ tests/test_mcp.py | 6 +- tests/test_synthetic_job_server.py | 159 +++++++++++++++ tests/test_wait_contention.py | 121 ++++++++++++ 16 files changed, 1144 insertions(+), 90 deletions(-) create mode 100644 scripts/synthetic_job_server.py create mode 100644 tests/test_driver_timeout.py create mode 100644 tests/test_job_contract.py create mode 100644 tests/test_synthetic_job_server.py create mode 100644 tests/test_wait_contention.py diff --git a/AGENTS.md b/AGENTS.md index 5efd936..2b1c37b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ src/cdwagent/ 1. **pip/uvx package** (`src/cdwagent/`): `cli.py` reads env vars → `server.main()` → `create_cdw_server(config)` → `mcp.run()` 2. **BioRouter extension**: install `extensions/cdwagent.brxt`; BioRouter creates its environment and configures the declared credentials. See `docs/BIOROUTER.md`. -3. **Job CLI**: `cdwagent submit/status/watch/list/cancel/purge` uses the same durable job runtime as the three MCP job tools. `cdwagent auth` configures a separate standalone OS-keyring profile. +3. **Job CLI**: `cdwagent submit/status/watch/list/cancel/purge` uses the same durable job runtime as the six MCP job tools. `cdwagent auth` configures a separate standalone OS-keyring profile. ### Schema Reference @@ -102,7 +102,7 @@ All config via environment variables (see `.env.example`): - `CDW_SCHEMA` (database schema for table qualification, default "deid_uf") - `CDW_LOG_LEVEL` -### 25 MCP Tools (namespace-prefixed with `CDW-`) +### 28 MCP Tools (namespace-prefixed with `CDW-`) | Module | Tools | |---|---| @@ -113,7 +113,7 @@ All config via environment variables (see `.env.example`): | concepts.py | `search_diagnoses_by_code`, `search_medications_by_code`, `search_labs_by_code`, `search_procedures_by_code` | | stats.py | `summarize_table`, `cohort_summary` | | cohort.py | `build_cohort` | -| jobs.py | `submit_query_job`, `query_job_status`, `cancel_query_job` | +| jobs.py | `submit_query_job`, `query_job_capacity`, `query_job_status`, `wait_for_query_job`, `read_query_job_result`, `cancel_query_job` | `search_note_concepts` and `search_note_sdoh` were added in v0.4.0 to expose the NLP-extracted concept layer (`note_concepts`, `note_concepts_sdoh`, populated by @@ -181,7 +181,7 @@ The `crossmap_patient` tool resolves an OMOP `person_id` to a CDW `PatientDurabl BioRouter does NOT use a classifier to pick agents; it flattens all enabled extensions' tools into one list and lets the LLM choose. Consequences for this repo: - Tool names and descriptions ARE the routing signal — keep the `CDW-` prefix to disambiguate from OMOPAgent's `query_ucsf_omop` etc. -- Use `submit_query_job` for expensive queries/exports and poll `query_job_status` at its recommended interval. Increasing an MCP timeout alone does not make an export durable. +- Use `submit_query_job` for expensive queries/exports and use native `wait_for_query_job`, or poll `query_job_status` at its recommended interval. Increasing an MCP timeout alone does not make an export durable. - Publish tagged releases so BioRouter's `extension_malware_check` pins a stable git ref. ### Context strategy (LLM dispatch optimization) diff --git a/README.md b/README.md index cca3f7b..956b4ee 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,15 @@ job-routing instructions; the skill folders can also be installed separately. ## Long queries, CLI and progress Use `CDW-submit_query_job` for an export or a query that could exceed the -interactive timeout. It returns a job ID immediately. Poll -`CDW-query_job_status` at its recommended interval; use -`CDW-cancel_query_job` to stop. Only `completed` means the file is complete. +interactive timeout. Call `CDW-query_job_capacity` first; submission returns a +job ID immediately and atomically checks the two-job limit. Use +`CDW-wait_for_query_job` for native waits of up to 30 seconds, or +`CDW-query_job_status` for an immediate snapshot. Use `CDW-cancel_query_job` +to stop. Only `completed` means the file is complete. Read bounded UTF-8 chunks +with `CDW-read_query_job_result` using the job ID and `next_offset`; do not pass +`result_path` to generic filesystem tools. The secure reader requires POSIX; +unsupported platforms retain the local CLI file. See [job boundaries and the +credential-free app fixture](docs/QUERY_JOBS.md#mcp-waiting-capacity-and-result-handoff). Status includes rows, bytes, elapsed time, phase and an advisory ETA when known. Set `mode="explain"` to obtain a plan without running the query. @@ -63,7 +69,8 @@ uv run cdwagent watch JOB_ID No-argument `uv run cdwagent` continues to start the MCP server. `status`, `watch`, `list`, `cancel` and `purge` need no database credentials. Results remain in the -local private job directory and are not sent to chat. Database/server limits can +local private job directory. The bounded result reader can return requested chunks +to chat; keep controlled data local and share only appropriate summaries. Database/server limits can still fail a query; jobs report those failures instead of silently truncating. See [architecture, storage, security and release details](docs/QUERY_JOBS.md). @@ -84,7 +91,7 @@ Architecture is based on the [MedCP](https://github.com/BaranziniLab/MedCP) temp ## Features -- 25 MCP tools: 22 domain tools across 7 modules and 3 durable query-job tools +- 28 MCP tools: 22 domain tools across 7 modules and 6 durable query-job tools - **One-call multimodal cohort building** (`build_cohort`) across 8 modalities: diagnosis, medication, procedure, lab, **imaging/radiology**, **immunization**, **allergy**, and **vitals/flowsheets** — resolves a term/code, builds the diff --git a/docs/QUERY_JOBS.md b/docs/QUERY_JOBS.md index 41c7bba..66fa56e 100644 --- a/docs/QUERY_JOBS.md +++ b/docs/QUERY_JOBS.md @@ -13,8 +13,9 @@ Keep each connector independently installable and releasable. Database adapters live in query_backend.py, orchestration in jobs.py/job_worker.py, and command parsing in query_cli.py. Existing domain tools remain alongside them. This avoids a new separately published dependency or any requirement to clone sibling repos. -The three small job-runtime modules intentionally have identical source; when -changing the protocol, run the same contract tests in all three repositories. +OMOP and CDW keep jobs.py and job_worker.py identical. Their query adapters differ +only in connector defaults. Run the same contract tests in both repositories when +changing the protocol; the SPOKE connector has an independently released runtime. A future shared package is appropriate once its API and release ownership settle. `extensions/cdwagent.brxt` is the current committed installable artifact. Build it @@ -79,6 +80,78 @@ uv run cdwagent cancel JOB_ID uv run cdwagent purge JOB_ID ``` +## MCP waiting, capacity and result handoff + +Six namespace-prefixed job tools are available. `submit_query_job` starts work; +`query_job_status` is an immediate lookup; `cancel_query_job` requests cancellation. +`query_job_capacity` reports `active_jobs`, `max_active_jobs` (2), and +`available_slots`. Admission is checked atomically, so a later submission can +still return `capacity_exceeded` with current capacity. Waiting jobs hold their +slot until completion, failure or confirmed cancellation. + +`wait_for_query_job(job_id, timeout_seconds=30)` waits natively for 0–30 seconds. +It returns a current status plus `wait_timed_out`; true means the wait budget ended +while the job remains active. Cancelling the MCP wait does not cancel the job. +Use another bounded wait or respect `recommended_poll_seconds` for immediate +status polls. Status lookups run off the MCP event loop with a short database +lock budget. If the wait expires while storage is busy after an active state +was observed, it returns that last state with `wait_timed_out=true`, +`wait_status_stale=true` and an explicit warning. If no state could be read, +it returns a transient `status_lookup_busy` lookup error without claiming an +operation outcome. A busy lookup never marks the job failed. No shell sleep, +fabricated progress or automatic resubmission is needed. Unknown ETA is normal +during connection, planning and execution. + +`read_query_job_result(job_id, offset=0, max_bytes=8192)` reads only a completed +job's managed CSV/JSONL file. It accepts no path. It returns `text`, byte +`offset`, `bytes_read`, `total_bytes`, `eof` and `next_offset`. Limits are 4–65536 +bytes per call; follow `next_offset` until `eof=true`. Chunk boundaries preserve +UTF-8 characters but can split rows or quoted CSV records. Assemble the desired +bounded content before parsing it. A mid-character offset is rejected. + +The reader ignores saved `result_path` values and opens a fixed result name +relative to the managed job directory, using pinned-root identity, descriptor- +relative opens and no-follow flags. It rejects directory/file symlinks, hardlinks, +nonregular files and changed result sizes. POSIX secure-open primitives are +required. On unsupported platforms (including Windows) it fails closed with +`secure_reader_unavailable`; the existing local CLI result remains available. +This does not grant arbitrary filesystem access or isolate hostile code running +under the same OS account. Keep controlled data local and use bounded summaries +rather than placing complete patient-level exports in chat. + +Failed jobs retain `error_details` with stable `code`, `kind`, sanitized `message`, +original failure `phase`, and boolean `retryable`. Driver messages and query +literals are not exposed. Retryability is advisory; resubmission remains explicit. +New submissions, status/wait responses and cancellation responses also carry: + +```json +{"_meta":{"io.biorouter/operation":{"version":1,"id":"cdwagent:JOB_ID","status":"failed","error":{"code":"query_rejected","kind":"invalid_args","message":"The database rejected the query. Check schema, projections and parameters.","phase":"executing","retryable":false}}}} +``` + +Allowed operation states are `queued`, `running`, `completed`, `failed` and +`cancelled`; job `timed_out` and `interrupted` map to operation `failed`. A status +lookup that finds a failed job still has MCP `isError=false`. Unknown-job, +admission and result-reader failures have `isError=true` and structured safe +errors, without changing the stored job outcome. Older failed records without +structured details receive a generic safe operation error. + +## Credential-free app smoke fixture + +From a source checkout, create a fresh empty absolute directory and run: + +```bash +uv run python scripts/synthetic_job_server.py --root /absolute/fresh-empty-dir --delay-seconds 2 +``` + +This stdio MCP server registers the actual six job tools with an isolated store. +It accepts only `SELECT 1 AS example` (three synthetic multilingual rows), +`SELECT 0 AS simulate_failure` (stable safe failure), and +`SELECT 2 AS simulate_slow` (bounded delayed success). No real database connection, +credential lookup or installed job store is used. An existing nonempty directory +is refused. Use `CDW-submit_query_job`, wait/status, and the result reader to +exercise real MCP metadata and chunked handoff in a development app. Use separate +fresh roots for concurrent fixtures. + ## Read-only and failure boundaries Use database accounts with SELECT/read-only privileges. The shared query guard diff --git a/extensions/SHA256SUMS b/extensions/SHA256SUMS index f8f5c5e..2f6faa7 100644 --- a/extensions/SHA256SUMS +++ b/extensions/SHA256SUMS @@ -1 +1 @@ -91372a3a9e69ae99bd76d849c882432ddfcd79b6bb6d8fd45f101286d911d0e2 cdwagent.brxt +faeddcc454e5f12fb526334bd6592201edfbad9511dcdc5a871cb50387ccfc5c cdwagent.brxt diff --git a/extensions/cdwagent.brxt b/extensions/cdwagent.brxt index 1aefff68d346e65cbd637a58c5c80222b137b115..811d921600022c61212f5e30579738a843b5e9ed 100644 GIT binary patch delta 36862 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%0DF-A>)xZmYe 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®y&!o)F*@MYltsAfukr$N&8L!HE_ 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�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 diff --git a/scripts/synthetic_job_server.py b/scripts/synthetic_job_server.py new file mode 100644 index 0000000..ceebb10 --- /dev/null +++ b/scripts/synthetic_job_server.py @@ -0,0 +1,149 @@ +"""Credential-free MCP job fixture; only the three documented queries are accepted.""" +from __future__ import annotations + +import argparse +import importlib +import json +import math +from pathlib import Path +import sys +import threading +import time + +REPOSITORY = Path(__file__).resolve().parents[1] +PACKAGE = json.loads((REPOSITORY / 'manifest.json').read_text())['name'] +PREFIXES = {'ucsfomopagent': 'omop-', 'cdwagent': 'CDW-'} +if PACKAGE not in PREFIXES: + raise RuntimeError('This fixture supports only the OMOP and CDW connector packages.') +PREFIX = PREFIXES[PACKAGE] +sys.path.insert(0, str(REPOSITORY / 'src')) + +jobs = importlib.import_module(PACKAGE + '.jobs') +worker = importlib.import_module(PACKAGE + '.job_worker') + +EXAMPLE_QUERY = 'SELECT 1 AS example' +FAILURE_QUERY = 'SELECT 0 AS simulate_failure' +SLOW_QUERY = 'SELECT 2 AS simulate_slow' +ALLOWED_QUERIES = frozenset((EXAMPLE_QUERY, FAILURE_QUERY, SLOW_QUERY)) +EXAMPLE_ROWS = ((1, 'café'), (2, '漢字 😀'), (3, 'a, "quote"\nsecond line')) +SYNTHETIC_CREDENTIALS = dict( + server='synthetic.invalid', database='synthetic', + username='synthetic-only', password='synthetic-only', +) + + +def bounded_delay(value): + try: + delay = float(value) + except (TypeError, ValueError): + raise ValueError('delay_seconds must be a number from 0 to 30.') from None + if isinstance(value, bool) or not math.isfinite(delay) or not 0 <= delay <= 30: + raise ValueError('delay_seconds must be a number from 0 to 30.') + return delay + + +class SyntheticJobStore(jobs.JobStore): + def __init__(self, root, delay_seconds=2): + self.delay_seconds = bounded_delay(delay_seconds) + root = Path(root) + if not root.is_absolute() or root.is_symlink(): + raise ValueError('Supply an absolute, fresh temporary directory as root.') + if root.exists() and (not root.is_dir() or any(root.iterdir())): + raise ValueError('The synthetic job root must be empty; use a fresh temporary directory.') + root.mkdir(parents=True, exist_ok=True, mode=0o700) + super().__init__(PACKAGE, root) + self._threads = [] + self._threads_lock = threading.Lock() + self._slow_release = threading.Event() + self._closed = False + + def submit(self, query, parameters, credentials, **options): + if (not isinstance(query, str) or query.strip() not in ALLOWED_QUERIES + or parameters not in (None, {}) or options.get('mode', 'query') != 'query'): + raise jobs.JobError( + 'synthetic_query_rejected', 'invalid_args', + 'This fixture accepts only its three documented queries without parameters.', + 'validation', + ) + if self._closed: + raise jobs.JobError('synthetic_server_closed', 'internal', + 'The synthetic job server is closed.', 'admission') + return super().submit(query.strip(), {}, dict(SYNTHETIC_CREDENTIALS), **options) + + def _stream(self, request, credentials, progress): + if request['query'] == FAILURE_QUERY: + raise jobs.JobError('synthetic_failure', 'tool_failure', + 'Synthetic job failed as requested.', 'executing') + if request['query'] == SLOW_QUERY: + progress['phase'] = 'executing' + self._slow_release.wait(self.delay_seconds) + yield ['example', 'label'], EXAMPLE_ROWS + + def _start_worker(self, job_id, request, credentials): + def run(): + try: + with self.lease(job_id).acquire(timeout=0): + worker.execute(self, job_id, dict(request=request, credentials={}), self._stream) + except Exception: + data = self.status(job_id) + jobs.record_failure(data, dict( + code='synthetic_worker_failed', kind='internal', + message='The synthetic worker could not complete.', + phase='synthetic_worker', retryable=False, + )) + self.update(data) + + thread = threading.Thread(target=run, name='synthetic-query-job', daemon=True) + with self._threads_lock: + self._threads.append(thread) + thread.start() + + def release_slow_jobs(self): + self._slow_release.set() + + def close(self): + self._closed = True + self.release_slow_jobs() + deadline = time.monotonic() + 5 + with self._threads_lock: + threads = list(self._threads) + for thread in threads: + thread.join(max(0, deadline - time.monotonic())) + return not any(thread.is_alive() for thread in threads) + + +def create_server(root, delay_seconds=2): + from fastmcp import FastMCP + store = SyntheticJobStore(root, delay_seconds) + server = FastMCP( + PACKAGE + ' synthetic jobs', + instructions=( + 'This is a synthetic fixture with no database access or real records. ' + 'Allowed queries are exactly SELECT 1 AS example, SELECT 0 AS simulate_failure, ' + 'and SELECT 2 AS simulate_slow. Use query_job_capacity, submit_query_job, ' + 'wait_for_query_job and read_query_job_result to exercise the job contract.' + ), + ) + jobs.register_job_tools(server, PACKAGE, dict(SYNTHETIC_CREDENTIALS), PREFIX, store=store) + return server, store + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--root', type=Path, required=True, + help='Absolute path to a fresh, empty temporary directory.') + parser.add_argument('--delay-seconds', type=float, default=2, + help='Synthetic slow-job delay, from 0 to 30 seconds (default: 2).') + arguments = parser.parse_args(argv) + try: + server, store = create_server(arguments.root, arguments.delay_seconds) + except ValueError as error: + parser.error(str(error)) + try: + server.run(transport='stdio') + finally: + store.close() + + +if __name__ == '__main__': + main() diff --git a/skills/cdwagent-query-jobs/SKILL.md b/skills/cdwagent-query-jobs/SKILL.md index 546f3ce..7914839 100644 --- a/skills/cdwagent-query-jobs/SKILL.md +++ b/skills/cdwagent-query-jobs/SKILL.md @@ -15,20 +15,36 @@ BioRouter. Never ask the user to paste credentials into chat or shell arguments. 2. If cost is uncertain, submit `mode="explain"` first and inspect the completed local plan. It does not execute the query. SQL Server SHOWPLAN may require additional permission; lack of a plan is not evidence of a cheap query. -3. Submit the query once. Set an explicit timeout budget (default 3600 seconds, - maximum 86400) and a disk budget (`max_bytes`, default 10 GB). Do not run more - than two simultaneous jobs per connector. Add `expected_rows` only if already - known or reasonably estimated, and tell the user it is an estimate. -4. Poll `CDW-query_job_status` using its `recommended_poll_seconds` (5–60). - Status is immediate and contains phase, elapsed time, rows, bytes and heartbeat. - Report unknown ETA honestly during blocking execution. Historical-query and - streaming-rate estimates are advisory; changing load invalidates them. -5. Only `completed` supplies a complete `result_path`. Process CSV/JSONL locally; - send summaries to chat. Exports do not silently apply an MCP preview row cap. -6. On `failed`, `timed_out` or `interrupted`, inspect the cause and revise the plan - before a new submission. Partial files are not complete datasets. There is no - automatic retry, keyset checkpoint, or safe general-purpose query resume. - Use `CDW-cancel_query_job` for unwanted work, then poll to terminal state. +3. Call `CDW-query_job_capacity` before admission. It reports `active_jobs`, + `max_active_jobs` (2) and `available_slots`. Submit once with an explicit timeout + budget (default 3600 seconds, maximum 86400) and disk budget (`max_bytes`, + default 10 GB). Capacity is checked atomically again on submission; a + `capacity_exceeded` rejection includes current capacity. Wait for a slot or + cancel unwanted work, rather than retrying admission in a tight loop. Supply + `expected_rows` only when already known or estimated, and label it provisional. +4. Call `CDW-wait_for_query_job` with the job ID and `timeout_seconds` from + 0 to 30. This is native bounded waiting; it does not require a shell sleep. + `wait_timed_out=true` means the wait ended with the job still active, not that + the job failed. Cancelling a wait leaves the job running. For an immediate + snapshot, use `CDW-query_job_status`; if polling, respect its + `recommended_poll_seconds` (5–60). Report phase, elapsed time, rows and bytes; + unknown ETA during blocking execution is normal. Estimates are advisory. +5. Only `completed` means the export is complete. Use + `CDW-read_query_job_result` with `job_id`, byte `offset` (initially 0) and + `max_bytes` (4–65536). Follow `next_offset` until `eof=true`; offsets are bytes, + not rows, and CSV records may span chunks. Do not send `result_path` to generic + filesystem tools or request a wider filesystem permission. Keep patient-level + exports local and return appropriate summaries. Large local analyses can use + the CLI file; the MCP reader requires POSIX secure-open support and refuses + unsupported platforms. Exports do not silently apply a preview row cap. +6. On `failed`, `timed_out` or `interrupted`, inspect sanitized `error_details` + (`code`, `kind`, `message`, `phase`, `retryable`) and revise the plan before a + new submission. A successful status lookup can report a failed job. The MCP + `_meta['io.biorouter/operation']` envelope describes that operation outcome + separately from lookup success. Partial files are incomplete; there is no + automatic retry, keyset checkpoint or general-purpose resume. `retryable` is + advisory and never triggers resubmission. Use `CDW-cancel_query_job` for + unwanted work, then wait for a terminal state before replacing it. CLI status needs no credentials: from the installed extension directory, run `uv run cdwagent status JOB_ID` or `uv run cdwagent watch JOB_ID`. diff --git a/src/cdwagent/job_worker.py b/src/cdwagent/job_worker.py index 6602812..f95d769 100644 --- a/src/cdwagent/job_worker.py +++ b/src/cdwagent/job_worker.py @@ -8,7 +8,7 @@ import threading import time -from .jobs import JobStore, ACTIVE +from .jobs import JobStore, ACTIVE, failure_details, record_failure def execute(store, job_id, payload, stream): @@ -23,7 +23,7 @@ def execute(store, job_id, payload, stream): return data partial = directory / ('result.' + request['format'] + '.partial') final = directory / ('result.' + request['format']) - progress = {'phase': 'connecting', 'rows': 0, 'bytes': 0} + progress = {'phase': 'opening_output', 'rows': 0, 'bytes': 0} done = threading.Event() failure = [] @@ -35,6 +35,7 @@ def run(): writer = csv.writer(csv_buffer) if request['format'] == 'csv' else None header = False def write_row(row): + progress['phase'] = 'writing_output' if writer: csv_buffer.seek(0); csv_buffer.truncate(0) writer.writerow([json.dumps(v, default=str, ensure_ascii=False) if isinstance(v, (dict, list)) else v for v in row]) @@ -45,6 +46,7 @@ def write_row(row): raise OverflowError('output budget') out.write(encoded) progress['bytes'] += len(encoded) + progress['phase'] = 'connecting' for columns, batch in stream(request, payload['credentials'], progress): progress.setdefault('stream_started_at', time.time()) progress['phase'] = 'streaming' @@ -55,18 +57,11 @@ def write_row(row): write_row(row if writer else dict(zip(columns, row))) progress['rows'] += 1 out.flush() + progress['phase'] = 'finalizing' out.flush() os.fsync(out.fileno()) except Exception as error: - name = type(error).__name__ - if isinstance(error, OverflowError): - failure.append('Output byte budget exceeded; narrow the export or explicitly raise max_bytes.') - elif 'Auth' in name: - failure.append('Database authentication failed. Reconfigure credentials through the trusted UI or CLI.') - elif 'Timeout' in name: - failure.append('Database timeout. Inspect the plan and filters before explicitly resubmitting.') - else: - failure.append('Database or output operation failed (' + name + '). Check credentials, connectivity, schema, permissions and disk space; raw errors are withheld to protect data.') + failure.append(failure_details(error, progress['phase'])) finally: done.set() @@ -77,14 +72,17 @@ def write_row(row): if (directory / 'cancel').exists(): data.update(state='cancelled', phase='cancelled') elif time.monotonic() - started >= request['timeout_seconds']: - data.update(state='timed_out', phase='timed_out', error='Job wall-clock budget expired. Inspect the plan before explicitly resubmitting with a larger budget.') + record_failure(data, dict(code='job_deadline_exceeded', kind='timeout', message='Job wall-clock budget expired. Inspect the plan before explicitly resubmitting with a larger budget.', phase=progress['phase'], retryable=False), state='timed_out') elif done.is_set(): data.update(progress) if failure: - data.update(state='failed', phase='failed', error=failure[0]) + record_failure(data, failure[0]) else: - partial.replace(final) - data.update(state='completed', phase='completed', result_path=str(final), bytes=final.stat().st_size) + try: + partial.replace(final) + data.update(state='completed', phase='completed', result_path=str(final), bytes=final.stat().st_size) + except Exception as error: + record_failure(data, failure_details(error, 'finalizing')) if data['state'] != 'running': data['finished_at'] = time.time() if not store.update(data): @@ -107,7 +105,7 @@ def main(): execute(store, job_id, payload, backend.stream) except Exception: data = store.status(job_id) - data.update(state='failed', phase='failed', error='Worker initialization failed.', finished_at=time.time()) + record_failure(data, dict(code='worker_initialization_failed', kind='internal', message='Worker initialization failed.', phase='worker_initialization', retryable=False)) store.update(data) # Keep the OS lease until exit kills the driver thread too. Releasing it while # a blocked thread can still write would let purge race against an active file. diff --git a/src/cdwagent/jobs.py b/src/cdwagent/jobs.py index 6b18080..3ad5d00 100644 --- a/src/cdwagent/jobs.py +++ b/src/cdwagent/jobs.py @@ -1,13 +1,17 @@ -"""Durable, bounded-memory query jobs. Kept identical in the three UCSF connectors.""" +"""Durable, bounded-memory query jobs shared by the OMOP and CDW connectors.""" from __future__ import annotations -from contextlib import contextmanager +from contextlib import ExitStack, contextmanager +import asyncio +import codecs +import errno import hashlib import json import os from pathlib import Path import re import sqlite3 +import stat import statistics import subprocess import sys @@ -15,9 +19,84 @@ import time import uuid from filelock import FileLock, Timeout +from mcp.types import CallToolResult ACTIVE = ('queued', 'running') TERMINAL = ('completed', 'failed', 'cancelled', 'timed_out', 'interrupted') +MAX_ACTIVE_JOBS = 2 +MAX_WAIT_SECONDS = 30 +WAIT_LOOKUP_SECONDS = 0.05 +MAX_RESULT_BYTES = 65536 +OPERATION_META_KEY = 'io.biorouter/operation' + + +class JobError(ValueError): + def __init__(self, code, kind, message, phase, retryable=False, **context): + super().__init__(message) + self.details = dict(code=code, kind=kind, message=message, phase=phase, retryable=retryable) + self.context = context + + +def failure_details(error, phase): + if isinstance(error, JobError): + return error.details + code, kind, message, retryable = 'query_failed', 'tool_failure', 'Database execution or result processing failed. Check the query plan, schema and selected output format.', False + if isinstance(error, OverflowError): + code, message = 'output_limit_exceeded', 'Output byte budget exceeded; narrow the export or explicitly raise max_bytes.' + elif isinstance(error, TimeoutError): + code, kind, message = 'database_timeout', 'timeout', 'Database timeout. Inspect the plan and filters before explicitly resubmitting.' + elif isinstance(error, OSError): + if error.errno == errno.ENOSPC: + code, message = 'disk_full', 'Local result storage is full. Free space before explicitly resubmitting.' + elif error.errno in (errno.EACCES, errno.EPERM): + code, kind, message = 'storage_permission_denied', 'permission_denied', 'Local result storage access was denied.' + elif isinstance(error, ConnectionError): + code, kind, message, retryable = 'connection_interrupted', 'transient', 'The database connection was interrupted.', True + else: + code, message = 'output_io_failed', 'Local result file processing failed. Check available space and file permissions.' + elif type(error).__module__.split('.')[0] == 'pymssql': + number = error.args[0] if error.args and type(error.args[0]) is int else None + if number == 20003: + code, kind, message = 'database_timeout', 'timeout', 'Database timeout. Inspect the plan and filters before explicitly resubmitting.' + elif number == 18456: + code, kind, message = 'authentication_failed', 'permission_denied', 'Database authentication failed. Reconfigure credentials through the trusted UI or CLI.' + elif number == 229: + code, kind, message = 'database_permission_denied', 'permission_denied', 'The database denied this operation. Check read or SHOWPLAN permissions.' + elif number in (1205, 1222): + code, kind, message, retryable = 'database_busy', 'transient', 'The database could not obtain the required locks.', True + elif type(error).__name__ == 'ProgrammingError': + code, kind, message = 'query_rejected', 'invalid_args', 'The database rejected the query. Check schema, projections and parameters.' + elif phase == 'connecting': + code, message = 'connection_failed', 'Database connection failed. Check connectivity and the configured authentication route.' + return dict(code=code, kind=kind, message=message, phase=phase, retryable=retryable) + + +def record_failure(data, details, state='failed'): + data.update(state=state, phase=state, error=details['message'], error_details=details, finished_at=time.time()) + + +def operation_metadata(package, data): + state = data['state'] + status = 'failed' if state in ('failed', 'timed_out', 'interrupted') else state + operation = dict(version=1, id=package + ':' + data['job_id'], status=status) + if status == 'failed': + operation['error'] = data.get('error_details') or dict(code='job_' + state, kind='tool_failure', message='The job did not complete. Inspect its status before explicitly submitting a replacement.', phase=state, retryable=False) + return operation + + +def tool_result(data, package=None): + from mcp.types import TextContent + metadata = {OPERATION_META_KEY: operation_metadata(package, data)} if package else None + return CallToolResult(content=[TextContent(type='text', text=json.dumps(data))], structuredContent=data, isError=False, _meta=metadata) + + +def tool_failure(error, phase='request'): + from mcp.types import TextContent + details = failure_details(error, phase) + data = dict(error=details, **(error.context if isinstance(error, JobError) else {})) + return CallToolResult(content=[TextContent(type='text', text=details['message'])], structuredContent=data, isError=True) + + INSTRUCTIONS = """ For schema discovery and small previews use the existing synchronous tools. For exports, joins/scans with uncertain cost, or a query that timed out, use @@ -26,7 +105,8 @@ Resolve schema/concepts first; select only needed columns and use indexed filters. Use mode='explain' to inspect a plan without executing the query (SQL Server needs SHOWPLAN permission). Do not run an expensive COUNT(*) solely to estimate export size. -Poll query_job_status at its recommended_poll_seconds until a terminal state. +Check query_job_capacity before submission. Use wait_for_query_job for bounded native +waiting (up to 30 seconds per call); query_job_status remains an immediate lookup. Report phase, elapsed time, rows and bytes. An unknown ETA is not a stuck query; execution before the first row often has no measurable percentage. Estimates from prior identical queries or expected_rows are advisory, never guarantees. @@ -35,7 +115,9 @@ Do not repeatedly retry failures: inspect schema/plan and narrow the query first. Jobs survive chat/MCP disconnects, but not host reboot; interrupted queries require an explicit new submission. There is no unsafe automatic OFFSET resume or retry. -Process downloaded data locally and return summaries instead of full rows to chat. +Use read_query_job_result with the completed job ID for bounded UTF-8 chunks; +continue with next_offset until eof. Do not pass result_path to filesystem tools. +For large analyses process the complete local file and return summaries to chat. """ @@ -91,6 +173,9 @@ class JobStore: def __init__(self, package: str, root: str | Path | None = None): self.package = package self.root = private_dir(Path(root or os.environ.get('UCSF_AGENT_JOB_ROOT', Path.home() / '.local/share/ucsf-agents')) / package) + self.root = self.root.resolve() + root_stat = self.root.stat() + self._root_identity = (root_stat.st_dev, root_stat.st_ino) self.dbpath = self.root / 'jobs.sqlite3' if self.dbpath.is_symlink(): raise ValueError('Job database must not be a symbolic link.') @@ -101,8 +186,8 @@ def __init__(self, package: str, root: str | Path | None = None): self.dbpath.chmod(0o600) @contextmanager - def connect(self): - db = sqlite3.connect(self.dbpath, timeout=10) + def connect(self, timeout=10): + db = sqlite3.connect(self.dbpath, timeout=timeout) try: with db: yield db @@ -111,7 +196,7 @@ def connect(self): def directory(self, job_id): if not re.fullmatch(r'[0-9a-f]{32}', job_id): - raise ValueError('Invalid job ID.') + raise JobError('invalid_job_id', 'invalid_args', 'Invalid job ID.', 'lookup') return self.root / job_id def lease(self, job_id): @@ -124,12 +209,21 @@ def update(self, data): (data['state'], data['updated_at'], json.dumps(data), data['job_id'])) return cursor.rowcount == 1 - def status(self, job_id): + def status(self, job_id, *, database_timeout=10): + deadline = time.monotonic() + database_timeout + try: + return self._status(job_id, deadline) + except sqlite3.OperationalError as error: + if (getattr(error, 'sqlite_errorcode', 0) & 255) not in (sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED): + raise + raise JobError('status_lookup_busy', 'transient', 'Job status storage is busy. Retry the lookup after existing work releases it.', 'status_lookup', retryable=True) from None + + def _status(self, job_id, deadline): self.directory(job_id) - with self.connect() as db: + with self.connect(timeout=max(0, deadline - time.monotonic())) as db: row = db.execute('SELECT data FROM jobs WHERE id=?', (job_id,)).fetchone() if not row: - raise ValueError('Unknown job ID.') + raise JobError('job_not_found', 'not_found', 'Unknown job ID.', 'lookup') data = json.loads(row[0]) now = time.time() data['heartbeat_stale'] = data['state'] in ACTIVE and now - data['updated_at'] > 60 @@ -137,12 +231,12 @@ def status(self, job_id): try: with self.lease(job_id).acquire(timeout=0): previous_update = data['updated_at'] - data = dict(data, state='interrupted', phase='interrupted', error='Worker exited without completing. Inspect before explicitly submitting a new job.', finished_at=now) - with self.connect() as db: + record_failure(data, dict(code='worker_interrupted', kind='internal', message='Worker exited without completing. Inspect before explicitly submitting a new job.', phase=data.get('phase', 'unknown'), retryable=False), state='interrupted') + with self.connect(timeout=max(0, deadline - time.monotonic())) as db: cursor = db.execute("UPDATE jobs SET state=?, data=? WHERE id=? AND updated=? AND state IN ('queued','running')", ('interrupted', json.dumps(data), job_id, previous_update)) if not cursor.rowcount: - return self.status(job_id) + return self._status(job_id, deadline) except Timeout: data['heartbeat_warning'] = 'Worker still holds its process lease but heartbeat is stale; do not resubmit.' data['elapsed_seconds'] = round((data.get('finished_at') or now) - data['created_at'], 2) @@ -159,8 +253,83 @@ def status(self, job_id): elif data.get('historical_seconds') and data['elapsed_seconds'] < data['historical_seconds']: data['eta_seconds'] = round(data['historical_seconds'] - data['elapsed_seconds'], 1) data['estimate_basis'] = 'median of prior successful identical queries; load and data may differ' + data['capacity'] = self.capacity(database_timeout=max(0, deadline - time.monotonic())) return data + def capacity(self, *, database_timeout=10): + with self.connect(timeout=database_timeout) as db: + active = db.execute("SELECT COUNT(*) FROM jobs WHERE state IN ('queued','running')").fetchone()[0] + return dict(active_jobs=active, max_active_jobs=MAX_ACTIVE_JOBS, available_slots=max(0, MAX_ACTIVE_JOBS - active)) + + async def wait(self, job_id, timeout_seconds=30): + if type(timeout_seconds) is not int or not 0 <= timeout_seconds <= MAX_WAIT_SECONDS: + raise JobError('invalid_wait', 'invalid_args', 'timeout_seconds must be an integer from 0 to 30.', 'validation') + deadline = time.monotonic() + timeout_seconds + data = None + while True: + remaining = max(0, deadline - time.monotonic()) + try: + data = await asyncio.to_thread(self.status, job_id, database_timeout=min(WAIT_LOOKUP_SECONDS, remaining)) + except JobError as error: + if error.details['code'] != 'status_lookup_busy': + raise + remaining = deadline - time.monotonic() + if remaining <= 0: + if data is None: + raise + data.update(wait_timed_out=True, wait_status_stale=True, wait_warning='The wait ended with status storage busy; this is the last successfully observed active state.') + return data + await asyncio.sleep(min(0.25, remaining)) + continue + remaining = deadline - time.monotonic() + if data['state'] in TERMINAL or remaining <= 0: + data['wait_timed_out'] = data['state'] not in TERMINAL + return data + await asyncio.sleep(min(0.25, remaining)) + + def read_result(self, job_id, offset=0, max_bytes=8192): + if type(offset) is not int or offset < 0 or type(max_bytes) is not int or not 4 <= max_bytes <= MAX_RESULT_BYTES: + raise JobError('invalid_result_chunk', 'invalid_args', 'offset must be a nonnegative byte offset; max_bytes must be 4..65536.', 'result_read') + data = self.status(job_id) + if data['state'] != 'completed': + raise JobError('result_not_complete', 'invalid_args', 'Only completed job results can be read.', 'result_read') + if data.get('format') not in ('csv', 'jsonl'): + raise JobError('invalid_result_format', 'internal', 'The saved result format is invalid.', 'result_read') + if not hasattr(os, 'O_NOFOLLOW') or not hasattr(os, 'O_DIRECTORY') or os.open not in os.supports_dir_fd: + raise JobError('secure_reader_unavailable', 'internal', 'This platform does not support the secure result reader; use the local CLI result file.', 'result_read') + try: + with ExitStack() as stack: + root_fd = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + stack.callback(os.close, root_fd) + root_stat = os.fstat(root_fd) + if (root_stat.st_dev, root_stat.st_ino) != self._root_identity: + raise JobError('result_scope_changed', 'permission_denied', 'The managed job directory changed; result access was refused.', 'result_read') + directory_fd = os.open(job_id, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=root_fd) + stack.callback(os.close, directory_fd) + result_fd = os.open('result.' + data['format'], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory_fd) + stack.callback(os.close, result_fd) + result_stat = os.fstat(result_fd) + if not stat.S_ISREG(result_stat.st_mode) or result_stat.st_nlink != 1: + raise JobError('invalid_result_file', 'permission_denied', 'The result must be a regular managed file without links.', 'result_read') + size = result_stat.st_size + if size != data.get('bytes'): + raise JobError('result_size_changed', 'internal', 'The completed result size changed; access was refused.', 'result_read') + if offset > size: + raise JobError('invalid_result_offset', 'invalid_args', 'The byte offset exceeds the completed result size.', 'result_read') + os.lseek(result_fd, offset, os.SEEK_SET) + raw = os.read(result_fd, min(max_bytes, size - offset)) + if len(raw) != min(max_bytes, size - offset): + raise JobError('result_size_changed', 'internal', 'The completed result changed while it was being read.', 'result_read') + decoder = codecs.getincrementaldecoder('utf-8')() + text = decoder.decode(raw, final=offset + len(raw) == size) + consumed = len(raw) - len(decoder.getstate()[0]) + except UnicodeDecodeError: + raise JobError('invalid_result_encoding', 'invalid_args', 'The result or byte offset is not valid UTF-8.', 'result_read') from None + except OSError: + raise JobError('result_unavailable', 'permission_denied', 'The managed result file is missing, linked or inaccessible.', 'result_read') from None + next_offset = offset + consumed + return dict(job_id=job_id, format=data['format'], text=text, offset=offset, bytes_read=consumed, total_bytes=size, eof=next_offset == size, next_offset=None if next_offset == size else next_offset) + def list_jobs(self): with self.connect() as db: ids = db.execute('SELECT id FROM jobs ORDER BY updated DESC LIMIT 50').fetchall() @@ -214,45 +383,79 @@ def submit(self, query, parameters, credentials, *, mode='query', format='jsonl' with self.connect() as db: db.execute('BEGIN IMMEDIATE') # Stale workers retain their slot until the operator checks their status. - if db.execute("SELECT COUNT(*) FROM jobs WHERE state IN ('queued','running')").fetchone()[0] >= 2: - raise ValueError('Two jobs are already active. Monitor or cancel them before submitting another.') + active = db.execute("SELECT COUNT(*) FROM jobs WHERE state IN ('queued','running')").fetchone()[0] + if active >= MAX_ACTIVE_JOBS: + raise JobError('capacity_exceeded', 'transient', 'Two jobs are already active. Wait for completion or cancel unwanted work before submitting another.', 'admission', retryable=True, capacity=dict(active_jobs=active, max_active_jobs=MAX_ACTIVE_JOBS, available_slots=0)) history = db.execute("SELECT data FROM jobs WHERE fingerprint=? AND state='completed' ORDER BY updated DESC LIMIT 5", (fingerprint,)).fetchall() if history: data['historical_seconds'] = statistics.median(json.loads(r[0])['finished_at'] - json.loads(r[0])['created_at'] for r in history) private_dir(self.directory(job_id)) db.execute('INSERT INTO jobs VALUES (?,?,?,?,?)', (job_id, 'queued', now, fingerprint, json.dumps(data))) - # Requests contain potentially sensitive query parameters, so only the status - # goes to disk. Credentials and query text reach the worker over a private pipe. - env = {k: v for k, v in os.environ.items() if k in ('PATH', 'HOME', 'USERPROFILE', 'SYSTEMROOT', 'WINDIR', 'TMPDIR', 'TEMP', 'TMP', 'LANG', 'LC_ALL', 'PYTHONPATH', 'SSL_CERT_FILE', 'SSL_CERT_DIR')} - env['UCSF_AGENT_JOB_ROOT'] = str(self.root.parent) - args = [sys.executable, '-m', f'{self.package}.job_worker', job_id] - options = {'start_new_session': True} if os.name != 'nt' else {'creationflags': subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS} try: - process = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env, **options) - process.stdin.write(json.dumps(dict(request=request, credentials=credentials)).encode()) - process.stdin.close() - threading.Thread(target=process.wait, daemon=True).start() + self._start_worker(job_id, request, credentials) except Exception: - data.update(state='failed', phase='failed', error='Could not start the local worker.', finished_at=time.time()) + details = dict(code='worker_start_failed', kind='internal', message='Could not start the local worker.', phase='worker_start', retryable=False) + record_failure(data, details) self.update(data) - raise RuntimeError('Could not start the local worker.') from None + raise JobError(**details) from None return self.status(job_id) + def _start_worker(self, job_id, request, credentials): + # Keep query text and credentials off disk and out of child arguments. + env = {k: v for k, v in os.environ.items() if k in ('PATH', 'HOME', 'USERPROFILE', 'SYSTEMROOT', 'WINDIR', 'TMPDIR', 'TEMP', 'TMP', 'LANG', 'LC_ALL', 'PYTHONPATH', 'SSL_CERT_FILE', 'SSL_CERT_DIR')} + env['UCSF_AGENT_JOB_ROOT'] = str(self.root.parent) + args = [sys.executable, '-m', f'{self.package}.job_worker', job_id] + options = {'start_new_session': True} if os.name != 'nt' else {'creationflags': subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS} + process = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env, **options) + process.stdin.write(json.dumps(dict(request=request, credentials=credentials)).encode()) + process.stdin.close() + threading.Thread(target=process.wait, daemon=True).start() + -def register_job_tools(mcp, package, credentials, prefix=''): +def register_job_tools(mcp, package, credentials, prefix='', *, store=None): from mcp.types import ToolAnnotations - store = JobStore(package) + store = store if store is not None else JobStore(package) + + def call(action, operation=False): + try: + data = action() + return tool_result(data, package if operation else None) + except ValueError as error: + if not isinstance(error, JobError): + error = JobError('invalid_request', 'invalid_args', 'Invalid query or job arguments. Check the read-only query policy and declared argument bounds.', 'validation') + return tool_failure(error) + except Exception as error: + return tool_failure(error) + @mcp.tool(name=prefix + 'submit_query_job', annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True)) - def submit_query_job(query: str, parameters: dict | None = None, mode: str = 'query', format: str = 'jsonl', timeout_seconds: int = 3600, expected_rows: int | None = None, max_bytes: int = 10_000_000_000) -> dict: - """Start a detached read-only SQL/Cypher export, or mode=explain for a plan. Returns a job ID immediately; poll query_job_status. No result-row cap; max_bytes is a fail-visible disk budget. Credentials come from the connector, never tool arguments.""" - return store.submit(query, parameters, credentials, mode=mode, format=format, timeout_seconds=timeout_seconds, expected_rows=expected_rows, max_bytes=max_bytes) + def submit_query_job(query: str, parameters: dict | None = None, mode: str = 'query', format: str = 'jsonl', timeout_seconds: int = 3600, expected_rows: int | None = None, max_bytes: int = 10_000_000_000) -> CallToolResult: + """Start a detached read-only export or mode=explain plan. Check query_job_capacity first, then wait_for_query_job. Credentials come from the connector. No automatic retry or result-row cap.""" + return call(lambda: store.submit(query, parameters, credentials, mode=mode, format=format, timeout_seconds=timeout_seconds, expected_rows=expected_rows, max_bytes=max_bytes), operation=True) + + @mcp.tool(name=prefix + 'query_job_capacity', annotations=ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False)) + def query_job_capacity() -> CallToolResult: + """Read active_jobs, max_active_jobs and available_slots before submission. Capacity is advisory; admission is atomically checked again when submitting.""" + return call(store.capacity) @mcp.tool(name=prefix + 'query_job_status', annotations=ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False)) - def query_job_status(job_id: str) -> dict: - """Read phase, heartbeat, rows, bytes, elapsed time, advisory ETA and completed file path. Poll at recommended_poll_seconds. No credentials needed; never reads result rows into chat.""" - return store.status(job_id) + def query_job_status(job_id: str) -> CallToolResult: + """Immediately read phase, heartbeat, rows, bytes, capacity and job outcome. Failed jobs include sanitized error_details. Use wait_for_query_job instead of rapid polling.""" + return call(lambda: store.status(job_id), operation=True) + + @mcp.tool(name=prefix + 'wait_for_query_job', annotations=ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False)) + async def wait_for_query_job(job_id: str, timeout_seconds: int = 30) -> CallToolResult: + """Wait natively for a terminal job state, for 0..30 seconds. wait_timed_out means this wait ended while the job remains active; it does not cancel or time out the job. Cancellation stops waiting only.""" + try: + return tool_result(await store.wait(job_id, timeout_seconds), package) + except Exception as error: + return tool_failure(error) + + @mcp.tool(name=prefix + 'read_query_job_result', annotations=ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False)) + def read_query_job_result(job_id: str, offset: int = 0, max_bytes: int = 8192) -> CallToolResult: + """Read 4..65536 bytes of a completed managed result as UTF-8 text. Continue at next_offset until eof. Offsets are bytes, not rows; CSV rows may span chunks. Accepts job IDs, never arbitrary file paths.""" + return call(lambda: store.read_result(job_id, offset, max_bytes)) @mcp.tool(name=prefix + 'cancel_query_job', annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=True, openWorldHint=False)) - def cancel_query_job(job_id: str) -> dict: - """Request cancellation. Poll until cancelled/completed; closing the worker connection releases the query. Partial files are not complete results.""" - return store.cancel(job_id) + def cancel_query_job(job_id: str) -> CallToolResult: + """Request cancellation, then wait_for_query_job until terminal. Closing the worker releases its query connection; partial files remain incomplete.""" + return call(lambda: store.cancel(job_id), operation=True) diff --git a/src/cdwagent/query_backend.py b/src/cdwagent/query_backend.py index 9ee5bfd..3c24c67 100644 --- a/src/cdwagent/query_backend.py +++ b/src/cdwagent/query_backend.py @@ -1,6 +1,8 @@ """Streaming SQL Server adapter; each background job owns its connection.""" import os +from .jobs import JobError + DIALECT = 'sql' SECRET_KEYS = ('CLINICAL_RECORDS_USERNAME', 'CLINICAL_RECORDS_PASSWORD') ENV_KEYS = ('CLINICAL_RECORDS_USERNAME', 'CLINICAL_RECORDS_PASSWORD', 'CLINICAL_RECORDS_SERVER', 'CLINICAL_RECORDS_DATABASE') @@ -14,7 +16,7 @@ def from_environment(env=None): def check_credentials(values): if not values.get('username') or not values.get('password'): - raise ValueError('Configure CLINICAL_RECORDS_USERNAME and CLINICAL_RECORDS_PASSWORD through BioRouter or the interactive auth command.') + raise JobError('credentials_missing', 'permission_denied', 'Configure database credentials through BioRouter or the interactive auth command.', 'credentials') def stream(request, credentials, progress): @@ -22,17 +24,18 @@ def stream(request, credentials, progress): check_credentials(credentials) with pymssql.connect(server=credentials['server'], database=credentials['database'], user=credentials['username'], password=credentials['password'], timeout=request['timeout_seconds'], login_timeout=20, autocommit=True, appname=__package__ + '-query-job') as conn: with conn.cursor() as cursor: + progress['phase'] = 'planning' if request['mode'] == 'explain' else 'executing' if request['mode'] == 'explain': cursor.execute('SET SHOWPLAN_XML ON') - progress['phase'] = 'planning' if request['mode'] == 'explain' else 'executing' cursor.execute(request['query'], request['parameters'] or None) columns = [c[0] for c in cursor.description] if cursor.description else [] if len(columns) != len(set(columns)): - raise ValueError('Duplicate column names: alias each projection.') + raise JobError('duplicate_columns', 'invalid_args', 'Duplicate column names: alias each projection.', 'executing') while True: + progress['phase'] = 'streaming' rows = cursor.fetchmany(1000) yield columns, rows if not rows: break if cursor.nextset(): - raise ValueError("Multiple result sets are not supported; submit one SELECT per job.") + raise JobError('multiple_result_sets', 'invalid_args', 'Multiple result sets are not supported; submit one SELECT per job.', 'streaming') diff --git a/src/cdwagent/server.py b/src/cdwagent/server.py index 48838a9..9acfd17 100644 --- a/src/cdwagent/server.py +++ b/src/cdwagent/server.py @@ -226,7 +226,7 @@ def clinical_data_exploration() -> str: "2. Search the schema for tables related to my topic of interest\n" "3. Describe the relevant tables to understand their columns\n" "4. Run a bounded preview; use submit_query_job for exports or uncertain-cost queries\n" - "5. Poll query_job_status at recommended_poll_seconds until completed; report failures and incomplete results\n\n" + "5. Use wait_for_query_job until completed, or query_job_status at recommended_poll_seconds; report failures and incomplete results\n\n" f"All tables are in the {schema} schema (e.g., {schema}.PatientDim).\n\n" "CRITICAL — PATIENT IDENTIFIERS:\n" "- PatientDurableKey is the STABLE patient identifier. Always use it for cohort queries.\n" @@ -272,7 +272,7 @@ def cohort_building() -> str: "3. Use cohort_summary with the patient_key_query to get counts and demographics\n" f"4. Retrieve demographics: SELECT ... FROM {schema}.PatientDim WHERE IsCurrent = 1 AND PatientDurableKey IN (subquery)\n" "5. For clinical details (labs, meds, encounters): filter fact tables WHERE PatientDurableKey IN (subquery)\n" - "6. Export with submit_query_job(format='csv'); monitor query_job_status until completed\n" + "6. Export with submit_query_job(format='csv'); use wait_for_query_job until completed\n" " Do not run a costly count only for an ETA. Do not duplicate active jobs.\n\n" "KEY COLUMN NAMES:\n" "- PatientDim: PatientKey, PatientDurableKey, Sex, BirthDate, DeathDate, FirstRace, Ethnicity\n" @@ -298,7 +298,7 @@ def notes_analysis() -> str: "3. Review note metadata (note_type, encounter_type, enc_dept_specialty, deid_service_date)\n" "4. Read full note text for relevant findings using get_note\n" "5. Summarize patterns, disclose NLP exclusions and population sampling notices\n" - "6. For expensive population queries or exports, submit_query_job and poll query_job_status\n\n" + "6. For expensive population queries or exports, submit_query_job and wait_for_query_job\n\n" "What patient or keyword should we start searching for?" ) diff --git a/tests/test_driver_timeout.py b/tests/test_driver_timeout.py new file mode 100644 index 0000000..ff9a2c7 --- /dev/null +++ b/tests/test_driver_timeout.py @@ -0,0 +1,31 @@ +import importlib +import json +import unittest + +import pymssql + +PACKAGE = 'cdwagent' +jobs = importlib.import_module(PACKAGE + '.jobs') + + +class DriverTimeoutTests(unittest.TestCase): + def test_freetds_timeout_keeps_its_kind_and_phase_without_driver_text(self): + for phase in ('connecting', 'executing', 'streaming'): + with self.subTest(phase=phase): + error = pymssql.OperationalError(20003, b'private_marker query=private_marker') + details = jobs.failure_details(error, phase) + self.assertEqual(details['code'], 'database_timeout') + self.assertEqual(details['kind'], 'timeout') + self.assertEqual(details['phase'], phase) + self.assertFalse(details['retryable']) + self.assertNotIn('private_marker', json.dumps(details)) + failed_job = {'job_id': 'a' * 32, 'state': 'running'} + jobs.record_failure(failed_job, details) + operation = jobs.operation_metadata(PACKAGE, failed_job) + self.assertEqual(operation['status'], 'failed') + self.assertEqual(operation['error'], details) + self.assertNotIn('private_marker', json.dumps(operation)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_job_contract.py b/tests/test_job_contract.py new file mode 100644 index 0000000..45fbb9e --- /dev/null +++ b/tests/test_job_contract.py @@ -0,0 +1,294 @@ +import asyncio +import errno +import importlib +import json +import os +from pathlib import Path +import shutil +import tempfile +import threading +import time +import unittest +from unittest.mock import patch + +PACKAGE = 'cdwagent' +jobs = importlib.import_module(PACKAGE + '.jobs') +worker = importlib.import_module(PACKAGE + '.job_worker') +backend = importlib.import_module(PACKAGE + '.query_backend') +SECURE_READER = hasattr(os, 'O_NOFOLLOW') and hasattr(os, 'O_DIRECTORY') and os.open in os.supports_dir_fd + + +class ContractTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.store = jobs.JobStore(PACKAGE, self.temp.name) + + def queued(self, **options): + with patch.object(backend, 'check_credentials'), patch.object(jobs.subprocess, 'Popen') as process: + data = self.store.submit('SELECT 1 AS example', {}, {}, **options) + payload = json.loads(process.return_value.stdin.write.call_args.args[0]) + return data, payload + + def completed(self, value='example'): + data, payload = self.queued() + done = worker.execute(self.store, data['job_id'], payload, lambda *args: iter([(['example'], [[value]])])) + self.assertEqual(done['state'], 'completed') + return done + + def rewrite(self, data, **changes): + data = dict(data, **changes) + with self.store.connect() as db: + db.execute('UPDATE jobs SET state=?, data=? WHERE id=?', (data['state'], json.dumps(data), data['job_id'])) + return data + + def test_capacity_is_discoverable_and_rejection_is_structured(self): + self.assertEqual(self.store.capacity(), dict(active_jobs=0, max_active_jobs=2, available_slots=2)) + first, payload = self.queued() + self.queued() + self.assertEqual(self.store.status(first['job_id'])['capacity']['available_slots'], 0) + with self.assertRaises(jobs.JobError) as raised: + self.queued() + self.assertEqual(raised.exception.details['code'], 'capacity_exceeded') + self.assertEqual(raised.exception.context['capacity']['active_jobs'], 2) + worker.execute(self.store, first['job_id'], payload, lambda *args: iter([])) + self.assertEqual(self.store.capacity()['available_slots'], 1) + + def test_wait_returns_completion_without_resubmission(self): + data, payload = self.queued() + def finish(): + time.sleep(.05) + worker.execute(self.store, data['job_id'], payload, lambda *args: iter([])) + thread = threading.Thread(target=finish) + thread.start() + try: + result = asyncio.run(self.store.wait(data['job_id'], 2)) + finally: + thread.join(3) + self.assertEqual(result['state'], 'completed') + self.assertFalse(result['wait_timed_out']) + self.assertEqual(len(self.store.list_jobs()), 1) + + def test_wait_deadline_and_cancellation_do_not_stop_the_job(self): + data, _ = self.queued() + result = asyncio.run(self.store.wait(data['job_id'], 0)) + self.assertEqual(result['state'], 'queued') + self.assertTrue(result['wait_timed_out']) + async def cancel_wait(): + task = asyncio.create_task(self.store.wait(data['job_id'], 30)) + await asyncio.sleep(.02) + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + asyncio.run(cancel_wait()) + self.assertEqual(self.store.status(data['job_id'])['state'], 'queued') + for timeout in [-1, 31, True, .1]: + with self.subTest(timeout=timeout), self.assertRaises(jobs.JobError): + asyncio.run(self.store.wait(data['job_id'], timeout)) + + def test_failure_keeps_safe_code_phase_and_retryability(self): + data, payload = self.queued() + def fail(request, credentials, progress): + progress['phase'] = 'executing' + raise ValueError('password=private_marker row=private_marker') + yield + done = worker.execute(self.store, data['job_id'], payload, fail) + error = done['error_details'] + self.assertEqual(error['code'], 'query_failed') + self.assertEqual(error['phase'], 'executing') + self.assertFalse(error['retryable']) + self.assertNotIn('private_marker', json.dumps(done)) + meta = jobs.operation_metadata(PACKAGE, done) + self.assertEqual(meta['status'], 'failed') + self.assertEqual(meta['error'], error) + + def test_error_categories_use_types_and_codes_without_raw_text(self): + import pymssql + cases = [(pymssql.OperationalError(18456, b'private_marker'), 'authentication_failed', False), + (pymssql.ProgrammingError(229, b'private_marker'), 'database_permission_denied', False), + (pymssql.ProgrammingError(208, b'private_marker'), 'query_rejected', False), + (pymssql.OperationalError(1205, b'private_marker'), 'database_busy', True), + (OSError(errno.ENOSPC, 'private_marker'), 'disk_full', False), + (PermissionError(errno.EACCES, 'private_marker'), 'storage_permission_denied', False), + (ConnectionResetError(errno.ECONNRESET, 'private_marker'), 'connection_interrupted', True), + (TimeoutError('private_marker'), 'database_timeout', False)] + for error, code, retryable in cases: + with self.subTest(code=code): + details = jobs.failure_details(error, 'connecting') + self.assertEqual(details['code'], code) + self.assertEqual(details['retryable'], retryable) + self.assertNotIn('private_marker', json.dumps(details)) + + def test_worker_start_error_is_private_and_releases_capacity(self): + with patch.object(backend, 'check_credentials'), patch.object(jobs.subprocess, 'Popen', side_effect=OSError('private_marker')): + with self.assertRaises(jobs.JobError) as raised: + self.store.submit('SELECT 1 AS example', {}, {}) + self.assertEqual(raised.exception.details['code'], 'worker_start_failed') + self.assertNotIn('private_marker', str(raised.exception)) + self.assertEqual(self.store.capacity()['active_jobs'], 0) + + def test_operation_outcomes_cover_legacy_failures_and_terminal_states(self): + data, _ = self.queued() + for state in ('failed', 'timed_out', 'interrupted'): + with self.subTest(state=state): + legacy = dict(data, state=state, error='private_marker') + operation = jobs.operation_metadata(PACKAGE, legacy) + self.assertEqual(operation['status'], 'failed') + self.assertEqual(operation['id'], PACKAGE + ':' + data['job_id']) + self.assertEqual(set(operation['error']), {'code', 'kind', 'message', 'phase', 'retryable'}) + self.assertNotIn('private_marker', json.dumps(operation)) + for state in ('queued', 'running', 'completed', 'cancelled'): + with self.subTest(state=state): + operation = jobs.operation_metadata(PACKAGE, dict(data, state=state)) + self.assertEqual(operation['status'], state) + self.assertNotIn('error', operation) + + def test_mcp_preserves_semantic_operation_metadata(self): + async def run(): + from fastmcp import Client, FastMCP + mcp = FastMCP('synthetic contract') + jobs.register_job_tools(mcp, PACKAGE, {}, prefix='fixture-', store=self.store) + async with Client(mcp) as client: + tools = {tool.name: tool for tool in await client.list_tools()} + self.assertEqual(set(tools['fixture-read_query_job_result'].input_schema['properties']), {'job_id', 'offset', 'max_bytes'}) + self.assertIn('timeout_seconds', tools['fixture-wait_for_query_job'].input_schema['properties']) + with patch.object(backend, 'check_credentials'), patch.object(jobs.subprocess, 'Popen'): + submitted = await client.call_tool('fixture-submit_query_job', {'query': 'SELECT 1 AS example'}) + data = json.loads(submitted.content[0].text) + self.assertEqual(submitted.meta[jobs.OPERATION_META_KEY]['status'], 'queued') + failed = self.rewrite(data, state='failed', error='Safe failure', error_details=dict(code='fixture_failed', kind='tool_failure', message='Safe failure', phase='executing', retryable=False)) + status = await client.call_tool('fixture-query_job_status', {'job_id': failed['job_id']}) + self.assertFalse(status.is_error) + self.assertEqual(status.meta[jobs.OPERATION_META_KEY]['status'], 'failed') + self.assertEqual(status.meta[jobs.OPERATION_META_KEY]['error']['code'], 'fixture_failed') + waited = await client.call_tool('fixture-wait_for_query_job', {'job_id': failed['job_id'], 'timeout_seconds': 0}) + self.assertEqual(waited.meta, status.meta) + unknown = await client.call_tool('fixture-query_job_status', {'job_id': '0' * 32}, raise_on_error=False) + self.assertTrue(unknown.is_error) + self.assertEqual(unknown.structured_content['error']['kind'], 'not_found') + capacity = await client.call_tool('fixture-query_job_capacity', {}) + self.assertEqual(capacity.structured_content['available_slots'], 2) + asyncio.run(run()) + + @unittest.skipUnless(SECURE_READER, 'secure POSIX file opens required') + def test_reader_reassembles_utf8_chunks_and_ignores_forged_path(self): + done = self.completed('α😀漢字' * 5) + original = Path(done['result_path']).read_text() + self.rewrite(done, result_path='/not/a/result') + for limit in range(4, 15): + offset, parts = 0, [] + while True: + chunk = self.store.read_result(done['job_id'], offset, limit) + parts.append(chunk['text']) + self.assertLessEqual(chunk['bytes_read'], limit) + if chunk['eof']: + self.assertIsNone(chunk['next_offset']) + break + self.assertGreater(chunk['next_offset'], offset) + offset = chunk['next_offset'] + self.assertEqual(''.join(parts), original) + end = self.store.read_result(done['job_id'], len(original.encode())) + self.assertEqual(end['text'], '') + self.assertTrue(end['eof']) + + @unittest.skipUnless(SECURE_READER, 'secure POSIX file opens required') + def test_reader_rejects_bad_offsets_limits_and_noncompleted_jobs(self): + done = self.completed('α') + for offset, limit in [(-1, 4), (True, 4), (0, 3), (0, 65537), (0, True), (9999, 4)]: + with self.subTest(offset=offset, limit=limit), self.assertRaises(jobs.JobError): + self.store.read_result(done['job_id'], offset, limit) + raw = Path(done['result_path']).read_bytes() + with self.assertRaises(jobs.JobError): + self.store.read_result(done['job_id'], raw.index('α'.encode()) + 1) + for state in ['queued', 'running', 'failed', 'cancelled', 'timed_out', 'interrupted']: + self.rewrite(done, state=state) + with self.subTest(state=state), self.assertRaises(jobs.JobError): + self.store.read_result(done['job_id']) + + @unittest.skipUnless(SECURE_READER, 'secure POSIX file opens required') + def test_reader_rejects_missing_changed_and_invalid_files(self): + done = self.completed() + path = Path(done['result_path']) + path.write_bytes(b'\xff' * done['bytes']) + with self.assertRaises(jobs.JobError) as raised: + self.store.read_result(done['job_id']) + self.assertEqual(raised.exception.details['code'], 'invalid_result_encoding') + path.write_text('changed') + with self.assertRaises(jobs.JobError): + self.store.read_result(done['job_id']) + path.unlink() + with self.assertRaises(jobs.JobError): + self.store.read_result(done['job_id']) + self.rewrite(done, format='../outside') + with self.assertRaises(jobs.JobError): + self.store.read_result(done['job_id']) + other = jobs.JobStore('other_connector', self.temp.name) + with self.assertRaises(jobs.JobError): + other.read_result(done['job_id']) + + @unittest.skipUnless(SECURE_READER, 'secure POSIX file opens required') + def test_reader_refuses_symlinks_hardlinks_and_fifo(self): + done = self.completed() + path = Path(done['result_path']) + outside = Path(self.temp.name) / 'outside' + outside.write_text('must not be read') + path.unlink() + path.symlink_to(outside) + with self.assertRaises(jobs.JobError): + self.store.read_result(done['job_id']) + path.unlink() + os.link(outside, path) + with self.assertRaises(jobs.JobError): + self.store.read_result(done['job_id']) + path.unlink() + os.mkfifo(path) + original_open = os.open + def checked_open(filename, flags, *args, **kwargs): + if filename == 'result.jsonl': + self.assertTrue(flags & os.O_NONBLOCK, 'opening an untrusted FIFO must not block') + return original_open(filename, flags, *args, **kwargs) + with patch.object(jobs.os, 'open', checked_open), patch.object(jobs.os, 'supports_dir_fd', {checked_open}): + with self.assertRaises(jobs.JobError): + self.store.read_result(done['job_id']) + + @unittest.skipUnless(SECURE_READER, 'secure POSIX file opens required') + def test_reader_refuses_job_directory_symlink(self): + done = self.completed() + directory = self.store.directory(done['job_id']) + moved = directory.with_name('moved') + directory.rename(moved) + directory.symlink_to(moved, target_is_directory=True) + with self.assertRaises(jobs.JobError): + self.store.read_result(done['job_id']) + + @unittest.skipUnless(SECURE_READER, 'secure POSIX file opens required') + def test_reader_refuses_replacement_of_the_pinned_root(self): + done = self.completed() + root = self.store.root + moved = root.with_name(root.name + '-moved') + root.rename(moved) + shutil.copytree(moved, root) + with self.assertRaises(jobs.JobError) as raised: + self.store.read_result(done['job_id']) + self.assertEqual(raised.exception.details['code'], 'result_scope_changed') + + @unittest.skipUnless(SECURE_READER, 'secure POSIX file opens required') + def test_reader_returns_eof_for_an_empty_complete_result(self): + data, payload = self.queued() + done = worker.execute(self.store, data['job_id'], payload, lambda *args: iter([])) + chunk = self.store.read_result(done['job_id']) + self.assertEqual(chunk['text'], '') + self.assertEqual(chunk['total_bytes'], 0) + self.assertEqual(chunk['bytes_read'], 0) + self.assertTrue(chunk['eof']) + self.assertIsNone(chunk['next_offset']) + + def test_reader_fails_closed_without_secure_open_support(self): + done = self.completed() + with patch.object(jobs.os, 'supports_dir_fd', set()), self.assertRaises(jobs.JobError) as raised: + self.store.read_result(done['job_id']) + self.assertEqual(raised.exception.details['code'], 'secure_reader_unavailable') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_mcp.py b/tests/test_mcp.py index c9d4970..e8c2014 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -17,11 +17,11 @@ async def run(): server = importlib.import_module(PACKAGE + '.server') if PACKAGE == 'ucsfomopagent': mcp = server.create_ucsf_omop_server(server.UCSFOMOPConfig(username='test',password='test')) - prefix, count = 'omop-', 8 + prefix, count = 'omop-', 11 elif PACKAGE == 'cdwagent': from cdwagent.config import CDWConfig, ClinicalDBConfig mcp = server.create_cdw_server(CDWConfig(clinical_db=ClinicalDBConfig(username='test',password='test'))) - prefix, count = 'CDW-', 25 + prefix, count = 'CDW-', 28 else: mcp = server.create_spoke_server(server.SPOKEConfig(uri='bolt://localhost:7687',username='test',password='test',database='neo4j')) prefix, count = 'spoke-', 8 @@ -29,7 +29,7 @@ async def run(): tools = await client.list_tools() names = {t.name for t in tools} self.assertEqual(len(names), count, names) - self.assertTrue({prefix+n for n in ['submit_query_job','query_job_status','cancel_query_job']} <= names) + self.assertTrue({prefix+n for n in ['submit_query_job','query_job_capacity','query_job_status','wait_for_query_job','read_query_job_result','cancel_query_job']} <= names) submit = next(t for t in tools if t.name == prefix+'submit_query_job') self.assertNotIn('credentials', submit.input_schema['properties']) query = 'SELECT 1 AS value' if PACKAGE != 'spokeagent' else 'RETURN 1 AS value' diff --git a/tests/test_synthetic_job_server.py b/tests/test_synthetic_job_server.py new file mode 100644 index 0000000..77900b1 --- /dev/null +++ b/tests/test_synthetic_job_server.py @@ -0,0 +1,159 @@ +import importlib +import importlib.util +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from fastmcp import Client + +SCRIPT = Path(__file__).resolve().parents[1] / 'scripts' / 'synthetic_job_server.py' +SPEC = importlib.util.spec_from_file_location('synthetic_job_server', SCRIPT) +fixture = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(fixture) +backend = importlib.import_module(fixture.PACKAGE + '.query_backend') +SECURE_READER = (hasattr(os, 'O_NOFOLLOW') and hasattr(os, 'O_DIRECTORY') + and os.open in os.supports_dir_fd) + + +class SyntheticJobServerTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.guards = [] + for target, name in [(backend, 'stream'), (backend, 'from_environment'), + (fixture.jobs.subprocess, 'Popen')]: + guard = patch.object(target, name, side_effect=AssertionError('Real backend access forbidden')) + mocked = guard.start() + self.addCleanup(guard.stop) + self.guards.append(mocked) + self.server, self.store = fixture.create_server(Path(self.temp.name), delay_seconds=30) + self.addCleanup(self.store.close) + + async def asyncTearDown(self): + self.assertTrue(self.store.close(), 'Synthetic workers must stop before fixture cleanup') + for guard in self.guards: + guard.assert_not_called() + + async def submit(self, client, query, **arguments): + return await client.call_tool(fixture.PREFIX + 'submit_query_job', + dict(query=query, **arguments), raise_on_error=False) + + async def wait(self, client, job_id): + return await client.call_tool(fixture.PREFIX + 'wait_for_query_job', + dict(job_id=job_id, timeout_seconds=5)) + + @unittest.skipUnless(SECURE_READER, 'Secure result reader requires POSIX no-follow opens') + async def test_success_wait_metadata_and_bounded_multilingual_result(self): + async with Client(self.server) as client: + submitted = await self.submit(client, fixture.EXAMPLE_QUERY, format='csv') + self.assertFalse(submitted.is_error) + job_id = submitted.structured_content['job_id'] + self.assertEqual(submitted.meta[fixture.jobs.OPERATION_META_KEY]['id'], + fixture.PACKAGE + ':' + job_id) + completed = await self.wait(client, job_id) + self.assertFalse(completed.is_error) + self.assertEqual(completed.structured_content['state'], 'completed') + self.assertFalse(completed.structured_content['wait_timed_out']) + self.assertEqual(completed.meta[fixture.jobs.OPERATION_META_KEY]['status'], 'completed') + offset, parts = 0, [] + while True: + result = await client.call_tool(fixture.PREFIX + 'read_query_job_result', + dict(job_id=job_id, offset=offset, max_bytes=4)) + chunk = result.structured_content + self.assertLessEqual(chunk['bytes_read'], 4) + parts.append(chunk['text']) + if chunk['eof']: + self.assertIsNone(chunk['next_offset']) + break + self.assertGreater(chunk['next_offset'], offset) + offset = chunk['next_offset'] + import csv + import io + rows = list(csv.reader(io.StringIO(''.join(parts), newline=''))) + self.assertEqual(rows, [['example', 'label']] + + [[str(number), label] for number, label in fixture.EXAMPLE_ROWS]) + + async def test_safe_failure_is_an_operation_outcome(self): + async with Client(self.server) as client: + submitted = await self.submit(client, fixture.FAILURE_QUERY) + self.assertFalse(submitted.is_error) + failed = await self.wait(client, submitted.structured_content['job_id']) + self.assertFalse(failed.is_error) + self.assertEqual(failed.structured_content['state'], 'failed') + operation = failed.meta[fixture.jobs.OPERATION_META_KEY] + self.assertEqual(operation['status'], 'failed') + self.assertEqual(operation['error']['code'], 'synthetic_failure') + self.assertEqual(operation['error']['kind'], 'tool_failure') + self.assertFalse(operation['error']['retryable']) + self.assertEqual(operation['error']['message'], 'Synthetic job failed as requested.') + self.assertIsNone(failed.structured_content['result_path']) + + async def test_two_slow_jobs_exhaust_capacity_without_starting_another(self): + async with Client(self.server) as client: + first = await self.submit(client, fixture.SLOW_QUERY) + second = await self.submit(client, fixture.SLOW_QUERY) + self.assertFalse(first.is_error) + self.assertFalse(second.is_error) + capacity = await client.call_tool(fixture.PREFIX + 'query_job_capacity', {}) + self.assertEqual(capacity.structured_content, + dict(active_jobs=2, max_active_jobs=2, available_slots=0)) + rejected = await self.submit(client, fixture.EXAMPLE_QUERY) + self.assertTrue(rejected.is_error) + self.assertEqual(rejected.structured_content['error']['code'], 'capacity_exceeded') + self.assertEqual(rejected.structured_content['capacity']['available_slots'], 0) + self.assertEqual(len(self.store.list_jobs()), 2) + pending = await client.call_tool(fixture.PREFIX + 'wait_for_query_job', + dict(job_id=first.structured_content['job_id'], timeout_seconds=0)) + self.assertTrue(pending.structured_content['wait_timed_out']) + self.assertIn(pending.meta[fixture.jobs.OPERATION_META_KEY]['status'], ('queued', 'running')) + self.store.release_slow_jobs() + for result in (first, second): + completed = await self.wait(client, result.structured_content['job_id']) + self.assertEqual(completed.structured_content['state'], 'completed') + capacity = await client.call_tool(fixture.PREFIX + 'query_job_capacity', {}) + self.assertEqual(capacity.structured_content['available_slots'], 2) + + async def test_unknown_queries_are_rejected_before_admission(self): + async with Client(self.server) as client: + for query in ('SELECT * FROM real_records', 'SELECT 1 AS example; SELECT 2', + 'SELECT 0 AS simulate_failure -- ignored'): + with self.subTest(query=query): + rejected = await self.submit(client, query) + self.assertTrue(rejected.is_error) + self.assertEqual(rejected.structured_content['error']['code'], + 'synthetic_query_rejected') + self.assertEqual(rejected.structured_content['error']['kind'], 'invalid_args') + rejected = await self.submit(client, fixture.EXAMPLE_QUERY, parameters={'unexpected': 1}) + self.assertEqual(rejected.structured_content['error']['code'], 'synthetic_query_rejected') + rejected = await self.submit(client, fixture.EXAMPLE_QUERY, mode='explain') + self.assertEqual(rejected.structured_content['error']['code'], 'synthetic_query_rejected') + self.assertEqual(self.store.list_jobs(), []) + + +class SyntheticConfigurationTests(unittest.TestCase): + def test_delay_is_finite_and_bounded(self): + for value in (-1, 31, float('nan'), float('inf'), True, 'not a number'): + with self.subTest(value=value), self.assertRaises(ValueError): + fixture.bounded_delay(value) + self.assertEqual(fixture.bounded_delay('2.5'), 2.5) + + def test_root_is_required_fresh_and_cannot_read_an_existing_store(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + marker = root / 'existing-private-data' + marker.write_text('synthetic marker') + with self.assertRaises(ValueError): + fixture.create_server(root) + self.assertEqual(list(root.iterdir()), [marker]) + with self.assertRaises(ValueError): + fixture.SyntheticJobStore(Path('relative-root')) + with patch.object(fixture, 'create_server') as create, patch('sys.stderr'): + with self.assertRaises(SystemExit): + fixture.main([]) + create.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_wait_contention.py b/tests/test_wait_contention.py new file mode 100644 index 0000000..b2b163d --- /dev/null +++ b/tests/test_wait_contention.py @@ -0,0 +1,121 @@ +import asyncio +import importlib +import sqlite3 +import tempfile +import threading +import time +import unittest +from unittest.mock import patch + +PACKAGE = 'cdwagent' +jobs = importlib.import_module(PACKAGE + '.jobs') +backend = importlib.import_module(PACKAGE + '.query_backend') + + +class WaitContentionTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.store = jobs.JobStore(PACKAGE, self.temp.name) + with patch.object(backend, 'check_credentials'), patch.object(jobs.subprocess, 'Popen'): + self.job = self.store.submit('SELECT 1 AS example', {}, {}) + + def exclusive_lock(self): + connection = sqlite3.connect(self.store.dbpath, timeout=0) + connection.execute('BEGIN EXCLUSIVE') + self.addCleanup(connection.close) + return connection + + def test_zero_wait_under_exclusive_lock_is_immediate_and_never_invents_a_job_state(self): + lock = self.exclusive_lock() + started = time.monotonic() + with self.assertRaises(jobs.JobError) as raised: + asyncio.run(self.store.wait(self.job['job_id'], 0)) + self.assertLess(time.monotonic() - started, 0.5) + details = raised.exception.details + self.assertEqual(details['code'], 'status_lookup_busy') + self.assertEqual(details['kind'], 'transient') + self.assertTrue(details['retryable']) + result = jobs.tool_failure(raised.exception) + self.assertTrue(result.is_error) + self.assertIsNone(result.meta) + lock.rollback() + self.assertEqual(self.store.status(self.job['job_id'])['state'], 'queued') + + def test_deadline_returns_the_last_observed_active_state_when_storage_becomes_busy(self): + observed = threading.Event() + original_status = self.store.status + + def observe(*args, **kwargs): + data = original_status(*args, **kwargs) + observed.set() + return data + + async def run(): + with patch.object(self.store, 'status', side_effect=observe): + waiting = asyncio.create_task(self.store.wait(self.job['job_id'], 1)) + while not observed.is_set(): + await asyncio.sleep(0.005) + lock = self.exclusive_lock() + try: + started = time.monotonic() + result = await asyncio.wait_for(waiting, 1.5) + self.assertLess(time.monotonic() - started, 1.3) + finally: + lock.rollback() + self.assertEqual(result['state'], 'queued') + self.assertTrue(result['wait_timed_out']) + self.assertTrue(result['wait_status_stale']) + self.assertIn('last successfully observed', result['wait_warning']) + self.assertEqual(jobs.operation_metadata(PACKAGE, result)['status'], 'queued') + self.assertEqual(original_status(self.job['job_id'])['state'], 'queued') + + asyncio.run(run()) + + def test_cancelled_wait_and_other_mcp_requests_remain_responsive_under_exclusive_lock(self): + async def run(): + from fastmcp import Client, FastMCP + mcp = FastMCP('locked job store') + jobs.register_job_tools(mcp, PACKAGE, {}, prefix='fixture-', store=self.store) + lookups = {'active': 0} + lookup_started = threading.Event() + original_status = self.store.status + counter_lock = threading.Lock() + + def observe(*args, **kwargs): + with counter_lock: + lookups['active'] += 1 + lookup_started.set() + try: + return original_status(*args, **kwargs) + finally: + with counter_lock: + lookups['active'] -= 1 + + lock = self.exclusive_lock() + try: + async with Client(mcp) as client: + with patch.object(self.store, 'status', side_effect=observe): + waiting = asyncio.create_task(client.call_tool('fixture-wait_for_query_job', {'job_id': self.job['job_id'], 'timeout_seconds': 30})) + while not lookup_started.is_set(): + await asyncio.sleep(0.005) + tools = await asyncio.wait_for(client.list_tools(), 0.5) + self.assertIn('fixture-query_job_status', {tool.name for tool in tools}) + started = time.monotonic() + waiting.cancel() + with self.assertRaises(asyncio.CancelledError): + await asyncio.wait_for(waiting, 0.5) + self.assertLess(time.monotonic() - started, 0.5) + deadline = time.monotonic() + 0.5 + while lookups['active'] and time.monotonic() < deadline: + await asyncio.sleep(0.005) + self.assertEqual(lookups['active'], 0, 'a cancelled wait leaves no blocked lookup thread') + finally: + lock.rollback() + self.assertEqual(self.store.status(self.job['job_id'])['state'], 'queued') + + asyncio.run(run()) + + +if __name__ == '__main__': + unittest.main() From dc267413e1c1f37c3764ddde124b508f2a218229 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 23 Oct 2026 21:34:40 -0700 Subject: [PATCH 02/14] fix: preserve failed job handoff and native CLI waiting --- docs/QUERY_JOBS.md | 14 ++++ extensions/SHA256SUMS | 2 +- extensions/cdwagent.brxt | Bin 299496 -> 299937 bytes src/cdwagent/jobs.py | 26 +++++--- src/cdwagent/query_cli.py | 5 +- tests/test_job_contract.py | 10 +-- tests/test_job_followup.py | 99 +++++++++++++++++++++++++++++ tests/test_synthetic_job_server.py | 2 +- 8 files changed, 140 insertions(+), 18 deletions(-) create mode 100644 tests/test_job_followup.py diff --git a/docs/QUERY_JOBS.md b/docs/QUERY_JOBS.md index 66fa56e..3877de5 100644 --- a/docs/QUERY_JOBS.md +++ b/docs/QUERY_JOBS.md @@ -203,3 +203,17 @@ Security updates remove the former Intel-macOS cryptography<49 constraint. Patched cryptography 50+ is required. If a compatible Intel macOS wheel is not available, install the upstream Rust/OpenSSL build prerequisites or use a supported Python/platform combination; do not restore a vulnerable version cap. + +## Submission and CLI follow-up semantics + +Capacity responses and rejection context include `advisory: true`: another client +may consume an available slot before submission. Atomic admission remains the +authority. Once a job ID has been allocated, a local worker-start or pipe failure +returns that durable ID with failed operation metadata; it does not hide the job +behind a generic submission error. No active slot remains allocated to that failure. + +CLI `watch JOB_ID` uses the same native bounded wait as MCP instead of a separate +sleep/poll loop. The wait remains at most 30 seconds per call and can be repeated +until terminal. `status JOB_ID` remains an immediate lookup. Terminal payload +`phase` is the terminal state, while `error_details.phase` identifies where the +failure actually occurred (for example `executing` or `worker_start`). diff --git a/extensions/SHA256SUMS b/extensions/SHA256SUMS index 2f6faa7..3aebd03 100644 --- a/extensions/SHA256SUMS +++ b/extensions/SHA256SUMS @@ -1 +1 @@ -faeddcc454e5f12fb526334bd6592201edfbad9511dcdc5a871cb50387ccfc5c cdwagent.brxt +3c9b21110f172e827dfe97705336dd29fe231ed86514d653bdc069bc0516178e cdwagent.brxt diff --git a/extensions/cdwagent.brxt b/extensions/cdwagent.brxt index 811d921600022c61212f5e30579738a843b5e9ed..25f5aa234b56c233a35b08b85beebd93d94a7819 100644 GIT binary patch delta 17087 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%0AbVQEvCy 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 A?f?J) diff --git a/src/cdwagent/jobs.py b/src/cdwagent/jobs.py index 3ad5d00..38ae406 100644 --- a/src/cdwagent/jobs.py +++ b/src/cdwagent/jobs.py @@ -1,7 +1,7 @@ """Durable, bounded-memory query jobs shared by the OMOP and CDW connectors.""" from __future__ import annotations -from contextlib import ExitStack, contextmanager +from contextlib import ExitStack, contextmanager, suppress import asyncio import codecs import errno @@ -112,7 +112,9 @@ def tool_failure(error, phase='request'): prior identical queries or expected_rows are advisory, never guarantees. Only completed means the result file is complete; partial files are incomplete. Use cancel_query_job to stop unwanted work. Do not resubmit while a job is active. -Do not repeatedly retry failures: inspect schema/plan and narrow the query first. +Inspect error_details.code, phase and retryable before explicitly resubmitting. +Fix credentials, connectivity, permissions or disk conditions rather than blindly +rewriting SQL. Revise the query only when its schema, syntax or plan is the cause. Jobs survive chat/MCP disconnects, but not host reboot; interrupted queries require an explicit new submission. There is no unsafe automatic OFFSET resume or retry. Use read_query_job_result with the completed job ID for bounded UTF-8 chunks; @@ -259,7 +261,7 @@ def _status(self, job_id, deadline): def capacity(self, *, database_timeout=10): with self.connect(timeout=database_timeout) as db: active = db.execute("SELECT COUNT(*) FROM jobs WHERE state IN ('queued','running')").fetchone()[0] - return dict(active_jobs=active, max_active_jobs=MAX_ACTIVE_JOBS, available_slots=max(0, MAX_ACTIVE_JOBS - active)) + return dict(active_jobs=active, max_active_jobs=MAX_ACTIVE_JOBS, available_slots=max(0, MAX_ACTIVE_JOBS - active), advisory=True) async def wait(self, job_id, timeout_seconds=30): if type(timeout_seconds) is not int or not 0 <= timeout_seconds <= MAX_WAIT_SECONDS: @@ -385,7 +387,7 @@ def submit(self, query, parameters, credentials, *, mode='query', format='jsonl' # Stale workers retain their slot until the operator checks their status. active = db.execute("SELECT COUNT(*) FROM jobs WHERE state IN ('queued','running')").fetchone()[0] if active >= MAX_ACTIVE_JOBS: - raise JobError('capacity_exceeded', 'transient', 'Two jobs are already active. Wait for completion or cancel unwanted work before submitting another.', 'admission', retryable=True, capacity=dict(active_jobs=active, max_active_jobs=MAX_ACTIVE_JOBS, available_slots=0)) + raise JobError('capacity_exceeded', 'transient', 'Two jobs are already active. Wait for completion or cancel unwanted work before submitting another.', 'admission', retryable=True, capacity=dict(active_jobs=active, max_active_jobs=MAX_ACTIVE_JOBS, available_slots=0, advisory=True)) history = db.execute("SELECT data FROM jobs WHERE fingerprint=? AND state='completed' ORDER BY updated DESC LIMIT 5", (fingerprint,)).fetchall() if history: data['historical_seconds'] = statistics.median(json.loads(r[0])['finished_at'] - json.loads(r[0])['created_at'] for r in history) @@ -397,7 +399,6 @@ def submit(self, query, parameters, credentials, *, mode='query', format='jsonl' details = dict(code='worker_start_failed', kind='internal', message='Could not start the local worker.', phase='worker_start', retryable=False) record_failure(data, details) self.update(data) - raise JobError(**details) from None return self.status(job_id) def _start_worker(self, job_id, request, credentials): @@ -406,10 +407,19 @@ def _start_worker(self, job_id, request, credentials): env['UCSF_AGENT_JOB_ROOT'] = str(self.root.parent) args = [sys.executable, '-m', f'{self.package}.job_worker', job_id] options = {'start_new_session': True} if os.name != 'nt' else {'creationflags': subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS} + wire = json.dumps(dict(request=request, credentials=credentials)).encode() process = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env, **options) - process.stdin.write(json.dumps(dict(request=request, credentials=credentials)).encode()) - process.stdin.close() - threading.Thread(target=process.wait, daemon=True).start() + try: + process.stdin.write(wire) + process.stdin.close() + except Exception: + with suppress(OSError): + if process.stdin is not None: + process.stdin.close() + raise + finally: + with suppress(RuntimeError): + threading.Thread(target=process.wait, daemon=True).start() def register_job_tools(mcp, package, credentials, prefix='', *, store=None): diff --git a/src/cdwagent/query_cli.py b/src/cdwagent/query_cli.py index f7b7af8..95094a1 100644 --- a/src/cdwagent/query_cli.py +++ b/src/cdwagent/query_cli.py @@ -1,12 +1,12 @@ """CLI front end for the same jobs exposed over MCP.""" import argparse +import asyncio import getpass import importlib import json import os from pathlib import Path import sys -import time from .jobs import JobStore, TERMINAL @@ -78,11 +78,10 @@ def main(argv=None): data = store.cancel(args.job_id) else: while True: - data = store.status(args.job_id) + data = asyncio.run(store.wait(args.job_id)) if args.command == 'watch' else store.status(args.job_id) if args.command != 'watch' or data['state'] in TERMINAL: break print(json.dumps(data), flush=True) - time.sleep(data['recommended_poll_seconds']) print(json.dumps(data), flush=True) return 1 if data['state'] in ('failed', 'timed_out', 'cancelled', 'interrupted') else 0 except (ValueError, RuntimeError) as error: diff --git a/tests/test_job_contract.py b/tests/test_job_contract.py index 45fbb9e..9fc0ffb 100644 --- a/tests/test_job_contract.py +++ b/tests/test_job_contract.py @@ -43,7 +43,7 @@ def rewrite(self, data, **changes): return data def test_capacity_is_discoverable_and_rejection_is_structured(self): - self.assertEqual(self.store.capacity(), dict(active_jobs=0, max_active_jobs=2, available_slots=2)) + self.assertEqual(self.store.capacity(), dict(active_jobs=0, max_active_jobs=2, available_slots=2, advisory=True)) first, payload = self.queued() self.queued() self.assertEqual(self.store.status(first['job_id'])['capacity']['available_slots'], 0) @@ -121,10 +121,10 @@ def test_error_categories_use_types_and_codes_without_raw_text(self): def test_worker_start_error_is_private_and_releases_capacity(self): with patch.object(backend, 'check_credentials'), patch.object(jobs.subprocess, 'Popen', side_effect=OSError('private_marker')): - with self.assertRaises(jobs.JobError) as raised: - self.store.submit('SELECT 1 AS example', {}, {}) - self.assertEqual(raised.exception.details['code'], 'worker_start_failed') - self.assertNotIn('private_marker', str(raised.exception)) + data = self.store.submit('SELECT 1 AS example', {}, {}) + self.assertEqual(data['error_details']['code'], 'worker_start_failed') + self.assertEqual(data['state'], 'failed') + self.assertNotIn('private_marker', json.dumps(data)) self.assertEqual(self.store.capacity()['active_jobs'], 0) def test_operation_outcomes_cover_legacy_failures_and_terminal_states(self): diff --git a/tests/test_job_followup.py b/tests/test_job_followup.py new file mode 100644 index 0000000..aebd452 --- /dev/null +++ b/tests/test_job_followup.py @@ -0,0 +1,99 @@ +"""Offline regressions for capacity discovery and worker-start handoff.""" +import asyncio +from contextlib import redirect_stdout +import importlib +import io +import json +import tempfile +import unittest +from unittest.mock import AsyncMock, Mock, patch + +PACKAGE = 'cdwagent' +jobs = importlib.import_module(PACKAGE + '.jobs') +backend = importlib.import_module(PACKAGE + '.query_backend') + + +class FollowupTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.store = jobs.JobStore(PACKAGE, self.temp.name) + + def test_capacity_snapshots_explicitly_remain_advisory(self): + self.assertIs(self.store.capacity()['advisory'], True) + with patch.object(backend, 'check_credentials'), patch.object(jobs.subprocess, 'Popen'): + data = self.store.submit('SELECT 1 AS example', {}, {}) + self.store.submit('SELECT 1 AS example', {}, {}) + with self.assertRaises(jobs.JobError) as caught: + self.store.submit('SELECT 1 AS example', {}, {}) + self.assertIs(self.store.status(data['job_id'])['capacity']['advisory'], True) + self.assertIs(caught.exception.context['capacity']['advisory'], True) + + def test_cli_watch_reuses_native_wait(self): + cli = importlib.import_module(PACKAGE + '.query_cli') + queued = dict(job_id='a'*32, state='queued') + completed = dict(queued, state='completed') + with patch.object(cli, 'JobStore') as constructor, redirect_stdout(io.StringIO()): + store = constructor.return_value + store.status.return_value = completed + store.wait = AsyncMock(side_effect=[queued, completed]) + self.assertEqual(cli.main(['watch', queued['job_id']]), 0) + self.assertEqual(store.wait.await_count, 2) + store.wait.assert_awaited_with(queued['job_id']) + store.status.assert_not_called() + + def test_failed_start_returns_durable_id_and_failed_operation(self): + with patch.object(backend, 'check_credentials'), patch.object(jobs.subprocess, 'Popen', side_effect=OSError('private-start-detail')): + data = self.store.submit('SELECT 1 AS example', {}, {}) + self.assertEqual(self.store.status(data['job_id'])['state'], 'failed') + self.assertEqual(self.store.capacity()['active_jobs'], 0) + result = jobs.tool_result(data, PACKAGE) + self.assertFalse(result.is_error) + self.assertEqual(result.meta[jobs.OPERATION_META_KEY]['id'], PACKAGE + ':' + data['job_id']) + self.assertEqual(result.meta[jobs.OPERATION_META_KEY]['status'], 'failed') + self.assertNotIn('private-start-detail', result.model_dump_json()) + + def test_failed_pipe_cleanup_does_not_hide_id_or_leak_capacity(self): + process = Mock() + process.stdin.write.side_effect = BrokenPipeError('private-write-detail') + process.stdin.close.side_effect = BrokenPipeError('private-close-detail') + with patch.object(backend, 'check_credentials'), patch.object(jobs.subprocess, 'Popen', return_value=process): + data = self.store.submit('SELECT 1 AS example', {}, {}) + self.assertEqual(data['error_details']['code'], 'worker_start_failed') + self.assertEqual(self.store.status(data['job_id'])['state'], 'failed') + self.assertEqual(self.store.capacity()['available_slots'], 2) + process.stdin.close.assert_called_once() + self.assertNotIn('private-write-detail', json.dumps(data)) + self.assertNotIn('private-close-detail', json.dumps(data)) + + def test_missing_pipe_still_returns_a_failed_job_id(self): + process = Mock(stdin=None) + with patch.object(backend, 'check_credentials'), patch.object(jobs.subprocess, 'Popen', return_value=process): + data = self.store.submit('SELECT 1 AS example', {}, {}) + self.assertEqual(data['state'], 'failed') + self.assertEqual(self.store.capacity()['active_jobs'], 0) + + def test_reaper_thread_failure_does_not_fail_a_started_worker(self): + with patch.object(backend, 'check_credentials'), patch.object(jobs.subprocess, 'Popen'), patch.object(jobs.threading.Thread, 'start', side_effect=RuntimeError('private-thread-detail')): + data = self.store.submit('SELECT 1 AS example', {}, {}) + self.assertEqual(data['state'], 'queued') + self.assertEqual(self.store.capacity()['active_jobs'], 1) + self.assertNotIn('private-thread-detail', json.dumps(data)) + + def test_real_mcp_start_failure_observes_allocated_failed_operation(self): + async def run(): + from fastmcp import Client, FastMCP + mcp = FastMCP('startup failure') + jobs.register_job_tools(mcp, PACKAGE, {}, prefix='fixture-', store=self.store) + async with Client(mcp) as client: + with patch.object(backend, 'check_credentials'), patch.object(jobs.subprocess, 'Popen', side_effect=OSError('private-start-detail')): + result = await client.call_tool('fixture-submit_query_job', {'query': 'SELECT 1 AS example'}, raise_on_error=False) + self.assertFalse(result.is_error) + data = json.loads(result.content[0].text) + outcome = result.meta[jobs.OPERATION_META_KEY] + self.assertEqual(outcome['id'], PACKAGE + ':' + data['job_id']) + self.assertEqual(outcome['status'], 'failed') + self.assertEqual(outcome['error']['phase'], 'worker_start') + self.assertNotIn('capacity', outcome) + self.assertEqual(self.store.capacity()['active_jobs'], 0) + asyncio.run(run()) diff --git a/tests/test_synthetic_job_server.py b/tests/test_synthetic_job_server.py index 77900b1..56fcb13 100644 --- a/tests/test_synthetic_job_server.py +++ b/tests/test_synthetic_job_server.py @@ -98,7 +98,7 @@ async def test_two_slow_jobs_exhaust_capacity_without_starting_another(self): self.assertFalse(second.is_error) capacity = await client.call_tool(fixture.PREFIX + 'query_job_capacity', {}) self.assertEqual(capacity.structured_content, - dict(active_jobs=2, max_active_jobs=2, available_slots=0)) + dict(active_jobs=2, max_active_jobs=2, available_slots=0, advisory=True)) rejected = await self.submit(client, fixture.EXAMPLE_QUERY) self.assertTrue(rejected.is_error) self.assertEqual(rejected.structured_content['error']['code'], 'capacity_exceeded') From 42c6fd6fac120c2a63ffcfdb28245b47d7e59ac2 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 23 Oct 2026 21:51:35 -0700 Subject: [PATCH 03/14] Protect durable job storage with owner-private descriptor repairs Refs #7. Preserve the six-tool contract and synchronous startup on storage errors. Windows handle backend follows separately. --- docs/QUERY_JOBS.md | 19 +++- src/cdwagent/job_worker.py | 6 +- src/cdwagent/jobs.py | 53 +++++---- src/cdwagent/private_storage.py | 194 ++++++++++++++++++++++++++++++++ tests/test_private_storage.py | 156 +++++++++++++++++++++++++ 5 files changed, 405 insertions(+), 23 deletions(-) create mode 100644 src/cdwagent/private_storage.py create mode 100644 tests/test_private_storage.py diff --git a/docs/QUERY_JOBS.md b/docs/QUERY_JOBS.md index 3877de5..0fac711 100644 --- a/docs/QUERY_JOBS.md +++ b/docs/QUERY_JOBS.md @@ -47,8 +47,15 @@ malicious code running under the same OS account. ## Storage and progress Jobs live in `~/.local/share/ucsf-agents/cdwagent/` (override the parent using -UCSF_AGENT_JOB_ROOT). Directories are private (0700) and DB/results are 0600 on -POSIX. On Windows use the account's private profile directory and its ACLs. +UCSF_AGENT_JOB_ROOT). The storage backend checks current-account ownership, +rejects symlinks, nonregular entries and hard-linked private files, and repairs +existing owned stores before reading them. On macOS/Linux it uses retained +no-follow descriptors, modes 0700/0600 and removal of inherited ACL grants. +New result files are protected before any result bytes are written. Windows uses +a protected current-user DACL through retained no-reparse handles. Unsupported +or unsafe storage returns an actionable `private_storage_unavailable` error from +the job tools; synchronous connector tools remain available. Normal job use +requires no additional prompts or changes to the six-tool interface. SQLite serializes admission (two active jobs per connector). Results stream in 1000-row batches to a `.partial` file and are renamed only after success and fsync. The default disk budget is 10 GB; exceeding it fails visibly rather than @@ -57,6 +64,14 @@ and JSON-encodes graph/list values. Use JSONL when empty string and null must be distinguished. SQL projections require unique column aliases. Exported text may contain spreadsheet formula strings: treat it as data when importing CSV. +These permissions protect against other ordinary local accounts where filesystem +ACLs are enforced; they do not revoke file handles another process opened before +an old store was repaired. They do not isolate arbitrary code running under the +same OS account, administrators or root. Choose storage beneath a private or +administrator-controlled parent directory. SQLite and process-lease/final-rename +operations still use pathnames after identity checks; this update does not claim +to eliminate every pathname replacement race in the complete job runtime. + Status records queued/running/terminal states and separate connecting/planning/ executing/streaming phases, rows written, bytes, heartbeat, elapsed time and advisory ETA. A heartbeat proves diff --git a/src/cdwagent/job_worker.py b/src/cdwagent/job_worker.py index f95d769..8836b72 100644 --- a/src/cdwagent/job_worker.py +++ b/src/cdwagent/job_worker.py @@ -9,6 +9,7 @@ import time from .jobs import JobStore, ACTIVE, failure_details, record_failure +from .private_storage import private_storage_call def execute(store, job_id, payload, stream): @@ -29,8 +30,9 @@ def execute(store, job_id, payload, stream): def run(): try: - with partial.open('xb') as out: - os.chmod(partial, 0o600) + with private_storage_call(lambda: store._storage.child(job_id)) as private_output, private_storage_call( + lambda: private_output.open_file(partial.name, exclusive=True) + ) as out: csv_buffer = io.StringIO(newline='') writer = csv.writer(csv_buffer) if request['format'] == 'csv' else None header = False diff --git a/src/cdwagent/jobs.py b/src/cdwagent/jobs.py index 38ae406..7fecbb4 100644 --- a/src/cdwagent/jobs.py +++ b/src/cdwagent/jobs.py @@ -21,6 +21,8 @@ from filelock import FileLock, Timeout from mcp.types import CallToolResult +from .private_storage import PrivateStorageError, private_directory, private_storage_call + ACTIVE = ('queued', 'running') TERMINAL = ('completed', 'failed', 'cancelled', 'timed_out', 'interrupted') MAX_ACTIVE_JOBS = 2 @@ -40,6 +42,8 @@ def __init__(self, code, kind, message, phase, retryable=False, **context): def failure_details(error, phase): if isinstance(error, JobError): return error.details + if isinstance(error, PrivateStorageError): + return dict(code='private_storage_unavailable', kind='permission_denied', message=str(error), phase=phase, retryable=False) code, kind, message, retryable = 'query_failed', 'tool_failure', 'Database execution or result processing failed. Check the query plan, schema and selected output format.', False if isinstance(error, OverflowError): code, message = 'output_limit_exceeded', 'Output byte budget exceeded; narrow the export or explicitly raise max_bytes.' @@ -164,31 +168,24 @@ def strip(m): def private_dir(path: Path) -> Path: - if path.is_symlink(): - raise ValueError('Job directories must not be symbolic links.') - path.mkdir(parents=True, exist_ok=True, mode=0o700) - path.chmod(0o700) - return path + with private_directory(path) as directory: + return directory.path class JobStore: def __init__(self, package: str, root: str | Path | None = None): self.package = package - self.root = private_dir(Path(root or os.environ.get('UCSF_AGENT_JOB_ROOT', Path.home() / '.local/share/ucsf-agents')) / package) - self.root = self.root.resolve() - root_stat = self.root.stat() - self._root_identity = (root_stat.st_dev, root_stat.st_ino) + self._storage = private_directory(Path(root or os.environ.get('UCSF_AGENT_JOB_ROOT', Path.home() / '.local/share/ucsf-agents')) / package) + self.root = self._storage.path + self._root_identity = self._storage.identity self.dbpath = self.root / 'jobs.sqlite3' - if self.dbpath.is_symlink(): - raise ValueError('Job database must not be a symbolic link.') - fd = os.open(self.dbpath, os.O_CREAT | os.O_RDWR, 0o600) - os.close(fd) with self.connect() as db: db.execute('CREATE TABLE IF NOT EXISTS jobs (id TEXT PRIMARY KEY, state TEXT, updated REAL, fingerprint TEXT, data TEXT)') - self.dbpath.chmod(0o600) @contextmanager def connect(self, timeout=10): + with private_storage_call(lambda: self._storage.open_file('jobs.sqlite3')): + pass db = sqlite3.connect(self.dbpath, timeout=timeout) try: with db: @@ -292,6 +289,10 @@ async def wait(self, job_id, timeout_seconds=30): def read_result(self, job_id, offset=0, max_bytes=8192): if type(offset) is not int or offset < 0 or type(max_bytes) is not int or not 4 <= max_bytes <= MAX_RESULT_BYTES: raise JobError('invalid_result_chunk', 'invalid_args', 'offset must be a nonnegative byte offset; max_bytes must be 4..65536.', 'result_read') + try: + self._storage.verify() + except (OSError, ValueError): + raise JobError('result_scope_changed', 'permission_denied', 'The managed job directory changed or cannot be secured; result access was refused.', 'result_read') from None data = self.status(job_id) if data['state'] != 'completed': raise JobError('result_not_complete', 'invalid_args', 'Only completed job results can be read.', 'result_read') @@ -301,7 +302,7 @@ def read_result(self, job_id, offset=0, max_bytes=8192): raise JobError('secure_reader_unavailable', 'internal', 'This platform does not support the secure result reader; use the local CLI result file.', 'result_read') try: with ExitStack() as stack: - root_fd = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + root_fd = self._storage.duplicate_fd() stack.callback(os.close, root_fd) root_stat = os.fstat(root_fd) if (root_stat.st_dev, root_stat.st_ino) != self._root_identity: @@ -357,7 +358,9 @@ def purge(self, job_id): def cancel(self, job_id): data = self.status(job_id) if data['state'] in ACTIVE: - (self.directory(job_id) / 'cancel').touch(mode=0o600) + with private_storage_call(lambda: self._storage.child(job_id)) as directory: + with private_storage_call(lambda: directory.open_file('cancel')): + pass data['cancel_requested'] = True return data @@ -391,7 +394,8 @@ def submit(self, query, parameters, credentials, *, mode='query', format='jsonl' history = db.execute("SELECT data FROM jobs WHERE fingerprint=? AND state='completed' ORDER BY updated DESC LIMIT 5", (fingerprint,)).fetchall() if history: data['historical_seconds'] = statistics.median(json.loads(r[0])['finished_at'] - json.loads(r[0])['created_at'] for r in history) - private_dir(self.directory(job_id)) + with private_storage_call(lambda: self._storage.child(job_id)): + pass db.execute('INSERT INTO jobs VALUES (?,?,?,?,?)', (job_id, 'queued', now, fingerprint, json.dumps(data))) try: self._start_worker(job_id, request, credentials) @@ -424,13 +428,22 @@ def _start_worker(self, job_id, request, credentials): def register_job_tools(mcp, package, credentials, prefix='', *, store=None): from mcp.types import ToolAnnotations - store = store if store is not None else JobStore(package) + storage_error = None + if store is None: + try: + store = JobStore(package) + except PrivateStorageError as error: + storage_error = error def call(action, operation=False): try: + if storage_error is not None: + raise storage_error data = action() return tool_result(data, package if operation else None) except ValueError as error: + if isinstance(error, PrivateStorageError): + return tool_failure(error, 'storage') if not isinstance(error, JobError): error = JobError('invalid_request', 'invalid_args', 'Invalid query or job arguments. Check the read-only query policy and declared argument bounds.', 'validation') return tool_failure(error) @@ -445,7 +458,7 @@ def submit_query_job(query: str, parameters: dict | None = None, mode: str = 'qu @mcp.tool(name=prefix + 'query_job_capacity', annotations=ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False)) def query_job_capacity() -> CallToolResult: """Read active_jobs, max_active_jobs and available_slots before submission. Capacity is advisory; admission is atomically checked again when submitting.""" - return call(store.capacity) + return call(lambda: store.capacity()) @mcp.tool(name=prefix + 'query_job_status', annotations=ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False)) def query_job_status(job_id: str) -> CallToolResult: @@ -456,6 +469,8 @@ def query_job_status(job_id: str) -> CallToolResult: async def wait_for_query_job(job_id: str, timeout_seconds: int = 30) -> CallToolResult: """Wait natively for a terminal job state, for 0..30 seconds. wait_timed_out means this wait ended while the job remains active; it does not cancel or time out the job. Cancellation stops waiting only.""" try: + if storage_error is not None: + raise storage_error return tool_result(await store.wait(job_id, timeout_seconds), package) except Exception as error: return tool_failure(error) diff --git a/src/cdwagent/private_storage.py b/src/cdwagent/private_storage.py new file mode 100644 index 0000000..dd23652 --- /dev/null +++ b/src/cdwagent/private_storage.py @@ -0,0 +1,194 @@ +"""Owner-private storage for durable jobs; handles pin every permission repair.""" +from __future__ import annotations + +import ctypes +import errno +import os +from pathlib import Path +import stat +import sys + + +class PrivateStorageError(ValueError): + pass + + +def _name(name): + if not isinstance(name, str) or name in ('', '.', '..') or '/' in name or '\\' in name: + raise ValueError('Private storage requires one plain path component.') + return name + + +def _clear_acl(fd, directory): + if sys.platform == 'darwin': + library = ctypes.CDLL(None, use_errno=True) + library.acl_init.argtypes = [ctypes.c_int] + library.acl_init.restype = ctypes.c_void_p + library.acl_set_fd.argtypes = [ctypes.c_int, ctypes.c_void_p] + library.acl_set_fd.restype = ctypes.c_int + library.acl_free.argtypes = [ctypes.c_void_p] + library.acl_free.restype = ctypes.c_int + acl = library.acl_init(0) + if not acl: + raise OSError(ctypes.get_errno(), 'Cannot allocate an owner-private ACL.') + try: + if library.acl_set_fd(fd, acl) != 0: + raise OSError(ctypes.get_errno(), 'Cannot enforce an owner-private ACL.') + finally: + library.acl_free(acl) + elif sys.platform.startswith('linux'): + names = ['system.posix_acl_access'] + if directory: + names.append('system.posix_acl_default') + for name in names: + try: + os.removexattr(fd, name) + except OSError as error: + if error.errno not in (errno.ENODATA, errno.ENOTSUP): + raise + else: + raise OSError(errno.ENOTSUP, 'Owner-private ACL enforcement is unavailable on this platform.') + + +def _protect(fd, directory): + information = os.fstat(fd) + valid_type = stat.S_ISDIR(information.st_mode) if directory else stat.S_ISREG(information.st_mode) + if not valid_type or information.st_uid != os.geteuid() or (not directory and information.st_nlink != 1): + raise ValueError('Private job storage must contain only owned directories and regular files without links.') + mode = 0o700 if directory else 0o600 + os.fchmod(fd, mode) + _clear_acl(fd, directory) + if stat.S_IMODE(os.fstat(fd).st_mode) != mode: + raise OSError(errno.ENOTSUP, 'The filesystem does not enforce owner-private permissions.') + + +class PrivateDirectory: + def __init__(self, path): + if os.name != 'posix' or not hasattr(os, 'O_NOFOLLOW') or os.open not in os.supports_dir_fd: + raise OSError(errno.ENOTSUP, 'Secure private job storage is unavailable on this platform.') + path = Path(path).expanduser().absolute() + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + self.path = path.parent.resolve(strict=True) / _name(path.name) + self._fd = None + parent = os.open(self.path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + try: + os.mkdir(self.path.name, mode=0o700, dir_fd=parent) + except FileExistsError: + pass + descriptor = os.open(self.path.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent) + finally: + os.close(parent) + self._initialize(descriptor) + + @classmethod + def _from_fd(cls, descriptor, path): + instance = cls.__new__(cls) + instance.path = path + instance._fd = None + instance._initialize(descriptor) + return instance + + def _initialize(self, descriptor): + self._fd = descriptor + try: + _protect(descriptor, True) + information = os.fstat(descriptor) + self.identity = (information.st_dev, information.st_ino) + self._repair_tree(descriptor) + self.verify() + except BaseException: + self.close() + raise + + def _repair_tree(self, descriptor): + # Enumeration and repairs remain beneath retained handles, including old stores. + with os.scandir(descriptor) as entries: + for entry in entries: + try: + information = entry.stat(follow_symlinks=False) + directory = stat.S_ISDIR(information.st_mode) + if not directory and not stat.S_ISREG(information.st_mode): + raise ValueError('Private job storage contains a symbolic link or nonregular entry; recover it before retrying.') + flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK + if directory: + flags |= os.O_DIRECTORY + child = os.open(entry.name, flags, dir_fd=descriptor) + except FileNotFoundError: + continue + try: + _protect(child, directory) + if directory: + self._repair_tree(child) + finally: + os.close(child) + + def verify(self): + descriptor = os.open(self.path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + information = os.fstat(descriptor) + if (information.st_dev, information.st_ino) != self.identity: + raise ValueError('The private job directory changed; restore its original location before retrying.') + finally: + os.close(descriptor) + _protect(self._fd, True) + + def child(self, name): + self.verify() + name = _name(name) + try: + os.mkdir(name, mode=0o700, dir_fd=self._fd) + except FileExistsError: + pass + descriptor = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=self._fd) + return self._from_fd(descriptor, self.path / name) + + def open_file(self, name, *, exclusive=False): + self.verify() + flags = os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK + if exclusive: + flags |= os.O_EXCL + descriptor = os.open(_name(name), flags, 0o600, dir_fd=self._fd) + try: + _protect(descriptor, False) + return os.fdopen(descriptor, 'r+b') + except BaseException: + os.close(descriptor) + raise + + def duplicate_fd(self): + self.verify() + return os.dup(self._fd) + + def close(self): + if getattr(self, '_fd', None) is not None: + os.close(self._fd) + self._fd = None + + def __enter__(self): + return self + + def __exit__(self, *unused): + self.close() + + def __del__(self): + self.close() + + +if os.name == 'nt': + from ._private_storage_windows import PrivateDirectory + + +def private_storage_call(action): + try: + return action() + except (OSError, ValueError) as error: + raise PrivateStorageError( + 'Private job storage could not be secured. Use an owned local directory ' + 'with enforceable owner-only ACLs; recover symbolic/hard links or ' + 'foreign-owned entries before retrying. No new job was started.' + ) from error + + +def private_directory(path): + return private_storage_call(lambda: PrivateDirectory(path)) diff --git a/tests/test_private_storage.py b/tests/test_private_storage.py new file mode 100644 index 0000000..bf26c2d --- /dev/null +++ b/tests/test_private_storage.py @@ -0,0 +1,156 @@ +"""Synthetic owner-private storage controls; no database credentials are needed.""" +import asyncio +import importlib +import os +from pathlib import Path +import stat +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +PACKAGE = 'cdwagent' +storage = importlib.import_module(PACKAGE + '.private_storage') +jobs = importlib.import_module(PACKAGE + '.jobs') + + +@unittest.skipUnless(os.name == 'posix' and (sys.platform == 'darwin' or sys.platform.startswith('linux')), 'POSIX private storage backend required') +class PrivateStorageTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='connector-private-storage-') + self.addCleanup(self.temporary.cleanup) + self.parent = Path(self.temporary.name) + + def assert_mode(self, path, mode): + self.assertEqual(stat.S_IMODE(path.stat().st_mode), mode) + + def test_new_file_is_private_before_any_sensitive_bytes(self): + with storage.private_directory(self.parent / 'store') as directory: + with directory.open_file('new-result', exclusive=True) as result: + self.assertEqual(os.fstat(result.fileno()).st_size, 0) + self.assert_mode(directory.path, 0o700) + self.assert_mode(directory.path / 'new-result', 0o600) + result.write(b'synthetic-only') + + def test_existing_owned_tree_is_repaired_without_reading_contents(self): + root = self.parent / 'store' + child = root / 'old-job' + child.mkdir(parents=True) + result = child / 'result.csv' + result.write_text('synthetic-only') + root.chmod(0o755) + child.chmod(0o755) + result.chmod(0o644) + with storage.private_directory(root): + self.assert_mode(root, 0o700) + self.assert_mode(child, 0o700) + self.assert_mode(result, 0o600) + self.assertEqual(result.read_text(), 'synthetic-only') + + def test_symlink_root_and_descendant_never_change_outside_permissions(self): + outside = self.parent / 'outside' + outside.mkdir(mode=0o755) + root = self.parent / 'store' + root.symlink_to(outside, target_is_directory=True) + with self.assertRaises(storage.PrivateStorageError): + storage.private_directory(root) + self.assert_mode(outside, 0o755) + root.unlink() + root.mkdir() + (root / 'alias').symlink_to(outside, target_is_directory=True) + with self.assertRaises(storage.PrivateStorageError): + storage.private_directory(root) + self.assert_mode(outside, 0o755) + + def test_hardlinked_file_is_rejected_before_outside_inode_repair(self): + root = self.parent / 'store' + root.mkdir() + outside = self.parent / 'outside' + outside.write_text('synthetic-only') + outside.chmod(0o644) + os.link(outside, root / 'linked-result') + with self.assertRaises(storage.PrivateStorageError): + storage.private_directory(root) + self.assert_mode(outside, 0o644) + self.assertEqual(outside.read_text(), 'synthetic-only') + + def test_foreign_owner_refusal_precedes_permission_repair(self): + root = self.parent / 'store' + root.mkdir(mode=0o755) + other_uid = os.geteuid() + 1 + with patch.object(storage.os, 'geteuid', return_value=other_uid): + with self.assertRaises(storage.PrivateStorageError): + storage.private_directory(root) + self.assert_mode(root, 0o755) + + def test_replacement_directory_is_refused_without_writing_into_it(self): + root = self.parent / 'store' + with storage.private_directory(root) as directory: + root.rename(self.parent / 'original') + root.mkdir(mode=0o755) + with self.assertRaises(ValueError): + directory.open_file('result.csv', exclusive=True) + self.assertFalse((root / 'result.csv').exists()) + self.assert_mode(root, 0o755) + + @unittest.skipUnless(sys.platform == 'darwin', 'macOS inherited ACL control') + def test_macos_inherited_acl_removed_before_new_file_and_from_old_tree(self): + shared = self.parent / 'shared' + shared.mkdir() + subprocess.run(['/bin/chmod', '+a', 'everyone allow list,search,read,readattr,readextattr,readsecurity,file_inherit,directory_inherit', str(shared)], check=True) + root = shared / 'store' + old = root / 'old-job' + old.mkdir(parents=True) + result = old / 'result.csv' + result.write_text('synthetic-only') + before = subprocess.run(['/bin/ls', '-lde', str(result)], check=True, text=True, capture_output=True).stdout + self.assertIn('everyone', before) + with storage.private_directory(root) as directory: + for path in (root, old, result): + acl = subprocess.run(['/bin/ls', '-lde', str(path)], check=True, text=True, capture_output=True).stdout + self.assertNotIn('everyone', acl) + with directory.open_file('new-result', exclusive=True) as created: + self.assertEqual(os.fstat(created.fileno()).st_size, 0) + acl = subprocess.run(['/bin/ls', '-lde', str(root / 'new-result')], check=True, text=True, capture_output=True).stdout + self.assertNotIn('everyone', acl) + created.write(b'synthetic-only') + + @unittest.skipUnless(sys.platform.startswith('linux') and os.geteuid() == 0, 'Linux root-only two-UID synthetic control') + def test_linux_second_uid_reads_control_but_cannot_read_private_result(self): + self.parent.chmod(0o755) + control = self.parent / 'control' + control.write_text('synthetic-only') + control.chmod(0o644) + with storage.private_directory(self.parent / 'store') as directory: + with directory.open_file('result.csv', exclusive=True) as result: + result.write(b'synthetic-only') + code = 'import os,sys; os.setgroups([]); os.setgid(65534); os.setuid(65534); assert open(sys.argv[1]).read()=="synthetic-only";\ntry: open(sys.argv[2]).read()\nexcept PermissionError: sys.exit(0)\nsys.exit(1)' + completed = subprocess.run([sys.executable, '-c', code, str(control), str(directory.path / 'result.csv')], timeout=10) + self.assertEqual(completed.returncode, 0) + + +class StorageFailureToolTests(unittest.TestCase): + def test_storage_failure_keeps_all_six_tools_registered_with_actionable_errors(self): + async def run(): + from fastmcp import Client, FastMCP + mcp = FastMCP('private storage failure') + with patch.object(jobs, 'JobStore', side_effect=storage.PrivateStorageError('Private job storage could not be secured; choose an owned local directory.')): + jobs.register_job_tools(mcp, PACKAGE, {}, prefix='fixture-') + arguments = { + 'submit_query_job': {'query': 'SELECT 1 AS example'}, + 'query_job_capacity': {}, + 'query_job_status': {'job_id': 'a' * 32}, + 'wait_for_query_job': {'job_id': 'a' * 32, 'timeout_seconds': 0}, + 'read_query_job_result': {'job_id': 'a' * 32}, + 'cancel_query_job': {'job_id': 'a' * 32}, + } + async with Client(mcp) as client: + self.assertEqual(len(await client.list_tools()), 6) + for name, params in arguments.items(): + result = await client.call_tool('fixture-' + name, params, raise_on_error=False) + self.assertTrue(result.is_error, name) + self.assertEqual(result.structured_content['error']['code'], 'private_storage_unavailable', name) + self.assertIn('owned local directory', result.structured_content['error']['message']) + self.assertIsNone(result.meta) + asyncio.run(run()) From 34365ab6624eda8901853045ecc22f82e1229cd8 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 23 Oct 2026 21:52:42 -0700 Subject: [PATCH 04/14] Preserve disk-full guidance and cover inherited Linux ACLs --- src/cdwagent/jobs.py | 2 +- src/cdwagent/private_storage.py | 10 ++++++++- tests/test_private_storage.py | 36 +++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/cdwagent/jobs.py b/src/cdwagent/jobs.py index 7fecbb4..58b93c9 100644 --- a/src/cdwagent/jobs.py +++ b/src/cdwagent/jobs.py @@ -43,7 +43,7 @@ def failure_details(error, phase): if isinstance(error, JobError): return error.details if isinstance(error, PrivateStorageError): - return dict(code='private_storage_unavailable', kind='permission_denied', message=str(error), phase=phase, retryable=False) + return dict(code=error.code, kind=error.kind, message=str(error), phase=phase, retryable=False) code, kind, message, retryable = 'query_failed', 'tool_failure', 'Database execution or result processing failed. Check the query plan, schema and selected output format.', False if isinstance(error, OverflowError): code, message = 'output_limit_exceeded', 'Output byte budget exceeded; narrow the export or explicitly raise max_bytes.' diff --git a/src/cdwagent/private_storage.py b/src/cdwagent/private_storage.py index dd23652..42b371f 100644 --- a/src/cdwagent/private_storage.py +++ b/src/cdwagent/private_storage.py @@ -10,7 +10,10 @@ class PrivateStorageError(ValueError): - pass + def __init__(self, message, code='private_storage_unavailable', kind='permission_denied'): + super().__init__(message) + self.code = code + self.kind = kind def _name(name): @@ -183,6 +186,11 @@ def private_storage_call(action): try: return action() except (OSError, ValueError) as error: + if isinstance(error, OSError) and error.errno == errno.ENOSPC: + raise PrivateStorageError( + 'Local job storage is full. Free space before explicitly resubmitting.', + code='disk_full', kind='tool_failure', + ) from error raise PrivateStorageError( 'Private job storage could not be secured. Use an owned local directory ' 'with enforceable owner-only ACLs; recover symbolic/hard links or ' diff --git a/tests/test_private_storage.py b/tests/test_private_storage.py index bf26c2d..83c9265 100644 --- a/tests/test_private_storage.py +++ b/tests/test_private_storage.py @@ -1,9 +1,11 @@ """Synthetic owner-private storage controls; no database credentials are needed.""" import asyncio +import errno import importlib import os from pathlib import Path import stat +import struct import subprocess import sys import tempfile @@ -116,6 +118,31 @@ def test_macos_inherited_acl_removed_before_new_file_and_from_old_tree(self): self.assertNotIn('everyone', acl) created.write(b'synthetic-only') + @unittest.skipUnless(sys.platform.startswith('linux'), 'Linux default ACL control') + def test_linux_default_and_access_acls_are_removed(self): + shared = self.parent / 'shared' + shared.mkdir() + entries = [(1, 7, 0xffffffff), (2, 5, 65534), (4, 5, 0xffffffff), (16, 5, 0xffffffff), (32, 5, 0xffffffff)] + acl = struct.pack(' Date: Fri, 23 Oct 2026 21:55:55 -0700 Subject: [PATCH 05/14] test: allow transport metadata on storage rejection --- tests/test_private_storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_private_storage.py b/tests/test_private_storage.py index 83c9265..873c7f9 100644 --- a/tests/test_private_storage.py +++ b/tests/test_private_storage.py @@ -188,5 +188,5 @@ async def run(): self.assertTrue(result.is_error, name) self.assertEqual(result.structured_content['error']['code'], 'private_storage_unavailable', name) self.assertIn('owned local directory', result.structured_content['error']['message']) - self.assertIsNone(result.meta) + self.assertNotIn(jobs.OPERATION_META_KEY, result.meta or {}) asyncio.run(run()) From 4c392b82f408aee3ea227275ec4f5243d631ff39 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 23 Oct 2026 21:57:22 -0700 Subject: [PATCH 06/14] Secure Windows durable job storage with owner-only handle ACLs --- .github/workflows/windows-private-storage.yml | 16 + src/cdwagent/_private_storage_windows.py | 407 ++++++++++++++++++ tests/test_private_storage_windows.py | 220 ++++++++++ 3 files changed, 643 insertions(+) create mode 100644 .github/workflows/windows-private-storage.yml create mode 100644 src/cdwagent/_private_storage_windows.py create mode 100644 tests/test_private_storage_windows.py diff --git a/.github/workflows/windows-private-storage.yml b/.github/workflows/windows-private-storage.yml new file mode 100644 index 0000000..a8cfa63 --- /dev/null +++ b/.github/workflows/windows-private-storage.yml @@ -0,0 +1,16 @@ +name: Windows private storage +on: + push: + pull_request: + workflow_dispatch: +jobs: + windows-private-storage: + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v6 + with: + version: "0.12.13" + - run: uv sync --locked --python 3.13 + - run: uv run python -m unittest discover -s tests -p test_private_storage_windows.py -v diff --git a/src/cdwagent/_private_storage_windows.py b/src/cdwagent/_private_storage_windows.py new file mode 100644 index 0000000..133c8ac --- /dev/null +++ b/src/cdwagent/_private_storage_windows.py @@ -0,0 +1,407 @@ +"""Handle-checked, current-owner-only storage for Windows durable jobs.""" +from __future__ import annotations + +from contextlib import contextmanager +import ctypes +from ctypes import wintypes +import errno +import os +from pathlib import Path + +if os.name != 'nt': + raise ImportError('The Windows private storage backend requires Windows.') + +import msvcrt + + +_kernel = ctypes.WinDLL('kernel32', use_last_error=True) +_security = ctypes.WinDLL('advapi32', use_last_error=True) +_POINTER = ctypes.c_void_p +_READ_CONTROL = 0x00020000 +_WRITE_DAC = 0x00040000 +_READ_ATTRIBUTES = 0x80 +_LIST_DIRECTORY = 1 +_SHARE_READ_WRITE = 3 +_SHARE_DELETE = 4 +_DIRECTORY = 0x10 +_REPARSE = 0x400 +_BACKUP_SEMANTICS = 0x02000000 +_OPEN_REPARSE_POINT = 0x00200000 +_INVALID_HANDLE = _POINTER(-1).value +_OWNER_INFO = 1 +_DACL_INFO = 4 +_PROTECTED_DACL_INFO = 0x80000000 +_DACL_PROTECTED = 0x1000 +_FILE_ALL_ACCESS = 0x001F01FF + + +class _SecurityAttributes(ctypes.Structure): + _fields_ = [('nLength', wintypes.DWORD), ('lpSecurityDescriptor', _POINTER), + ('bInheritHandle', wintypes.BOOL)] + + +class _SidAttributes(ctypes.Structure): + _fields_ = [('Sid', _POINTER), ('Attributes', wintypes.DWORD)] + + +class _TokenUser(ctypes.Structure): + _fields_ = [('User', _SidAttributes)] + + +class _FileInformation(ctypes.Structure): + _fields_ = [('attributes', wintypes.DWORD), ('creation', wintypes.FILETIME), + ('access', wintypes.FILETIME), ('write', wintypes.FILETIME), + ('volume', wintypes.DWORD), ('size_high', wintypes.DWORD), + ('size_low', wintypes.DWORD), ('links', wintypes.DWORD), + ('index_high', wintypes.DWORD), ('index_low', wintypes.DWORD)] + + +class _AclHeader(ctypes.Structure): + _fields_ = [('revision', wintypes.BYTE), ('reserved', wintypes.BYTE), + ('size', wintypes.WORD), ('count', wintypes.WORD), + ('reserved2', wintypes.WORD)] + + +class _AceHeader(ctypes.Structure): + _fields_ = [('kind', wintypes.BYTE), ('flags', wintypes.BYTE), ('size', wintypes.WORD)] + + +_kernel.GetCurrentProcess.argtypes = [] +_kernel.GetCurrentProcess.restype = wintypes.HANDLE +_kernel.CloseHandle.argtypes = [wintypes.HANDLE] +_kernel.CloseHandle.restype = wintypes.BOOL +_kernel.LocalFree.argtypes = [_POINTER] +_kernel.LocalFree.restype = _POINTER +_kernel.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, + ctypes.POINTER(_SecurityAttributes), wintypes.DWORD, + wintypes.DWORD, wintypes.HANDLE] +_kernel.CreateFileW.restype = wintypes.HANDLE +_kernel.CreateDirectoryW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(_SecurityAttributes)] +_kernel.CreateDirectoryW.restype = wintypes.BOOL +_kernel.GetFileInformationByHandle.argtypes = [wintypes.HANDLE, ctypes.POINTER(_FileInformation)] +_kernel.GetFileInformationByHandle.restype = wintypes.BOOL +_kernel.GetFileType.argtypes = [wintypes.HANDLE] +_kernel.GetFileType.restype = wintypes.DWORD +_security.OpenProcessToken.argtypes = [wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)] +_security.OpenProcessToken.restype = wintypes.BOOL +_security.GetTokenInformation.argtypes = [wintypes.HANDLE, ctypes.c_int, _POINTER, + wintypes.DWORD, ctypes.POINTER(wintypes.DWORD)] +_security.GetTokenInformation.restype = wintypes.BOOL +_security.ConvertSidToStringSidW.argtypes = [_POINTER, ctypes.POINTER(_POINTER)] +_security.ConvertSidToStringSidW.restype = wintypes.BOOL +_security.ConvertStringSecurityDescriptorToSecurityDescriptorW.argtypes = [ + wintypes.LPCWSTR, wintypes.DWORD, ctypes.POINTER(_POINTER), ctypes.POINTER(wintypes.DWORD)] +_security.ConvertStringSecurityDescriptorToSecurityDescriptorW.restype = wintypes.BOOL +_security.GetSecurityDescriptorDacl.argtypes = [ + _POINTER, ctypes.POINTER(wintypes.BOOL), ctypes.POINTER(_POINTER), ctypes.POINTER(wintypes.BOOL)] +_security.GetSecurityDescriptorDacl.restype = wintypes.BOOL +_security.GetSecurityDescriptorControl.argtypes = [ + _POINTER, ctypes.POINTER(wintypes.WORD), ctypes.POINTER(wintypes.DWORD)] +_security.GetSecurityDescriptorControl.restype = wintypes.BOOL +_security.GetSecurityInfo.argtypes = [wintypes.HANDLE, ctypes.c_int, wintypes.DWORD, + ctypes.POINTER(_POINTER), ctypes.POINTER(_POINTER), + ctypes.POINTER(_POINTER), ctypes.POINTER(_POINTER), + ctypes.POINTER(_POINTER)] +_security.GetSecurityInfo.restype = wintypes.DWORD +_security.SetSecurityInfo.argtypes = [wintypes.HANDLE, ctypes.c_int, wintypes.DWORD, + _POINTER, _POINTER, _POINTER, _POINTER] +_security.SetSecurityInfo.restype = wintypes.DWORD +_security.IsValidSid.argtypes = [_POINTER] +_security.IsValidSid.restype = wintypes.BOOL +_security.EqualSid.argtypes = [_POINTER, _POINTER] +_security.EqualSid.restype = wintypes.BOOL +_security.GetAce.argtypes = [_POINTER, wintypes.DWORD, ctypes.POINTER(_POINTER)] +_security.GetAce.restype = wintypes.BOOL + + +def _error(): + return ctypes.WinError(ctypes.get_last_error()) + + +def _name(name): + if (not isinstance(name, str) or name in ('', '.', '..') + or any(character in name for character in '/\\:\0') + or name.endswith((' ', '.'))): + raise ValueError('Private storage requires one ordinary Windows path component.') + stem = name.split('.')[0].upper() + if stem in {'CON', 'PRN', 'AUX', 'NUL', 'CONIN$', 'CONOUT$'} or ( + len(stem) == 4 and stem[:3] in ('COM', 'LPT') and stem[3] in '123456789¹²³'): + raise ValueError('Windows device names cannot be private storage entries.') + return name + + +def _absolute(path): + value = os.path.abspath(os.path.expanduser(os.fspath(path))) + if value.startswith(('\\\\?\\', '\\\\.\\')): + raise ValueError('Windows device namespace paths cannot be private job storage.') + result = Path(value) + if not result.anchor: + raise ValueError('Private storage requires an absolute directory.') + for component in result.parts[1:]: + _name(component) + if result == Path(result.anchor): + raise ValueError('A drive or share root cannot be private job storage.') + return result + + +def _close(handle): + if handle is not None and handle != _INVALID_HANDLE: + _kernel.CloseHandle(handle) + + +def _open(path, directory, *, repair=True): + access = _READ_CONTROL | _READ_ATTRIBUTES + if directory: + access |= _LIST_DIRECTORY + if repair: + access |= _WRITE_DAC + # Retained directory handles prevent pathname replacement. File metadata + # handles allow journal deletion while still binding repairs to the object. + share = _SHARE_READ_WRITE | (0 if directory else _SHARE_DELETE) + handle = _kernel.CreateFileW(str(path), access, share, None, 3, + _OPEN_REPARSE_POINT | _BACKUP_SEMANTICS, None) + if handle == _INVALID_HANDLE: + raise _error() + return handle + + +def _information(handle): + result = _FileInformation() + if not _kernel.GetFileInformationByHandle(handle, ctypes.byref(result)): + raise _error() + return result + + +def _identity(information): + return information.volume, (information.index_high << 32) | information.index_low + + +@contextmanager +def _security_info(handle): + owner, dacl, descriptor = _POINTER(), _POINTER(), _POINTER() + code = _security.GetSecurityInfo(handle, 1, _OWNER_INFO | _DACL_INFO, + ctypes.byref(owner), None, ctypes.byref(dacl), + None, ctypes.byref(descriptor)) + if code: + raise ctypes.WinError(code) + try: + yield owner, dacl, descriptor + finally: + _kernel.LocalFree(descriptor) + + +class _Security: + def __init__(self): + self._descriptors = [] + token = wintypes.HANDLE() + if not _security.OpenProcessToken(_kernel.GetCurrentProcess(), 8, ctypes.byref(token)): + raise _error() + try: + size = wintypes.DWORD() + _security.GetTokenInformation(token, 1, None, 0, ctypes.byref(size)) + if not ctypes.sizeof(_TokenUser) <= size.value <= 65536: + raise OSError(errno.ENOTSUP, 'Cannot obtain the Windows process owner SID.') + self._user = ctypes.create_string_buffer(size.value) + if not _security.GetTokenInformation(token, 1, self._user, size, ctypes.byref(size)): + raise _error() + self.sid = ctypes.cast(self._user, ctypes.POINTER(_TokenUser)).contents.User.Sid + finally: + _close(token) + if not _security.IsValidSid(self.sid): + raise ValueError('The Windows process owner SID is invalid.') + string = _POINTER() + if not _security.ConvertSidToStringSidW(self.sid, ctypes.byref(string)): + raise _error() + try: + self.sid_text = ctypes.wstring_at(string) + finally: + _kernel.LocalFree(string) + try: + self.directory = self._descriptor('OICI') + self.file = self._descriptor('') + except BaseException: + self.close() + raise + + def _descriptor(self, inheritance): + descriptor = _POINTER() + sddl = f'O:{self.sid_text}D:P(A;{inheritance};FA;;;{self.sid_text})' + if not _security.ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl, 1, ctypes.byref(descriptor), None): + raise _error() + self._descriptors.append(descriptor) + present, defaulted, dacl = wintypes.BOOL(), wintypes.BOOL(), _POINTER() + if not _security.GetSecurityDescriptorDacl(descriptor, ctypes.byref(present), + ctypes.byref(dacl), ctypes.byref(defaulted)): + raise _error() + if not present or not dacl: + raise ValueError('An owner-only Windows DACL could not be constructed.') + attributes = _SecurityAttributes(ctypes.sizeof(_SecurityAttributes), descriptor, False) + return attributes, dacl + + def validate(self, handle, directory, *, owner=True): + information = _information(handle) + if (_kernel.GetFileType(handle) != 1 or information.attributes & _REPARSE + or bool(information.attributes & _DIRECTORY) != directory + or (not directory and information.links != 1)): + raise ValueError('Private job storage requires ordinary files and directories without reparse points or hard links.') + if owner: + with _security_info(handle) as (actual_owner, unused, descriptor): + if not actual_owner or not _security.IsValidSid(actual_owner) or not _security.EqualSid(actual_owner, self.sid): + raise ValueError('Private job storage must be owned by the current Windows user.') + return _identity(information) + + def assert_private(self, handle, directory): + self.validate(handle, directory) + with _security_info(handle) as (owner, dacl, descriptor): + control, revision = wintypes.WORD(), wintypes.DWORD() + if not _security.GetSecurityDescriptorControl(descriptor, ctypes.byref(control), ctypes.byref(revision)): + raise _error() + if not dacl or not control.value & _DACL_PROTECTED or ctypes.cast(dacl, ctypes.POINTER(_AclHeader)).contents.count != 1: + raise OSError(errno.ENOTSUP, 'The filesystem did not enforce the protected owner-only Windows DACL.') + ace = _POINTER() + if not _security.GetAce(dacl, 0, ctypes.byref(ace)): + raise _error() + header = ctypes.cast(ace, ctypes.POINTER(_AceHeader)).contents + mask = ctypes.c_uint32.from_address(ace.value + 4).value + sid = _POINTER(ace.value + 8) + expected_flags = 3 if directory else 0 + if (header.kind != 0 or header.flags != expected_flags or mask != _FILE_ALL_ACCESS + or not _security.IsValidSid(sid) or not _security.EqualSid(sid, self.sid)): + raise OSError(errno.ENOTSUP, 'The filesystem did not retain the owner-only Windows access rule.') + + def protect(self, handle, directory): + self.validate(handle, directory) + dacl = (self.directory if directory else self.file)[1] + code = _security.SetSecurityInfo(handle, 1, _DACL_INFO | _PROTECTED_DACL_INFO, + None, None, dacl, None) + if code: + raise ctypes.WinError(code) + self.assert_private(handle, directory) + + def close(self): + for descriptor in self._descriptors: + _kernel.LocalFree(descriptor) + self._descriptors.clear() + + +class PrivateDirectory: + def __init__(self, path): + self._handles = [] + self._handle = None + self._security = None + self.path = _absolute(path) + try: + self._security = _Security() + current = Path(self.path.anchor) + anchor = _open(current, True, repair=False) + self._handles.append(anchor) + self._security.validate(anchor, True, owner=False) + for component in self.path.parts[1:]: + current /= component + created = False + try: + handle = _open(current, True, repair=current == self.path) + except FileNotFoundError: + attributes = self._security.directory[0] + if _kernel.CreateDirectoryW(str(current), ctypes.byref(attributes)): + created = True + else: + error = _error() + if error.winerror != 183: + raise error + handle = _open(current, True, repair=current == self.path) + self._handles.append(handle) + self._security.validate(handle, True, owner=created or current == self.path) + if created: + self._security.assert_private(handle, True) + self._handle = self._handles[-1] + self.identity = self._security.validate(self._handle, True) + self._repair_tree() + self.verify() + except BaseException: + self.close() + raise + + def _repair_tree(self): + entries = [(self._handle, True)] + retained = [] + try: + def inspect(path): + with os.scandir(path) as children: + for child in children: + try: + _name(child.name) + directory = child.is_dir(follow_symlinks=False) + handle = _open(child.path, directory) + except FileNotFoundError: + continue + retained.append(handle) + self._security.validate(handle, directory) + entries.append((handle, directory)) + if directory: + inspect(Path(child.path)) + inspect(self.path) + # Validate the entire legacy tree before changing any existing ACL; + # protected children are repaired before inheritable parent rules. + for handle, directory in reversed(entries): + self._security.protect(handle, directory) + finally: + for handle in reversed(retained): + _close(handle) + + def verify(self): + if self._handle is None: + raise ValueError('The private job directory is closed.') + handle = _open(self.path, True, repair=False) + try: + if self._security.validate(handle, True) != self.identity: + raise ValueError('The private job directory changed; restore its original location before retrying.') + finally: + _close(handle) + self._security.assert_private(self._handle, True) + + def child(self, name): + self.verify() + return PrivateDirectory(self.path / _name(name)) + + def open_file(self, name, *, exclusive=False): + self.verify() + target = self.path / _name(name) + attributes = self._security.file[0] + handle = _kernel.CreateFileW(str(target), 0xC0000000 | _READ_CONTROL | _WRITE_DAC, + _SHARE_READ_WRITE, ctypes.byref(attributes), + 1 if exclusive else 4, _OPEN_REPARSE_POINT, None) + if handle == _INVALID_HANDLE: + raise _error() + try: + self._security.protect(handle, False) + descriptor = msvcrt.open_osfhandle(handle, os.O_RDWR | os.O_BINARY) + handle = None + try: + return os.fdopen(descriptor, 'r+b', buffering=0) + except BaseException: + os.close(descriptor) + raise + finally: + _close(handle) + + def duplicate_fd(self): + raise OSError(errno.ENOTSUP, 'Windows private directory handles are not POSIX directory descriptors.') + + def close(self): + for handle in reversed(getattr(self, '_handles', [])): + _close(handle) + self._handles = [] + self._handle = None + if getattr(self, '_security', None) is not None: + self._security.close() + self._security = None + + def __enter__(self): + return self + + def __exit__(self, *unused): + self.close() + + def __del__(self): + self.close() diff --git a/tests/test_private_storage_windows.py b/tests/test_private_storage_windows.py new file mode 100644 index 0000000..ccf5878 --- /dev/null +++ b/tests/test_private_storage_windows.py @@ -0,0 +1,220 @@ +"""Real NTFS/Windows ACL checks, with synthetic data and no database access.""" +import importlib +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + + +PACKAGE = 'cdwagent' + + +@unittest.skipUnless(os.name == 'nt', 'Requires actual Windows security descriptors and filesystem handles.') +class WindowsPrivateStorageTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.windows = importlib.import_module(PACKAGE + '._private_storage_windows') + + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + + def powershell(self, script, path): + environment = dict(os.environ, PRIVATE_STORAGE_TEST_PATH=str(path)) + result = subprocess.run( + ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', + "$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new(); " + script], + env=environment, capture_output=True, text=True, encoding='utf-8', timeout=30, + ) + self.assertEqual(result.returncode, 0, result.stderr) + return result.stdout.strip() + + def acl(self, path): + return json.loads(self.powershell(""" + $acl=Get-Acl -LiteralPath $env:PRIVATE_STORAGE_TEST_PATH; + $sidType=[Security.Principal.SecurityIdentifier]; + $owner=([Security.Principal.NTAccount]::new($acl.Owner)).Translate($sidType).Value; + $rules=@($acl.Access | ForEach-Object { + @{sid=$_.IdentityReference.Translate($sidType).Value; + type=$_.AccessControlType.ToString(); rights=[int64]$_.FileSystemRights; + inherited=$_.IsInherited; inheritance=[int]$_.InheritanceFlags} + }); + @{owner=$owner; current=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value; + protected=$acl.AreAccessRulesProtected; rules=$rules; + sddl=$acl.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::All)} | + ConvertTo-Json -Depth 5 -Compress + """, path)) + + def broad_acl(self, path): + self.powershell(""" + $item=Get-Item -Force -LiteralPath $env:PRIVATE_STORAGE_TEST_PATH; + $acl=if ($item.PSIsContainer) {[Security.AccessControl.DirectorySecurity]::new()} + else {[Security.AccessControl.FileSecurity]::new()}; + $sid=[Security.Principal.WindowsIdentity]::GetCurrent().User; + $acl.SetOwner($sid); $acl.SetAccessRuleProtection($true,$false); + $inheritance=if ($item.PSIsContainer) { + [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor + [Security.AccessControl.InheritanceFlags]::ObjectInherit + } else {[Security.AccessControl.InheritanceFlags]::None}; + foreach ($identity in @($sid,[Security.Principal.SecurityIdentifier]::new('S-1-1-0'))) { + $rule=[Security.AccessControl.FileSystemAccessRule]::new( + $identity,[Security.AccessControl.FileSystemRights]::FullControl, + $inheritance,[Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow); + $acl.AddAccessRule($rule) + }; + Set-Acl -LiteralPath $env:PRIVATE_STORAGE_TEST_PATH -AclObject $acl + """, path) + + def assert_private(self, path, directory): + acl = self.acl(path) + self.assertEqual(acl['owner'], acl['current']) + self.assertTrue(acl['protected']) + self.assertEqual(acl['rules'], [dict(sid=acl['current'], type='Allow', + rights=0x001F01FF, inherited=False, + inheritance=3 if directory else 0)]) + + def junction(self, link, target): + result = subprocess.run(['cmd.exe', '/d', '/c', 'mklink', '/J', str(link), str(target)], + capture_output=True, text=True, timeout=15) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.addCleanup(lambda: os.rmdir(link) if os.path.lexists(link) else None) + + def test_first_publication_is_private_and_reopen_does_not_truncate(self): + parent = self.base / 'broad-parent' + parent.mkdir() + self.broad_acl(parent) + parent_acl = self.acl(parent)['sddl'] + with self.windows.PrivateDirectory(parent / 'new' / 'store') as root: + self.assert_private(root.path, True) + self.assert_private(parent / 'new', True) + root.verify() + with root.open_file('output.csv', exclusive=True) as output: + self.assert_private(root.path / 'output.csv', False) + output.write(b'synthetic,only\r\n1,ok\r\n') + with root.open_file('output.csv') as output: + self.assertEqual(output.read(), b'synthetic,only\r\n1,ok\r\n') + with self.assertRaises(FileExistsError): + root.open_file('output.csv', exclusive=True) + with root.child('job') as child: + self.assert_private(child.path, True) + self.assertEqual(self.acl(parent)['sddl'], parent_acl) + + def test_legacy_tree_repairs_real_acls_and_preserves_content(self): + path = self.base / 'legacy' + child = path / 'job' + child.mkdir(parents=True) + output = child / 'result.csv' + output.write_bytes(b'synthetic result') + for entry in (path, child, output): + self.broad_acl(entry) + with self.windows.PrivateDirectory(path): + for entry in (path, child): + self.assert_private(entry, True) + self.assert_private(output, False) + self.assertEqual(output.read_bytes(), b'synthetic result') + + def test_junction_root_ancestor_and_tree_refused_without_acl_changes(self): + outside = self.base / 'outside' + (outside / 'nested').mkdir(parents=True) + canary = outside / 'canary' + canary.write_bytes(b'unchanged') + self.broad_acl(outside) + before = {entry: self.acl(entry)['sddl'] for entry in (outside, canary)} + link = self.base / 'junction' + self.junction(link, outside) + for path in (link, link / 'nested'): + with self.subTest(path=path), self.assertRaises((OSError, ValueError)): + self.windows.PrivateDirectory(path) + legacy = self.base / 'legacy' + legacy.mkdir() + self.broad_acl(legacy) + legacy_acl = self.acl(legacy)['sddl'] + self.junction(legacy / 'linked', outside) + with self.assertRaises((OSError, ValueError)): + self.windows.PrivateDirectory(legacy) + self.assertEqual(self.acl(legacy)['sddl'], legacy_acl) + self.assertEqual({entry: self.acl(entry)['sddl'] for entry in before}, before) + self.assertEqual(canary.read_bytes(), b'unchanged') + + def test_hardlink_rejected_before_any_legacy_acl_repair(self): + outside = self.base / 'outside.txt' + outside.write_bytes(b'external canary') + self.broad_acl(outside) + legacy = self.base / 'legacy' + legacy.mkdir() + sibling = legacy / 'ordinary.txt' + sibling.write_bytes(b'ordinary') + self.broad_acl(legacy) + self.broad_acl(sibling) + os.link(outside, legacy / 'linked.txt') + before = {entry: self.acl(entry)['sddl'] for entry in (legacy, sibling, outside)} + with self.assertRaises(ValueError): + self.windows.PrivateDirectory(legacy) + self.assertEqual({entry: self.acl(entry)['sddl'] for entry in before}, before) + self.assertEqual(outside.read_bytes(), b'external canary') + with self.windows.PrivateDirectory(self.base / 'private') as root: + os.link(outside, root.path / 'linked.txt') + with self.assertRaises(ValueError): + root.open_file('linked.txt') + self.assertEqual(self.acl(outside)['sddl'], before[outside]) + + def test_foreign_owner_is_refused_without_taking_ownership(self): + path = Path(os.environ['WINDIR']) + before = self.acl(path) + self.assertNotEqual(before['owner'], before['current']) + context = self.windows._Security() + self.addCleanup(context.close) + handle = self.windows._open(path, True, repair=False) + try: + with self.assertRaisesRegex(ValueError, 'owned'): + context.validate(handle, True) + finally: + self.windows._close(handle) + self.assertEqual(self.acl(path)['sddl'], before['sddl']) + + def test_retained_directory_prevents_replacement_and_names_are_plain(self): + path = self.base / 'private' + with self.windows.PrivateDirectory(path) as root: + for name in ('../outside', 'a/b', 'a\\b', 'x:stream', 'x.', 'x ', 'NUL', 'COM1.log'): + with self.subTest(name=name), self.assertRaises(ValueError): + root.child(name) + with self.assertRaises(OSError): + path.rename(self.base / 'replacement') + root.verify() + self.assertEqual(root.identity, self.windows._identity(self.windows._information(root._handle))) + path.rename(self.base / 'replacement') + with self.assertRaises(ValueError): + root.verify() + + def test_normal_job_and_concurrent_sqlite_store_stay_usable(self): + jobs = importlib.import_module(PACKAGE + '.jobs') + worker = importlib.import_module(PACKAGE + '.job_worker') + backend = importlib.import_module(PACKAGE + '.query_backend') + store = jobs.JobStore(PACKAGE, self.base) + self.addCleanup(store._storage.close) + with store.connect() as database: + database.execute('CREATE TABLE synthetic_check(value TEXT)') + database.execute("INSERT INTO synthetic_check VALUES ('synthetic')") + second = jobs.JobStore(PACKAGE, self.base) + self.addCleanup(second._storage.close) + with patch.object(backend, 'check_credentials'), patch.object(jobs.subprocess, 'Popen') as popen: + data = store.submit('SELECT 1 AS value', {}, {'password': 'synthetic'}, format='csv') + wire = json.loads(popen.return_value.stdin.write.call_args.args[0]) + done = worker.execute(store, data['job_id'], wire, + lambda *args: iter([(['value'], [[1], [2]])])) + self.assertEqual(done['state'], 'completed', done) + self.assertEqual(store.status(data['job_id'])['state'], 'completed') + result = Path(done['result_path']) + self.assertEqual(result.read_bytes(), b'value\r\n1\r\n2\r\n') + self.assert_private(result, False) + self.assert_private(store.dbpath, False) + self.assertFalse(list(result.parent.glob('*.partial'))) + + +if __name__ == '__main__': + unittest.main() From 528ec52fa0bfc156d7301ee07c4d9f144e2d85c7 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 23 Oct 2026 21:59:02 -0700 Subject: [PATCH 07/14] Rebuild connector bundle with owner-private storage backends --- extensions/SHA256SUMS | 2 +- extensions/cdwagent.brxt | Bin 299937 -> 307350 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/SHA256SUMS b/extensions/SHA256SUMS index 3aebd03..5b5f8e6 100644 --- a/extensions/SHA256SUMS +++ b/extensions/SHA256SUMS @@ -1 +1 @@ -3c9b21110f172e827dfe97705336dd29fe231ed86514d653bdc069bc0516178e cdwagent.brxt +4b92b8fc818b385f841041550511cac8d63ac9b2fbfaf0ceba3104decea0cbcf cdwagent.brxt diff --git a/extensions/cdwagent.brxt b/extensions/cdwagent.brxt index 25f5aa234b56c233a35b08b85beebd93d94a7819..4a0a071c8d4b7cae669872c4a47e98a7c51c00a8 100644 GIT binary patch delta 24684 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 Vy3hV0&g3BTza*Nt6!vfR{{YwHXk-8Y delta 17224 zcmV(tK4&lE^TCW zTHA8mMv{HUS5(LkmSXxs*<g;GeZn_@ovU2I*3v*eQfBLX=8#`1G6^rsjHICMF z^U%7&Z+vlF#i#-)#?Jv+VEAwfgxefbn(dT32%q?BMIbt?U}AqA;DS zoDKYQVP2-Ke{5-6STbale{lH3{F6SnT0=KjXwmLx*q54zX}K|9p+n-y692z?Y`S~b z$$Q+RXm-$gnY9)=t?#AX<+`&$se|A>bn3P(abvy0-NW|`?jw`&Iu?IhcwQ(v-}Yg-=1VKC57XplSgIX* zoTJ}rKw{bE5w5{)C^=v;+qvE{yeLrVV6&aC>4*hF!9BRDY1LO~j@cnH0mxi{1tmCV z=D~OUV5`j4_*YZ&BgH<6(9wQ-MDrRBx!u|B-rx~`g98(!@Cp8xT2Xvi9O>`oZ|C3r z@Z*2Yhc`Dlv_)t-eAWEy`@aqAWQ>r4K;1{&tEWmc8(;VqB46404ZiS|c%|an z4T7ZcT#OAB8PE+x1Vg*g#5n6vU z3O|#KgJd^!G&&B+Tg~5;6p?A?VK%5 zvr#B(Dl@c71md|Lw=lNs3ENXaYw`@h?u7-iR7SATmH=c&cn<1kNf_Wz;erGo9bGbs zl7}dlivIFNwzJTry=NjTJ)hXh5a@q+Vram_Jxd?rhhl{dBY#WJgdCC(0#1cZxi?&M zVmAY&;=a($fylK(PzjiUb31O%WK5q)~EP^;R#x%2;DA>2m9{7KrJF=Ti)9q|ex-w!Eh#G%j9icg< zTG+B}IxL!PBx?BO9H|=;kw<(=YJS@QDJ-{uw;t$V1C%96pxl1fR!&7^T;P|sLr@4b zj+95t07EnNB(^8XKb@WjhHq6$Gd5kbgK;Zg_-3HxK?;xyYT8RxIwn&n8{0HUa0)EM z1^PIR4M(~|lmJtHA&X8VC;xx%C3&x{mXZaRlU1e+w#>~_#(x+22;qdP9O#bOhl`t6 ztMi{P-`=i%xqh*Fe|>%X47miPqlo#N0h)m0>B+yHoIDeWUcOjrM$tD;emprrvd!LI z-(3B+FxR#Dhz#C520=M)8_)J_7+1rgHjJ(^?xu`IlJ&)FgxJlmuRVXm)xqYK|DCdw zJ0Ap)pZ1TyI~e!DX<>nop5PNlcprrW;d6EeWo6G#PEO`s^Jvz#R}N0Z%oi=;##Xrz zVI<86CNb*(8pwt0hH(^zg3-)ZvJLA2jo!$O=ot4%aOIs7KX`s(etvO=98x$}YUIP) zJRlFy0}2OHV+BTgaHD@L-AKf)w)6jMWi;8BY1v9%Z-9H+^Fd^Z4Rzn#yDFlO%lKxYrHWTBrL{z00{jcbDGof zPjw&e)NXl@S=Q01A3L%L)LQF3R-BowgA=VGDypx_wq&Y;-r`NApOisto#YO7ESDN7~mwbi=h5j(DCgk1X2XJ{U8X`CDqC`Ud zBRek}r`W&g9JLNm%0h*|an6@Qz8_X7yFiA5{YWC8z*IxkSRme&~ZU@Hb9e8n73t0>ZK}JCs37_ zgXAn_>kjfVA{OQqM0lXvRA7iMgBGA6$6g85p(wAMos3~w3ceFq6&#`}H~usG5H%uX zODS25yE_o+S3+i@DkC79lwV@1*AQX-SB>&Z{S1GB%2SkYc@BEH&0@8c@}HsMzxXlI zJLbHrmtQ8Ye#WeL4Q0+TAZYMT3@MG)x0 z4wru-Y)kPTiKIB(;&9P)m^}J^08Ny{%cmiqnS5tIuMU4QPrrL6r9zgE=U6cqLJqeb zb2y|#9xm=__P*J%FH!AhEXWWcRx|GCn+203Okc%ffM-#;R+?g6nX8+PMS6U`Fdx+} zN))zD#WL6VPUBM@iXd6JUixRu(xi7yr)__}jy&L4Jy}j$uYjuvlaMZPvYH61FL+1c)*1=OD zd0bxAw&IcUs6&Hn34K#S2!-m@`kfNHtZ7|rgW?H{<;Y7!9r06b2C(ZBM(1HzkocjL z0#qXqL*-mM%Qkl1A$FM!5*d;H<$##RCdF}%VTB~o3icV9iV_$05Kt&(TU@0El8optCgBA zl@Zy8+gJ0ykcY90ONO={B?dhJ8 zgpO_Wo{qNj<$T;S8cC_uCn0+a9SCWc!1y~j2I-pglX5ljiptMp9V370gG)GJJda$I9lvmvSmIUC9nWq7^$zBGLWF= zlz^Nbp5h<0RL^1=R!V;jKGb7>ghAA^yb9*2uZwDsnlu*a&!*y3npmZvMwaV7E49RG zsK;!cYwzBrDTs12_BNxqW2PB-jCNGU%z|o^Vq~ZkJ9;GOQaP%OR4vJB^3*Wwb`liXK{-xk6^cTN3=DsE;_bX*mwjZtoRJ_Q zz+qnrI4Z*c9+0o*g1;-aWTDtdP9jInc@l;{YB-`our+M>I~^RvFu*^NKbp?pN!^+V zNiH>Lu3cCZN+?QdMnvE3#V8B&Qk#@<4Rs`e#|+^N%#ZAn2H+V2{p7uV?<*2kGRIi2 zo*e^svS`X$i|l_b2U+6_aB0m&OUb%q1NbpH^8tH0d{lw#6}Id4%HKKX&HnXd#WBZ| z(|v;RXT*rF=_EhoC7>LeLj2Q%O|H#5<5RtUjAhEM5VzaO_ zkzfZe&>HyN*D?@Zq0|ZZHEI(NZevGAMvmz_aY0#%NDF^HYXWHP&d!e7BeAfF9r6X1 z6=P!2vDtv%Mq55P-9S0+zwjR|G0urEPH--!?$phoV`#^N4i%$={~XgA|4=35%F3fT z@b{U(U_Nn5%|!>bb`HR7iJjBVX)%JiYB`I%LFm!Ob9P}PVUP`!xpTBfE|>doNmKkJ zXUsfEaLP_x2O=c<9w zSbN(vk&^NV8_Ot!Cz4=bvQ@~yBEa<>X+f>MEoL|P_AM*XcCKu}8sn}IA zUni(9<5VI$;9A1c@|(-omlwC@xjDZ923fi`3QZ1hm{sFx%nr`tS4%u#zX2QX|#|udFiSEpwH_m(Sj-BuuQ&_xf9I~yq|<&N{le8<5~-^(>LcI+wk zJj9DX^9Q~zmoBit`{|u|CM4tH3S~hW;mF}928;Z7bW4cdqh*q!!I)(<2!G}TaRh&g5ejNnWhnAo z6_!pX=;rFB-g*!YB-gC^ee0GkUOm(yDvNmJoVKZ&yFC#Sx@XDDo{MSLpL8bl5@%bE z)3nkReg^?#=|2vK@p%@fh70q??MQ|KPU6PlgN%K(k*aq{x;uLFA295cOwOY=O?N$? zKBHD5a?3TC1Eh$sf&hQsNfRM7iS*0nR<3}63ieE2Volxx)C~*{3xXsho_R6qRmYh} zH!=`Y1)Qv7+iJ&=p&WqKb{wJ0cvX9e2RFKoE+YlU!zh4_RjpK3LO$Xxn4SL|uRGe< zZ~*{ya{o$zB?OjE5b?V0=bXt%%Ty?=K*Q7M%i+*%zammh7J`54&rY+i+>+xCE7^g! zshoan<{iWpwz$5* z$NRQ#WN5$t_dkFCRi)~9g=LYwlN$`>(L3gclRs$}Ej56yHfj3Xd#XZKH@doDbiA&T zjZK(gWXC&JW4lQ&pe<9^iE^ytIP)w` zJ2{@z8TG?Zp?DI5MU?0GCfrSU)#{;FfswXtoU8d5r2(K&XeP4je9 zWRd17qrl_3UyfC=d%9rK+C49iX0cJ1t_>ewK$$khNz`$FaFIR{#Ms1}({jlSB~+}1 zw1P%a(G*fbmFB5t4k?GRVrnJj^p#wlXC=;)KU-w~53?{(_YIe{sss?1(y9a#gG6tK zL~jANL~jB{EDuUFc9EtA001Tv000^Q0GI2l1RH;qSl@5kHV}UIU%_P$k}y|GU)G_6 z3T$fzbOTmkEw%?Q5NL^x*-)ZJQg)nS|NFipX<4@HCKV8elJ1VjyYGJK>gwvPaJ*WJ znr*eY7v?o{Yr$;a3A2$_8)hBfS!RUobZg~CFtHIW+ph6Lly84xwZUV7% zS;u!ub1bZb0!{XM#&Ff#aaJ%-o5MJ+w`qUi$ozY`@+6Cd-ihRvwHl{wqe?*rT}HUx zw4Qbr?*Hk9br2o*T_o{QavL_5P#(MPhTVrJrwXJ8+HYA>@wO5w$#U@^t+Q!%P=a>P z`wsu;^!P0zwGwp#3#_?4d9;$Pw17*=T|s`x=_k#yL1a)Z$|U*O#UppzILQ^xGuD4M z#kp;-T%NFR_EuRWD{UGMQ{Y*WN5&+GhDu*3B=B`c-+{`4_20xFHF?bv-H8@hD|PR> z-r>ZF(OZi**H|uhPWbhI!6mvC;2r>)-yk+z8r2FI!~4bUERX|hrfb$KcGw{u9TZwU z~Yr;XLRo}Fzb4%{3HGv}3t~6R> zekd-L5jBz(QURv&Vm;YY11y+u9e@ zkU%c`^N>BFNiGM*kc&Q=ewvE*mW3!Xr?=~Kw)OOb+CEe%c+*GOrGu9!vt2OLv8j9s z@|byf5rr$&+jaU(Ao-dpAJ1C+bHY$=R(97`sV9;^upT>FJXE4{?8g6-x}~xuD432- z7E5E!I~2G&J?YX?&oO_WDn8oC>#t5~CzWFYDD?Kv4|VyYU!?#lBDp~7MoQwM(QQFa z&vFmpc-#nQs_TSh5NW4dS;g1eHBRlZa=wk)YfWvVZ>X>vH58-#@g*JFsrct~X(N9q z>~(q!_F_2Wo8pisi40yXEn4KZo@C48&e?Z%^8jf|U`0YLXbpcOQ!h~x4v=0*yL;Kz z#a=k>8`y^5fdSn*+ro)2DjFPOmIZ=3Sfw0yA61WPmup!woUC1MR497ej<)IOXV(&J0o(nIXBi-uHsL;?P2 zyJ`A3Yuk9YxITX=>gS8_q>01sNd;G<#i^QZn<>n%PdZ~4VQnz*nt$@H{7D5)z`0H#Zib? z_XtWHtt{iZEd86H8;zVhn^f?1%0&@&$oP04u|w>hF$@>7V4h`lm;< zr!hMev6nu=a%@;2%Quy@PDvN*kl7D5TVi+A5}IxHpBnP8Dlr}PYS7OQkVtuES4N(3 zm_P9)AaZ}?@r%Ih9Da|>!sk8}T!ix|t%k2MB>+fKEFU>-;mJqYRkmm{fb?;C{mV15 zV@4Jj4|(yt{A8LPpB(VNX1@v1`E>pML-c=-{!+m}(m_$BtbZFn7lq=?oYpz}Ll8Y> zaZvgK8%6T)RSA_UlB38$0murS72E1w)!5Cku18DaM;~7a#_u>yO=uPwN3*;Uj5?Kk zkN%PrqPcB0L!^~u%NwBGkF8}%ky4g1%0k%8{s&M?0|XQR000O8001FSBmuFheIoz> z`E!@6q5>O#ef?Y8Mz-+p{1vn8b6?4+5hiVVyJ6j~V@%=(Y}PhSbAi{DZ5dQ#$t%ea zw&Z`m=VN9x`Z8qOefH9)u_cXW&YbUa=FE6^clT%$yR*P+idnP?7v5rT<*lOlLHuLm z!N0#ovqU5}ZtN|@>_MbAo*14E&x9K;MEB@}m`7oM=*`n8PMqD{-JN9|t;BS?+@zb> zn@)wlT1RmTt-~mFQ$Grm9c}~frQWAB@Mo$`?~|X7Q+IybR1a5f=w5qqQzV=9I`)!e zM|GDxgmXVqm-A@h&Gkbsjzj(E#!YTu40ZWy5{2q}lrSV~H@z{yID<>oB=*#|l^+`53||$3x;ax2VM}(ZxvX_ zaD5UXawgC|+N9iUHD5dF!`e$!yRI7qlPC&CUa|?&ot;j1(*LO^+QPPgpc`*tHH8&# z!Vn;^8atET=(IoRoKS;#v|0yV%59b|j7Z;q=59Fm0(pVZE~YR(Uid(!INq%BmB!9# z=jSO8H~n$=W(-}vqkbD;c=PMl4&6B!4&R@j84p@c>lM8)>W$A&Cet?` zCq2CX?Wy0{-{%|lo?M*t<_q~ts$nqm+=0`EWm31q3vUUG>W6+ho!W^PEStnfO>ygoi>6os^?>=BVht<= zAF-Fl4=5+?V>iGXFJ4G4mHpu#@TnKu4M+DYdue1XU?6AVrGVT7;9vOj)W%`k+1TwI z0PQ>=?GiZpTVW6h9SkDB0ZwqvNLQ49O$mhUAx>oPaw`5(_=z6^V*}w;1BFRrx-j%6 z4h1z(lcI-}h}3{0-AK7iGagHUWv3D}ygqVMcLop%?~^y*AWcOSLl?-@LJAjfu;3mX z(Y^6NR!}>!+FA#02zq0FoMRe%#vQ=r6vRo0tSETeekrVfln^sJxv@Xm{WmqB!2 zoIKA_&JL%<9Zv&)1*G>r%{_0ya~txAvxn3Zv(4h#105wFABxb0e(wo#?-T2#7{3Mn z+)slC5xYKMVdZ|B;`@nX5ddCL39D2M)u86cfdDTgj;ATVro>x*)g+Mj9MKPxH6W3I zMFbOKK~aJ1C1U0QO1&KDK&=^nAR`4rZHId}E`kirn5{^gi*}pUy3-pB$7kKFT_q(J zesT*YCS;1OW|wW}wQOmGg4vC>Y}2O$4_rf?dXamkl_7bY7&v^CWq$ z-=CW10C_2P!^HPO;-(4kjAA&Ey+fcH@1a0W;*DoOop?`>TMC{Z8TurL9kiwOO2fT_ zOPUSyfn;iUL!&(>c4%%Hd8p`D(P9&Tr{g5RHmPknR^y^|g$!%0A65zY4ORwzxLM7- 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 From afc320ca4b0bb0e44e38e09e46c01b0dc7169aa2 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 23 Oct 2026 22:01:53 -0700 Subject: [PATCH 08/14] Preserve Windows token default ownership for SQLite journals --- src/cdwagent/_private_storage_windows.py | 20 ++++++++++++++++---- tests/test_private_storage_windows.py | 14 +++++++++++--- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/cdwagent/_private_storage_windows.py b/src/cdwagent/_private_storage_windows.py index 133c8ac..750d868 100644 --- a/src/cdwagent/_private_storage_windows.py +++ b/src/cdwagent/_private_storage_windows.py @@ -205,10 +205,18 @@ def __init__(self): if not _security.GetTokenInformation(token, 1, self._user, size, ctypes.byref(size)): raise _error() self.sid = ctypes.cast(self._user, ctypes.POINTER(_TokenUser)).contents.User.Sid + size = wintypes.DWORD() + _security.GetTokenInformation(token, 4, None, 0, ctypes.byref(size)) + if not ctypes.sizeof(_POINTER) <= size.value <= 65536: + raise OSError(errno.ENOTSUP, 'Cannot obtain the Windows process default owner SID.') + self._owner = ctypes.create_string_buffer(size.value) + if not _security.GetTokenInformation(token, 4, self._owner, size, ctypes.byref(size)): + raise _error() + self.owner_sid = ctypes.cast(self._owner, ctypes.POINTER(_POINTER)).contents.value finally: _close(token) - if not _security.IsValidSid(self.sid): - raise ValueError('The Windows process owner SID is invalid.') + if not _security.IsValidSid(self.sid) or not _security.IsValidSid(self.owner_sid): + raise ValueError('A Windows process ownership SID is invalid.') string = _POINTER() if not _security.ConvertSidToStringSidW(self.sid, ctypes.byref(string)): raise _error() @@ -246,8 +254,12 @@ def validate(self, handle, directory, *, owner=True): raise ValueError('Private job storage requires ordinary files and directories without reparse points or hard links.') if owner: with _security_info(handle) as (actual_owner, unused, descriptor): - if not actual_owner or not _security.IsValidSid(actual_owner) or not _security.EqualSid(actual_owner, self.sid): - raise ValueError('Private job storage must be owned by the current Windows user.') + # Elevated Windows tokens can assign their default owner to + # SQLite journals. Accept only that exact token SID, not groups + # in general; the protected DACL still grants only TokenUser. + if (not actual_owner or not _security.IsValidSid(actual_owner) + or not any(_security.EqualSid(actual_owner, sid) for sid in (self.sid, self.owner_sid))): + raise ValueError('Private job storage must be owned by the Windows account or its process default owner.') return _identity(information) def assert_private(self, handle, directory): diff --git a/tests/test_private_storage_windows.py b/tests/test_private_storage_windows.py index ccf5878..dbf9d94 100644 --- a/tests/test_private_storage_windows.py +++ b/tests/test_private_storage_windows.py @@ -3,6 +3,7 @@ import json import os from pathlib import Path +import shutil import subprocess import tempfile import unittest @@ -25,8 +26,11 @@ def setUp(self): def powershell(self, script, path): environment = dict(os.environ, PRIVATE_STORAGE_TEST_PATH=str(path)) + # A parent PS7 module path can prevent Windows PowerShell 5 from loading + # its own security module. Let the selected engine build its defaults. + environment.pop('PSModulePath', None) result = subprocess.run( - ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', + [shutil.which('pwsh.exe') or 'powershell.exe', '-NoProfile', '-NonInteractive', '-Command', "$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new(); " + script], env=environment, capture_output=True, text=True, encoding='utf-8', timeout=30, ) @@ -44,6 +48,7 @@ def acl(self, path): inherited=$_.IsInherited; inheritance=[int]$_.InheritanceFlags} }); @{owner=$owner; current=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value; + tokenOwner=[Security.Principal.WindowsIdentity]::GetCurrent().Owner.Value; protected=$acl.AreAccessRulesProtected; rules=$rules; sddl=$acl.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::All)} | ConvertTo-Json -Depth 5 -Compress @@ -70,9 +75,9 @@ def broad_acl(self, path): Set-Acl -LiteralPath $env:PRIVATE_STORAGE_TEST_PATH -AclObject $acl """, path) - def assert_private(self, path, directory): + def assert_private(self, path, directory, *, token_owner=False): acl = self.acl(path) - self.assertEqual(acl['owner'], acl['current']) + self.assertIn(acl['owner'], (acl['current'], acl['tokenOwner']) if token_owner else (acl['current'],)) self.assertTrue(acl['protected']) self.assertEqual(acl['rules'], [dict(sid=acl['current'], type='Allow', rights=0x001F01FF, inherited=False, @@ -200,8 +205,11 @@ def test_normal_job_and_concurrent_sqlite_store_stay_usable(self): with store.connect() as database: database.execute('CREATE TABLE synthetic_check(value TEXT)') database.execute("INSERT INTO synthetic_check VALUES ('synthetic')") + journal = Path(str(store.dbpath) + '-journal') + self.assertTrue(journal.is_file()) second = jobs.JobStore(PACKAGE, self.base) self.addCleanup(second._storage.close) + self.assert_private(journal, False, token_owner=True) with patch.object(backend, 'check_credentials'), patch.object(jobs.subprocess, 'Popen') as popen: data = store.submit('SELECT 1 AS value', {}, {'password': 'synthetic'}, format='csv') wire = json.loads(popen.return_value.stdin.write.call_args.args[0]) From 0aacee94b761248b609e0cb74c011dc1af97511e Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 23 Oct 2026 22:02:11 -0700 Subject: [PATCH 09/14] Rebuild bundle after Windows SQLite ownership compatibility fix --- extensions/SHA256SUMS | 2 +- extensions/cdwagent.brxt | Bin 307350 -> 307532 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/SHA256SUMS b/extensions/SHA256SUMS index 5b5f8e6..88f39d7 100644 --- a/extensions/SHA256SUMS +++ b/extensions/SHA256SUMS @@ -1 +1 @@ -4b92b8fc818b385f841041550511cac8d63ac9b2fbfaf0ceba3104decea0cbcf cdwagent.brxt +148502d8e49ef1e0ac896c1a21f67567e0b2e343dde50014fb284dde81f86179 cdwagent.brxt diff --git a/extensions/cdwagent.brxt b/extensions/cdwagent.brxt index 4a0a071c8d4b7cae669872c4a47e98a7c51c00a8..97f4f929ebd2b42e28686cb02cdc60046abfabfc 100644 GIT binary patch delta 4821 zcmV;`5-RPM;S$W^60j#;3XC+K;A#;708mJiFIP5=>}DvXq?9 zj_Q^csSE2EQAg47rfd}o1coAF2;c&sJ~qjp+I`yB{Ic!tnE?ia0SWq&tz#uNf$8b* z>FMqn`q{H*-+FNn@#p@U`yYAGXZ|)#d7M8_9%7z8PvYo_WqFc%x16n#l)Ve%AbH4G zuuZ*xWyIN!$ub)}d-kleN|QV0x~pxzO}Xo`@NSc&IrHK;$-O*G;;bWJ{3Oo#V;+S| z1tZ_wc`;B)JL+xzwBebYL*EtrLl}!O`OMQaPSispr)<1@tLATdY zeB5RCewueW9rq(oV;-?HDa8PQN7Jd(74J`8bo*?ZaX0d^+~r98Y>{qxuj6JMj1lHf z^{HNP?`^^zQ?F)|%f)zJ&h*{;Bn;e5$DNOdBlmoExtPzUFn;uSbaeCr{y*xt@8*+# z#n>GU&(-@=@t&p(7mNAi&D+KJnnqv3`|0F*;f^Nr@%dsl{{dbfciii5hx4(RLFnM^ ziF!L4PsjA;v{RXO^mt5Eu7>mLF+HCm**C-U|9N}mUXQ;UUM?o*g0A2dhi6ygOEpOd zEZKe`0VbE<52urn`|a>@G)25hEza|QJiwyn$H%% z=Xg{ZK=BnD^u7R-(%`_wWIA?-)2TZ=KObL9?vIWy;J*v-3m>eOG51;;d6?&ExZLJE zbEL_x^K|RuYU&9mbyp#eg3JX(@11TuZ=5(WtLObGrnV*Im za9n^?^7s0LQ2#jH) zM+hO2QiVqy-$fYlNerRvj-paKtlkb@Q5HYtgiRks^DidfjjhV|e4l5T9UO)qzz)1M zvil_3-f=r;S@<{ZuESfKqgYUX5qOk5G`Lk5f3#`9f(Qa4e{3RLj9V#b#v1z3x7-Wb z8b9UtVP=@&%?}HqQ;5?%z2`xFut5(xU}@=O3%f>b#DYT6dr^a~2*?i+@FS?rD%>ja zZZqqMRg#->UmE;3&(Fo4f0ZWal>;xm75gpFyx9Q<5+WRc71%|nE#{nmM@hy-K&fCg z;BTxGEa)*36|mF9_o9oGS5d1Nb7O%kF)FB%Fs@*Z!Qq{q)9KauyX(c=^w!#Q%@d4| zw6ofYEb+g0FwRDnYUJni?aI#&jg z-c1_{Ety-uNIO5*Cs>ewlO4+ha4_5OSgtzS4|j`fx`Uwt10FTdOsG-vVhV^tpqk!V zS;Ti?pv(#OI(ehFH;JcqlijTD7w|lZ?|GU-MzKgBJcscue0^sPI(2tb8PCmrgzY&I zb7~nLV5_~W^4Y2AuQ8-G32}(7KZ(~?-d3MD^8BbSXQNT7iS4I&YmM8C(8 zWkSlKW{9;mk?3N7$>&?1Yds>T*HbvK6XxI_5>2>bPMqWns;D^2z1SC{eU{~Ek0mLi z)>#-sT>&*kcR+t&*GqR50Q2G}$6tG?=jS{{c$$a(!^+$4mmfa7`fyaAkKh9y2iZfI zuN|k$1hrmI@<}37&+dEd;>ecWmC>dv+H*WcaCjKD$KruIBXD@a?C)Ql+z4Ht&%Q$x)2l_dR}PEO?(vJ0)33h% zkC*@X>py>g{rj)~=18-ed62ivk_d8p z2h}M_Q)dm7m}CQdI{+B`+fmQ_aRWkZv8MyCR$`Fm_X_nAjsf^!nky&x0H6Q*@BuY} zUkC7Yw?Ei~-FWZ~Y1zd7F>g z97WJluHs{D@2sh!D&viKhKNJzyu_u79xh-10ZR318&?y!-ZN0x70Cq38S<=tsoFmK zybqjzZM;x+%LPW!k;B$Nr9u8PGrQl4@d9DVk;L&cAgQ*-OIsjW>x82BPJ^GZInTW? z=0QnE>I9D?_&vG<3Pyny&_>|bcNoVQ)|KoIW(QvGNn~oK7e&bf7z~W#MX)X+jx`W_ zSmT^552J`JVbMUwE?Lc3p0IokPO$tDxEP3k7)jm$JlA^tKQrg3q8r8|@TyAtUwDYL z?-{eSCLkE5A|CXmhWdhLpZufGPFmzFw&MXXrg^3yQ1%ROJlo-RVxg$5RCjkVS@~Y5 zHJK|;UxbueFS-(0_O~S@+9Ih~KBasm7kE7~+BmK*&@cc8f&|3TSg+<6+Q2|`o%>9G zhaQw|U-r#6?BqocbY33^Cj^>*HqQNs2bBbo`l@=P6G>ReP*uU7MWLdf>-72!-wA=A z1mtl|X?LN|W0{eG9{c8u7!xO;`1JKxU!8pYs^*14%GtH>89HSjj4x+@i|e;n zn9zzuAX(x*MSDbfTu#~)j>N7fBeOyfHpT={JD{fc?`G65a+llH3ev-pO{<7)RaesM zljK$%HpySJav?z$1{(iRUpYDK+Xi}P%N&jTnke;=F!6dkCX{+vG!%UQ@e3b0Kk$L7}b%XmS>jdW%rj94O14ilh8n z6#YA)3OTGXvRY7BBSf|iH)v_B0mAqmk?TemMy>4f%_7)a>xDR`=?LPJgS$lPViHpt z@j-f_mn-~xj8oe|&1rVZWsZGT+ccf-Y;r!at(;XzS$BPc2FxFQzRB5t8!zKy`U(re z!hmq9UR`&ca+B96;G8}fm@b)MYe`dkLpE3XKoH;ss#SOP>Zgj@e;K{Ha)z&e($N0$ z`eOL{^=kvJ*WK?lha6Qn$P#TXO$nK-EL!RO{GdXTdq_okmN~4FvPq!Ld8tG_o$*3 zCjXFDZy`69tq%yk8C}%il~7e)6Qb(4@n)CWU9D&Tx{fbAD4$FGvI;B72*a1_je;la zgmST|dZya@APM!>2kETb`;d1_@v5uWv7BQoIPBRRqtf-dPt(kQpO~u3YrX=LmPryh z%`=OR3Xv(~vSFe|b{QnGsifnjyG+EZ-K%W9JFU~DHJ4Rm8AEGEbQIMPp0RnEY;)?T zq=0vmz>>sSV4XBz#D?xectsTrROJb=G!)0o%b4T&`PPe6*07Bs1r2mpvg9Y9v2nyv zte|joN)V_q3P`hm@Rm}vFg230Jh9-q{@-ax8lu)xHef@aHW6A#`P$22R{z zpVrcCnrt_j1s|k`w;ZT>(QC>RA!Eont~aq7MX%~M^+=aUqe!m@HEilsLs!zyyhaD? zHAXav0na+>=SGG_D3!uyF6MjK(=Rg;BZ;!5lI^|nZkthmfI@r$TRv5CY|p3LYqU1= zhLl@(MHz1FeW5bwfUCgn>B@F+Q|K{E{32Av9=pFU$X|pGaPH4*kr7s&!x0xB@Czz)ccy0 zV-#(G#->SL(_{tJotJ&oujp^V2~S=O@b?R@7X&G`qyV$D=NIg>XH75lZj0i@d`qwX$W*2y1RmhsQ}lo%+PlPmJAnvZ8mIw+WW(bd;~Km*xa}-5 ztyO3dXUiB$(epQQ&y*J(3X4z5W1Dmv(TP_P38f?dmLX6k_Ks2V)$LYPcfI$=WxKT2 z^1j~D@-Bbj@H4ah@Zo^uUbLOWpJL&a$1KvXs(tFxQq8^ULUG29V^LpW5>ccYJ!ag9 zwUQHm3|%Y0_SNXN!mM7!`4RUpeyBdU+_Fd2qr+M|yw9}L^b)f=#j7;L^&uERH5+5v z&xq=ybPM9lJjdVDdeBi8MT`+9c4Au0a zYGxaOD%<7C6R?VqsEjOhYRB-UIF^%mQw3jto|Tj9K-9X#5KeS!<24JYPkDm6LuZ8* z&PtZ+8Le#iU?q2%(Yj#ScY%8#qM zL?u|kFjh7rp#2_IYTNh?F$iP;d83$wxzWp$X4Lo*0gm8*ZA~QIxtiPckJ`LrnUNeJ=&1^d3V(%2~v3J^zW}x?)QX4$ASWYJ7`-M^9c1 zIrd|i<@f-}@M{BKS-&b@W^`SSmMXSk1U_{}fj^svtN|k@RuEaWVYQ;pprJi+^AF-m z#sf#{+OixCL#lFQhaIgaQY8x&y2`CAgsXS^w0^XRghrsk+p~J`4l_zj_jO?x>*}3D zO*@&$^DE7GV`&!Nusf@)#hQYD+1=YC^`k4hOj9M=P9$YmK*vxLGNNa>dv7#<3aXIV9zCAR ze?6eMUaDO;EiQt#EVpa7Fq>27mKcvIy;V9D?S<36?M9`gtZ#9HlDy!ML-yv7X4#@E zXUXDU;TVwTB-v_Ry*CM&%gmjR-pxVp^!?4`ayb9N==WJmYb&!Pm~d~r*d9>e(c8h@ z^owP;&%^~4Sg&VCW`}NnU!sVgB$KJ4tqD<4@Ik?#SZBd@6NNrrs96PO?B3l?GylPq z`rT#R7_q^2HM^etMH|<B1k)t(_V9ViY{W7I=Mtoa=e$-k`pNrE%GiWLhHneF0j~ec4D&T(s zP)h>@m#V4+8kf?l1QUZqZ-+#00f$6y0*6Fz1GhwP1R6UEj5MC$Y7qbcP)L_(ssv^M z$(PTn1V92Za+eXS1VRFUbd%>XoR{jW1V#f%qXL((q5>6{ zNUQ`Z15w8Um$=3PAeWV_1S$f3(3ioi1W5vV)R)1m1QnMdtpqRu(U(%K1VaJvmzJ#r zL;_*^m)xxcN&~z50+;R$0~VJ*t^_dxhYpufuLKpBhpq%Q0)`luQLh9Qm(#8UGXfGH zmr<_-6_*{a1Tq36HJ4GZ1W5w+L6=dl1QnODuLLs!@=cdfuLKpBZA}9RmnE+R5SJ6M v1R4T#T9+@d1V$lyTLS=fb}nphV{1@L1qJ{B0018V9{^?u007Xa1ONa4=aX5t delta 4648 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 From 32f97a3a04e04cc4eac6311b17c5b0c03c77f76d Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 23 Oct 2026 22:04:05 -0700 Subject: [PATCH 10/14] Inspect Windows ACL SIDs without account-name resolution --- tests/test_private_storage_windows.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_private_storage_windows.py b/tests/test_private_storage_windows.py index dbf9d94..26a5db6 100644 --- a/tests/test_private_storage_windows.py +++ b/tests/test_private_storage_windows.py @@ -41,9 +41,9 @@ def acl(self, path): return json.loads(self.powershell(""" $acl=Get-Acl -LiteralPath $env:PRIVATE_STORAGE_TEST_PATH; $sidType=[Security.Principal.SecurityIdentifier]; - $owner=([Security.Principal.NTAccount]::new($acl.Owner)).Translate($sidType).Value; - $rules=@($acl.Access | ForEach-Object { - @{sid=$_.IdentityReference.Translate($sidType).Value; + $owner=$acl.GetOwner($sidType).Value; + $rules=@($acl.GetAccessRules($true,$true,$sidType) | ForEach-Object { + @{sid=$_.IdentityReference.Value; type=$_.AccessControlType.ToString(); rights=[int64]$_.FileSystemRights; inherited=$_.IsInherited; inheritance=[int]$_.InheritanceFlags} }); From 4c448c0322f02bfcb9012a88e9988e39b96b6f9d Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 23 Oct 2026 22:11:21 -0700 Subject: [PATCH 11/14] Preserve explicit legacy storage recovery refusals --- docs/QUERY_JOBS.md | 16 ++++++++++++++++ src/cdwagent/private_storage.py | 2 ++ tests/test_private_storage.py | 13 +++++++++++++ 3 files changed, 31 insertions(+) diff --git a/docs/QUERY_JOBS.md b/docs/QUERY_JOBS.md index 0fac711..9a8c648 100644 --- a/docs/QUERY_JOBS.md +++ b/docs/QUERY_JOBS.md @@ -72,6 +72,22 @@ administrator-controlled parent directory. SQLite and process-lease/final-rename operations still use pathnames after identity checks; this update does not claim to eliminate every pathname replacement race in the complete job runtime. +On Windows, automatic repair first checks every managed directory for mutation +rights granted to another ordinary account. A writable known child remains +accessible to that account even when its parent is private, so a protected parent +alone does not make legacy migration safe. Such stores return +`private_storage_requires_quiescent_repair` before changing existing ACLs. Unknown +ACL formats receive the same conservative refusal. Already-private stores and +legacy read-only inheritance continue through normal automatic repair. + +For this refusal, stop the connector and other writers and ask an administrator +to repair only the owned ordinary entries without following links, verify that +other ordinary accounts cannot mutate any managed directory, and retry. Another +option is a fresh private `UCSF_AGENT_JOB_ROOT`, retaining the old store untouched +for trusted recovery. Moving an old directory alone does not repair its existing +ACLs. The connector does not delete old data or silently migrate an actively +shared store. Ordinary job use needs no additional approval prompts. + Status records queued/running/terminal states and separate connecting/planning/ executing/streaming phases, rows written, bytes, heartbeat, elapsed time and advisory ETA. A heartbeat proves diff --git a/src/cdwagent/private_storage.py b/src/cdwagent/private_storage.py index 42b371f..cdc9dfc 100644 --- a/src/cdwagent/private_storage.py +++ b/src/cdwagent/private_storage.py @@ -186,6 +186,8 @@ def private_storage_call(action): try: return action() except (OSError, ValueError) as error: + if isinstance(error, PrivateStorageError): + raise if isinstance(error, OSError) and error.errno == errno.ENOSPC: raise PrivateStorageError( 'Local job storage is full. Free space before explicitly resubmitting.', diff --git a/tests/test_private_storage.py b/tests/test_private_storage.py index 873c7f9..996d874 100644 --- a/tests/test_private_storage.py +++ b/tests/test_private_storage.py @@ -158,6 +158,19 @@ def test_linux_second_uid_reads_control_but_cannot_read_private_result(self): class StorageFailureToolTests(unittest.TestCase): + def test_specific_safe_recovery_refusal_survives_the_storage_wrapper(self): + refusal = storage.PrivateStorageError( + 'Stop writers and have an administrator safely repair this owned legacy store.', + code='private_storage_requires_quiescent_repair', + ) + def refuse(): + raise refusal + with self.assertRaises(storage.PrivateStorageError) as caught: + storage.private_storage_call(refuse) + self.assertIs(caught.exception, refusal) + self.assertEqual(jobs.failure_details(caught.exception, 'storage')['code'], + 'private_storage_requires_quiescent_repair') + def test_full_disk_keeps_actionable_storage_category(self): def full(): raise OSError(errno.ENOSPC, 'private-path-marker') From 0d2066c76fdf38974b95ec559e42a09f6f676971 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 23 Oct 2026 22:11:42 -0700 Subject: [PATCH 12/14] Check safe recovery guidance through the actual MCP tool response --- tests/test_private_storage.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/test_private_storage.py b/tests/test_private_storage.py index 996d874..5052cfb 100644 --- a/tests/test_private_storage.py +++ b/tests/test_private_storage.py @@ -165,11 +165,19 @@ def test_specific_safe_recovery_refusal_survives_the_storage_wrapper(self): ) def refuse(): raise refusal - with self.assertRaises(storage.PrivateStorageError) as caught: - storage.private_storage_call(refuse) - self.assertIs(caught.exception, refusal) - self.assertEqual(jobs.failure_details(caught.exception, 'storage')['code'], - 'private_storage_requires_quiescent_repair') + async def run(): + from fastmcp import Client, FastMCP + mcp = FastMCP('legacy storage recovery') + with patch.object(jobs, 'JobStore', side_effect=lambda package: storage.private_storage_call(refuse)): + jobs.register_job_tools(mcp, PACKAGE, {}, prefix='fixture-') + async with Client(mcp) as client: + result = await client.call_tool('fixture-query_job_capacity', {}, raise_on_error=False) + self.assertTrue(result.is_error) + self.assertEqual(result.structured_content['error']['code'], + 'private_storage_requires_quiescent_repair') + self.assertIn('administrator', result.structured_content['error']['message']) + self.assertNotIn(jobs.OPERATION_META_KEY, result.meta or {}) + asyncio.run(run()) def test_full_disk_keeps_actionable_storage_category(self): def full(): From ab33f32701e1ea64c813d660d8638ffc3e7665f8 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 23 Oct 2026 22:12:58 -0700 Subject: [PATCH 13/14] Refuse unsafe Windows legacy ACL migration before repairs --- src/cdwagent/_private_storage_windows.py | 85 +++++++++++++++++++++-- tests/test_private_storage_windows.py | 87 +++++++++++++++++++++++- 2 files changed, 164 insertions(+), 8 deletions(-) diff --git a/src/cdwagent/_private_storage_windows.py b/src/cdwagent/_private_storage_windows.py index 750d868..645e3ba 100644 --- a/src/cdwagent/_private_storage_windows.py +++ b/src/cdwagent/_private_storage_windows.py @@ -33,6 +33,8 @@ _PROTECTED_DACL_INFO = 0x80000000 _DACL_PROTECTED = 0x1000 _FILE_ALL_ACCESS = 0x001F01FF +_MUTATION_RIGHTS = 0x510D0156 +_KNOWN_FILE_RIGHTS = _FILE_ALL_ACCESS | 0xF1000000 class _SecurityAttributes(ctypes.Structure): @@ -89,6 +91,8 @@ class _AceHeader(ctypes.Structure): _security.GetTokenInformation.restype = wintypes.BOOL _security.ConvertSidToStringSidW.argtypes = [_POINTER, ctypes.POINTER(_POINTER)] _security.ConvertSidToStringSidW.restype = wintypes.BOOL +_security.ConvertStringSidToSidW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(_POINTER)] +_security.ConvertStringSidToSidW.restype = wintypes.BOOL _security.ConvertStringSecurityDescriptorToSecurityDescriptorW.argtypes = [ wintypes.LPCWSTR, wintypes.DWORD, ctypes.POINTER(_POINTER), ctypes.POINTER(wintypes.DWORD)] _security.ConvertStringSecurityDescriptorToSecurityDescriptorW.restype = wintypes.BOOL @@ -108,6 +112,8 @@ class _AceHeader(ctypes.Structure): _security.SetSecurityInfo.restype = wintypes.DWORD _security.IsValidSid.argtypes = [_POINTER] _security.IsValidSid.restype = wintypes.BOOL +_security.GetLengthSid.argtypes = [_POINTER] +_security.GetLengthSid.restype = wintypes.DWORD _security.EqualSid.argtypes = [_POINTER, _POINTER] _security.EqualSid.restype = wintypes.BOOL _security.GetAce.argtypes = [_POINTER, wintypes.DWORD, ctypes.POINTER(_POINTER)] @@ -193,6 +199,7 @@ def _security_info(handle): class _Security: def __init__(self): self._descriptors = [] + self._owned_sids = [] token = wintypes.HANDLE() if not _security.OpenProcessToken(_kernel.GetCurrentProcess(), 8, ctypes.byref(token)): raise _error() @@ -225,12 +232,31 @@ def __init__(self): finally: _kernel.LocalFree(string) try: + self.system_sid = self._sid('S-1-5-18') + self.administrators_sid = self._sid('S-1-5-32-544') self.directory = self._descriptor('OICI') self.file = self._descriptor('') except BaseException: self.close() raise + def _sid(self, text): + sid = _POINTER() + if not _security.ConvertStringSidToSidW(text, ctypes.byref(sid)): + raise _error() + self._owned_sids.append(sid) + if not _security.IsValidSid(sid): + raise ValueError('A well-known Windows security SID is invalid.') + return sid + + def _owner_allowed(self, owner): + if _security.EqualSid(owner, self.sid): + return True + return bool(_security.EqualSid(owner, self.owner_sid) and any( + _security.EqualSid(self.owner_sid, privileged) + for privileged in (self.system_sid, self.administrators_sid) + )) + def _descriptor(self, inheritance): descriptor = _POINTER() sddl = f'O:{self.sid_text}D:P(A;{inheritance};FA;;;{self.sid_text})' @@ -254,14 +280,57 @@ def validate(self, handle, directory, *, owner=True): raise ValueError('Private job storage requires ordinary files and directories without reparse points or hard links.') if owner: with _security_info(handle) as (actual_owner, unused, descriptor): - # Elevated Windows tokens can assign their default owner to - # SQLite journals. Accept only that exact token SID, not groups - # in general; the protected DACL still grants only TokenUser. + # Elevated tokens give SQLite journals an administrator owner. + # A non-admin group owner would give other ordinary members + # implicit WRITE_DAC even after an owner-only DACL repair. if (not actual_owner or not _security.IsValidSid(actual_owner) - or not any(_security.EqualSid(actual_owner, sid) for sid in (self.sid, self.owner_sid))): - raise ValueError('Private job storage must be owned by the Windows account or its process default owner.') + or not self._owner_allowed(actual_owner)): + raise ValueError('Private job storage must be owned by the Windows account or its exact administrator process default owner.') return _identity(information) + @staticmethod + def _unsafe_legacy_directory(): + from .private_storage import PrivateStorageError + return PrivateStorageError( + 'Existing Windows job storage is writable by another ordinary account, ' + 'or its ACL cannot be safely inspected. Stop all processes using the store, ' + 'move it to an account-owned local directory and have its owner or administrator ' + 'remove other accounts\' write/delete/change-permission access throughout the tree ' + 'before retrying. Automatic ACL repair was refused; no existing ACL was changed.', + code='private_storage_requires_quiescent_repair', kind='permission_denied', + ) + + def preflight_directory(self, handle): + self.validate(handle, True) + with _security_info(handle) as (owner, dacl, descriptor): + if not dacl: + raise self._unsafe_legacy_directory() + header = ctypes.cast(dacl, ctypes.POINTER(_AclHeader)).contents + if header.revision not in (2, 4): + raise self._unsafe_legacy_directory() + for index in range(header.count): + ace = _POINTER() + if not _security.GetAce(dacl, index, ctypes.byref(ace)): + raise self._unsafe_legacy_directory() + entry = ctypes.cast(ace, ctypes.POINTER(_AceHeader)).contents + if entry.kind not in (0, 1) or entry.flags & ~0x1F or entry.size < 16: + raise self._unsafe_legacy_directory() + sid = _POINTER(ace.value + 8) + if not _security.IsValidSid(sid) or 8 + _security.GetLengthSid(sid) > entry.size: + raise self._unsafe_legacy_directory() + mask = ctypes.c_uint32.from_address(ace.value + 4).value + if mask & ~_KNOWN_FILE_RIGHTS: + raise self._unsafe_legacy_directory() + if entry.kind == 1 or entry.flags & 8: + continue + trusted = any(_security.EqualSid(sid, safe) for safe in ( + self.sid, self.system_sid, self.administrators_sid, + )) + # Conservatively refuse a foreign mutation allow ACE even if + # a separate deny ACE might narrow its effective rights. + if not trusted and mask & _MUTATION_RIGHTS: + raise self._unsafe_legacy_directory() + def assert_private(self, handle, directory): self.validate(handle, directory) with _security_info(handle) as (owner, dacl, descriptor): @@ -294,6 +363,9 @@ def close(self): for descriptor in self._descriptors: _kernel.LocalFree(descriptor) self._descriptors.clear() + for sid in self._owned_sids: + _kernel.LocalFree(sid) + self._owned_sids.clear() class PrivateDirectory: @@ -353,6 +425,9 @@ def inspect(path): if directory: inspect(Path(child.path)) inspect(self.path) + for handle, directory in entries: + if directory: + self._security.preflight_directory(handle) # Validate the entire legacy tree before changing any existing ACL; # protected children are repaired before inheritable parent rules. for handle, directory in reversed(entries): diff --git a/tests/test_private_storage_windows.py b/tests/test_private_storage_windows.py index 26a5db6..ff09dc0 100644 --- a/tests/test_private_storage_windows.py +++ b/tests/test_private_storage_windows.py @@ -1,5 +1,7 @@ """Real NTFS/Windows ACL checks, with synthetic data and no database access.""" import importlib +from contextlib import contextmanager +import ctypes import json import os from pathlib import Path @@ -54,7 +56,7 @@ def acl(self, path): ConvertTo-Json -Depth 5 -Compress """, path)) - def broad_acl(self, path): + def broad_acl(self, path, *, mutation=False): self.powershell(""" $item=Get-Item -Force -LiteralPath $env:PRIVATE_STORAGE_TEST_PATH; $acl=if ($item.PSIsContainer) {[Security.AccessControl.DirectorySecurity]::new()} @@ -66,14 +68,17 @@ def broad_acl(self, path): [Security.AccessControl.InheritanceFlags]::ObjectInherit } else {[Security.AccessControl.InheritanceFlags]::None}; foreach ($identity in @($sid,[Security.Principal.SecurityIdentifier]::new('S-1-1-0'))) { + $rights=if ($identity.Equals($sid) -or MUTATION_FIXTURE) { + [Security.AccessControl.FileSystemRights]::FullControl + } else {[Security.AccessControl.FileSystemRights]::ReadAndExecute}; $rule=[Security.AccessControl.FileSystemAccessRule]::new( - $identity,[Security.AccessControl.FileSystemRights]::FullControl, + $identity,$rights, $inheritance,[Security.AccessControl.PropagationFlags]::None, [Security.AccessControl.AccessControlType]::Allow); $acl.AddAccessRule($rule) }; Set-Acl -LiteralPath $env:PRIVATE_STORAGE_TEST_PATH -AclObject $acl - """, path) + """.replace('MUTATION_FIXTURE', '$true' if mutation else '$false'), path) def assert_private(self, path, directory, *, token_owner=False): acl = self.acl(path) @@ -182,6 +187,82 @@ def test_foreign_owner_is_refused_without_taking_ownership(self): self.windows._close(handle) self.assertEqual(self.acl(path)['sddl'], before['sddl']) + def test_nonadministrator_default_owner_cannot_authorize_acl_changes(self): + canary = self.base / 'owner-canary.txt' + canary.write_bytes(b'unchanged owner canary') + self.broad_acl(canary) + before = self.acl(canary)['sddl'] + context = self.windows._Security() + self.addCleanup(context.close) + ordinary_group = context._sid('S-1-5-32-545') + context.owner_sid = ordinary_group + self.assertFalse(context._owner_allowed(ordinary_group)) + actual_security_info = self.windows._security_info + + @contextmanager + def group_owned(handle): + with actual_security_info(handle) as (owner, dacl, descriptor): + yield ordinary_group, dacl, descriptor + + handle = self.windows._open(canary, False) + try: + with patch.object(self.windows, '_security_info', group_owned), self.assertRaisesRegex(ValueError, 'owned'): + context.protect(handle, False) + finally: + self.windows._close(handle) + self.assertEqual(self.acl(canary)['sddl'], before) + self.assertEqual(canary.read_bytes(), b'unchanged owner canary') + + def test_foreign_writable_nested_directory_refuses_before_propagation(self): + outside_parent = self.base / 'outside' + outside_parent.mkdir() + self.broad_acl(outside_parent) + outside = outside_parent / 'unprotected-canary.txt' + outside.write_bytes(b'outside propagation canary') + self.assertFalse(self.acl(outside)['protected']) + legacy = self.base / 'legacy' + nested = legacy / 'nested' + nested.mkdir(parents=True) + self.broad_acl(legacy) + self.broad_acl(nested, mutation=True) + before = {entry: self.acl(entry)['sddl'] for entry in (legacy, nested, outside)} + attempts = [] + original = self.windows._Security.protect + + def insert_at_first_directory_repair(context, handle, directory): + attempts.append(directory) + if directory and not (nested / 'injected-hardlink').exists(): + os.link(outside, nested / 'injected-hardlink') + return original(context, handle, directory) + + error = importlib.import_module(PACKAGE + '.private_storage').PrivateStorageError + with patch.object(self.windows._Security, 'protect', insert_at_first_directory_repair): + with self.assertRaises(error) as refused: + self.windows.PrivateDirectory(legacy) + self.assertEqual(refused.exception.code, 'private_storage_requires_quiescent_repair') + self.assertIn('Stop all processes', str(refused.exception)) + self.assertEqual(attempts, []) + self.assertEqual({entry: self.acl(entry)['sddl'] for entry in before}, before) + self.assertEqual(outside.read_bytes(), b'outside propagation canary') + self.assertFalse((nested / 'injected-hardlink').exists()) + + def test_unknown_directory_ace_refuses_before_repair(self): + path = self.base / 'private' + with self.windows.PrivateDirectory(path) as root: + before = self.acl(path)['sddl'] + unknown = ctypes.create_string_buffer(32) + ctypes.cast(unknown, ctypes.POINTER(self.windows._AceHeader)).contents.kind = 9 + address = ctypes.addressof(unknown) + + def unknown_ace(dacl, index, destination): + ctypes.cast(destination, ctypes.POINTER(ctypes.c_void_p)).contents.value = address + return True + + error = importlib.import_module(PACKAGE + '.private_storage').PrivateStorageError + with patch.object(self.windows._security, 'GetAce', unknown_ace), self.assertRaises(error): + root._security.preflight_directory(root._handle) + self.assertEqual(self.acl(path)['sddl'], before) + def test_retained_directory_prevents_replacement_and_names_are_plain(self): path = self.base / 'private' with self.windows.PrivateDirectory(path) as root: From ded5d6eeb56c833ce15bc1c63cf474c350c48acb Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 23 Oct 2026 22:13:17 -0700 Subject: [PATCH 14/14] Rebuild bundle with safe Windows legacy migration preflight --- extensions/SHA256SUMS | 2 +- extensions/cdwagent.brxt | Bin 307532 -> 308800 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/SHA256SUMS b/extensions/SHA256SUMS index 88f39d7..7aa8168 100644 --- a/extensions/SHA256SUMS +++ b/extensions/SHA256SUMS @@ -1 +1 @@ -148502d8e49ef1e0ac896c1a21f67567e0b2e343dde50014fb284dde81f86179 cdwagent.brxt +1e0923a6616a47719ed4acc7f2684bde8304f5a14db616959b8ddd851cff3bcd cdwagent.brxt diff --git a/extensions/cdwagent.brxt b/extensions/cdwagent.brxt index 97f4f929ebd2b42e28686cb02cdc60046abfabfc..4d60c02ad800cf3b983d0e67af50b77ff15810ee 100644 GIT binary patch delta 12798 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 delta 11462 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 Q5JfA10Cuhd@ZTf;4=y@I&;S4c