Skip to content

One-step desktop setup: python oc-desktop.py self-bootstraps everything#2137

Open
JSv4 wants to merge 39 commits into
mainfrom
claude/packaging-setup-simplify-plgwgj
Open

One-step desktop setup: python oc-desktop.py self-bootstraps everything#2137
JSv4 wants to merge 39 commits into
mainfrom
claude/packaging-setup-simplify-plgwgj

Conversation

@JSv4

@JSv4 JSv4 commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Goal

Make the single-user desktop packaging usable by a minimally technical end user: from a source download to the app in a browser in as close to one step as possible, cross-platform (Windows/macOS/Linux).

Validated by two clean-room usability tests with an agent role-playing a non-technical user: one against the baseline flow (to find the real cliffs), one against the new flow (to confirm they're gone). Results are posted as a PR comment.

What the baseline test found

The old flow (yarn build + pip install + OC_DESKTOP_PASSWORD=... python oc-desktop.py) failed a non-technical user four ways:

  1. BLOCKER — every fresh desktop install died mid-migration: the embedded pgserver Postgres bundles no contrib extensions, so annotations/0074's unconditional TrigramExtension() raised NotSupportedError: extension "pg_trgm" is not available. Escaping it required compiling the extension from PostgreSQL source.
  2. MAJOR — step 1 (yarn build) fails as written (yarn install never mentioned) and assumes a Node toolchain.
  3. MAJOR — desktop docs unreachable: not linked from README, quick start, or the mkdocs nav; login username desktop documented nowhere (README's admin fails).
  4. MINOR — no venv guidance, random port each launch, no stop instructions.

What changes

One command now does everything: python3 oc-desktop.py (Windows: py oc-desktop.py).

  • opencontractserver/desktop/bootstrap.py (new, stdlib-only): Python version gate 3.10–3.12 (pgserver has no cp313 wheels) with a plain-English error; asks for the login password up front (the one interactive question, then setup runs unattended); creates a private venv under the per-user app-data dir; installs requirements/desktop.txt (auto-reinstalls when the requirement files change, via content fingerprint) under a cross-process install lock; re-execs the launcher inside the venv. Loop-guarded, with actionable failure messages (missing python3-venv, disk full/read-only, no network).
  • opencontractserver/desktop/spa_dist.py (new): removes the Node requirement. SPA resolution: repo frontend/dist → version-stamped cached download in app-data → opencontracts-frontend-dist.zip from the GitHub release (version-matched tag, then latest; SHA-256-verified, size-capped, staged-then-swapped so a failed refresh never destroys a working cache) → yarn install && yarn build fallback for developers. Zip-slip and symlink-member guarded extraction; honors SSL_CERT_FILE, falls back to certifi.
  • .github/workflows/docker-build-release.yml: new build-frontend-dist job attaches the built SPA + its .sha256 to every release (bundle is environment-agnostic — the launcher rewrites env-config.js at startup).
  • annotations/0074 migration: probes pg_available_extensions via RunPython; skips extension + GIN index when pg_trgm is absent (the queries it accelerates are plain icontains — correct unindexed, fine at single-user scale). Real Postgres deployments get the identical objects. Reverse now drops only the index, removing the documented DROP EXTENSION reversal hazard. Note on the in-place edit (considered deliberately): Django tracks applied migrations by (app, name), never content, so no already-migrated database re-runs it; 0074 only landed on main days ago via Authority packs: baseline-origin collision guard + multi-YAML taxonomy merge (#2057) #2133, and the new body is behavior-identical wherever pg_trgm exists — a superseding migration would not have fixed fresh desktop installs, which crash inside 0074 itself.
  • desktop_bootstrap: password from OC_DESKTOP_PASSWORD or the interactive first-run prompt (min-length floor enforced on both paths, confirmed twice, EOF/Ctrl+C-safe); a password-less account left over from a headless run self-heals on the next launch; never re-prompts once a usable password exists. The password is scrubbed from every long-lived process's environment (wrapper, launcher, children) after the single hand-off.
  • Launcher: stable default port 8406 (ephemeral fallback); startup banner with URL, the desktop username, and Ctrl+C stop instructions; crash-aware health check (no success banner over a dead Daphne — points at the per-child logs, which rotate at 10 MB); clean-teardown farewell stating where data lives; user-private (0o700) app-data tree enforced even for pre-existing dirs.
  • Discoverability: README "Desktop — run it on your computer (no Docker)" Quick Start section with the app URL; docs/quick_start.md cross-link; docs/deployment/desktop_packaging.md rewritten around the one-command flow; page added to the mkdocs nav.

Merge with main

origin/main merged in. The one conflict was add/add on warp_ingest_parser.py: main's version is the REST client to the Warp-Ingest microservice; this branch's is the in-process library parser the desktop build needs. Resolution keeps main's REST client at the original path and moves the in-process variant to warp_ingest_local_parser.py / WarpIngestLocalParser ("Warp-Ingest Parser (Local)", WARP_INGEST_LOCAL_* env vars, same max_file_size_mb guard and mutual-exclusion error convention as the REST sibling) so both components coexist unambiguously.

Tests

  • opencontractserver/tests/test_desktop_packaging.py (64 tests): path resolution per-OS, private-dir permission enforcement (incl. pre-existing permissive trees), SPA resolution order/zip-slip/symlink/checksum edge cases, trigram migration guard against a real Postgres, password prompt/self-heal/no-reprompt/floor/cross-process-scrub flows, launcher teardown and keyring-fallback behavior.
  • Full desktop + local-parser test files pass against a real embedded Postgres with the complete migration run.
  • Live end-to-end (three runs this branch): fresh app-data → password prompt within seconds → unattended venv+deps+Postgres+migrate (0074 skips cleanly without pg_trgm) → healthy app on :8406 → GraphQL login with the prompted password → clean Ctrl+C teardown → ~20 s warm relaunch.

🤖 Generated with Claude Code

https://claude.ai/code/session_01E4ahQjPm1McibG65spkxLR

claude added 13 commits July 5, 2026 21:02
…Redis process launcher

Collapse the local.yml compose topology toward a single-user desktop app that
runs as plain processes with no Docker and no Redis:

- config/settings/desktop.py: no-Redis profile (LocMem cache, in-process
  channels, filesystem Celery broker + SQLAlchemy DB result backend, local
  per-user storage, Auth0 off) that serves the built SPA from Daphne and
  selects in-process pipeline components.
- opencontractserver/desktop/{paths,launcher}.py + oc-desktop.py: one-command
  supervisor - embedded PostgreSQL+pgvector (pgserver) or external DATABASE_URL,
  migrate, first-run bootstrap, Daphne + Celery worker/beat, clean shutdown.
- opencontractserver/pipeline/parsers/warp_ingest_parser.py: in-process,
  rule-based PDF parser (Warp-Ingest) replacing the Docling microservice. Lazy
  optional import; emits PAWLS tokens + structural annotations + OC_PARENT_CHILD
  hierarchy directly as an OpenContractDocExport.
- desktop_bootstrap management command: local superuser, PipelineSettings
  seeding, nltk corpora.
- config/spa.py + gated config/urls.py catch-all: serve the SPA from Daphne.
- requirements/desktop.txt, docs/deployment/desktop_packaging.md, changelog.

Known Phase-0 limitation (documented): notification WebSocket delivery needs a
loop-safe transport under the in-process channel layer; agent chat is
unaffected (streams inline). See the docs for the phased plan.
Address CI + review feedback on the Phase 0 desktop packaging PR:

Security (CodeQL, 2 high alerts):
- Stop persisting the SECRET_KEY and the local login password to plaintext
  files. Secrets are now env-sourced only: the launcher generates one shared
  DJANGO_SECRET_KEY and exports it to every child process; without it an
  ephemeral in-process key is used (with a warning). The local user password
  comes only from OC_DESKTOP_PASSWORD; when unset the user is created with an
  unusable password and the operator sets one.

Typing (mypy):
- Replace the _shutdown()-in-lambda signal handlers with a named function.
- Type the filesystem-broker transport options dict override.
- Test fixes: setattr for injected module attrs, assert-narrow Optional
  returns before indexing, type: ignore for the sys.modules None sentinel.

Coverage (codecov patch):
- Add opencontractserver/tests/test_desktop_packaging.py covering the
  per-OS path helpers and the SPA catch-all traversal guard.
- Omit the desktop launcher (a process-supervision entry point, like
  manage.py/wsgi.py) from coverage.

Review nits:
- Drop the unused REACT_APP_WS_ROOT_URL key from env-config generation.
- Remove the unused WHITENOISE_ROOT/INDEX_FILE settings (the SPA catch-all
  serves assets + index.html).
- Fix the misleading CELERY_BEAT_SCHEDULER comment (beat runs as a subprocess;
  APScheduler is a Phase-1 follow-up).
- Surface migrate_pipeline_settings failures loudly instead of swallowing.
- Note the _free_port() TOCTOU window.
- Fix /media/ 404s in the desktop build: set SERVE_MEDIA_WITHOUT_DEBUG=True so
  Django serves local media (uploaded PDFs, thumbnails) with DEBUG=False and no
  nginx/S3 in front. Without it every FieldFile.url would 404.
- Correct the config/spa.py docstring: spa_fallback serves the SPA's assets +
  index.html itself; WhiteNoise only serves Django's STATIC_ROOT, not the dist/.
- Drop the redundant set_unusable_password() in desktop_bootstrap (Django's
  create_superuser(password=None) already stores an unusable password).
- Tighten the SPA catch-all regex to whole-segment exclusions so a future route
  like /mcp-guide still falls through to index.html.
- Log a warning when collectstatic fails instead of silently ignoring it.
- Doc note: an external DATABASE_URL must already have the pgvector extension
  (the embedded pgserver path creates it automatically).
The command no longer persists a generated password to disk; correct the
module docstring accordingly and consolidate a doubled comment block in
_seed_pipeline_settings left over from an earlier edit.
…ettings cache

Addresses two real bugs found in review:

- SECRET_KEY was regenerated every launch, so PipelineSettings' encrypted
  secrets (e.g. OPENAI_API_KEY) — derived from SECRET_KEY via Fernet/PBKDF2 —
  became permanently undecryptable on the next restart, silently breaking the
  Tier-1 'set your API key once' flow. The launcher now resolves a STABLE key
  from the OS keyring (Keychain/Credential Locker/Secret Service), with an
  ephemeral fallback + warning when no backend is available. No plaintext file
  (keeps the CodeQL secret-storage fix intact). Adds 'keyring' to desktop reqs.
- PipelineSettings.get_instance() cached the singleton in process-local
  LocMemCache; with Daphne/worker/beat as separate processes, a settings change
  in Daphne never invalidated the worker's copy (stale parser/embedder/key for
  up to the TTL). Set PIPELINE_SETTINGS_CACHE_TTL_SECONDS=0 on desktop so reads
  are always fresh (cheap for a single user).

Review nits: drop dead CELERY_TASK_EAGER_PROPAGATES; cap early-stage desktop
deps (warp-ingest/pgserver/sqlalchemy) with upper bounds; cross-reference the
deliberate CELERY_BROKER_TRANSPORT_OPTIONS reassignment in base.py; correct the
config/urls.py SERVE_MEDIA_WITHOUT_DEBUG comment; add desktop_bootstrap unit
tests (password set/unset/idempotent, pipeline seeding + failure path).
Return (command, stdout, stderr) StringIO streams from the helper rather than
assigning self.out/self.err in a helper method, which keeps the test mypy-clean
under django-stubs (no instance attribute defined outside __init__).
Fixes from review:

- requirements/desktop.txt was missing a Postgres driver (base.txt has none;
  it lives in local/production.txt), so 'manage.py migrate' would fail on a
  clean desktop install. Add psycopg2-binary==2.9.12.
- config.settings.desktop reassigned USE_AUTH0/STORAGE_BACKEND AFTER
  'from .base import *', but base.py branches on both at its own import time
  (requiring AUTH0_*/GS_BUCKET_NAME with no defaults). A stray USE_AUTH0=true /
  STORAGE_BACKEND=GCP inherited from the process env would crash the import
  first. Force both to desktop-safe values via os.environ BEFORE the base
  import (hard set, since setdefault would leave an explicit hostile value in
  place). Drop the now-redundant later reassignments. Add a subprocess test
  that imports the profile under a hostile env.

Cleanups:
- Extract the Django->SQLAlchemy result-backend URL mapping to
  opencontractserver/desktop/db.py (pure, unit-tested: postgres scheme, query
  string preserved, non-postgres passthrough).
- WarpIngestParser now marks ConnectionError/TimeoutError transient (retryable,
  e.g. a first-run OCR model download) instead of permanent; add a test.
- launcher: single teardown path (atexit only; signal handlers just sys.exit)
  and read DATABASE_URL from the threaded env for consistency.
- Note in the SPA catch-all that mcp/sse/ws exclusions are defensive-only
  (those routes are handled at the ASGI/protocol layer, never reaching http
  urlpatterns).
- WarpIngestParser: requests/httpx network errors do not subclass the builtin
  ConnectionError/TimeoutError, so classify transient by matching the exception
  class name across its MRO (no optional-lib import) in addition to the
  builtins — a flaky first-run OCR model fetch is now retryable, not permanent.
  Add a test for the httpx-style (non-builtin) case.
- launcher: warn on the console when the server does not answer within 60s
  before opening the browser, instead of discarding _wait_for_http's result.
- desktop settings: drop "0.0.0.0" from ALLOWED_HOSTS (a bind address, never an
  incoming Host header; Daphne binds 127.0.0.1).
…a dir

Both from the review's non-blocking observations:
- WarpIngestParser streams the stored PDF to the temp file via
  shutil.copyfileobj instead of read()-into-memory, so a very large PDF no
  longer briefly doubles memory usage.
- launcher scopes the OS-keyring SECRET_KEY entry by a hash of OC_DESKTOP_DATA_DIR
  so two desktop instances under different data dirs (e.g. a test profile beside
  a real one) each keep their own persisted key instead of sharing one.
Review follow-ups (none blocking):
- launcher: redirect each child's (Daphne/worker/beat) stdout+stderr to a
  per-child file under logs_dir() so crashes leave a durable traceback — needed
  once the Phase-2 Tauri shell runs with no attached console. logs_dir() was
  created but unused before. Close the handles on shutdown.
- launcher: reserve the free port immediately before Daphne binds it (after the
  slow worker/beat spawn) to keep the release-then-rebind TOCTOU window
  near-instant; update the _free_port docstring accordingly.
- Fix WhiteNoise/SPA doc drift left over from removing WHITENOISE_ROOT: the
  spa_fallback view serves the SPA assets itself (WhiteNoise covers only
  STATIC_ROOT). Corrected in config/spa.py, config/urls.py and the deployment
  doc's compose-replacement table.
- Move the placeholder desktop DATABASE_URL into a named constant
  (db.DEFAULT_DESKTOP_DATABASE_URL) per the no-magic-values convention.
- Docs: note OC_DESKTOP_PASSWORD is a superuser password and is not
  strength-validated on the desktop profile.
_spawn opened the per-child log file before subprocess.Popen; if Popen raised
(e.g. a bad executable) the fd leaked. Spawn inside try/except, close the handle
on failure and re-raise, and only track the handle/child after a successful
spawn. Flagged by github-code-quality static analysis.
…heck fix

Reliability fixes from review:
- launcher._stable_secret_key runs the OS-keyring call in a daemon thread with a
  10s timeout: a locked GNOME Keyring/KWallet can block on an interactive unlock
  prompt instead of raising, which would hang the launcher. On timeout or error,
  fall back to an ephemeral key with a clear warning.
- First-run bootstrap is now retry-safe: desktop_bootstrap exits non-zero
  (CommandError) if pipeline seeding failed, and the launcher writes the
  .bootstrapped marker ONLY on a clean exit — so a transient first-run failure
  is retried on the next launch (every step is idempotent) instead of
  permanently disabling Tier-1 embeddings/chat. Add a handle()-raises test.
- Health-check /api/health/ instead of / before opening the browser: when the
  SPA isn't built, / redirects to the absent :3000 dev server and every poll
  would error, falsely reporting the server down and burning the full 60s.
- _resolve_spa_dir reads OC_DESKTOP_FRONTEND_DIR from the threaded env for
  consistency with the rest of the launcher.
- Docs: note Windows Popen.terminate() is a hard kill (no graceful drain);
  reconcile_stuck_documents recovers stranded docs — graceful Windows teardown
  is a Phase-1 item.
Groundwork for reducing desktop setup to 'python oc-desktop.py':
- desktop/bootstrap.py: stdlib-only Python-version gate (3.10-3.12; pgserver
  ships no 3.13 wheels), private venv under app-data, automatic
  requirements install with content-fingerprint refresh, re-exec in venv.
- desktop/spa_dist.py: acquire the built SPA without a Node toolchain -
  repo dist -> cached download -> GitHub release asset (version-matched
  tag, then latest) -> yarn fallback; zip-slip-guarded extraction.
- docker-build-release.yml: build-frontend-dist job attaches
  opencontracts-frontend-dist.zip to every release.
- tests: BootstrapTests + SpaDistTests in test_desktop_packaging.py.

Entry-point/launcher wiring lands in the follow-up commit (kept separate
while a clean-room usability test of the current flow completes).
os.environ["USE_AUTH0"] = "False"
os.environ["STORAGE_BACKEND"] = "LOCAL"

from .base import * # noqa: E402,F401,F403
Comment thread opencontractserver/desktop/spa_dist.py Fixed
claude added 7 commits July 7, 2026 20:58
…n password prompt (part 2)

Clean-room usability test findings (agent role-playing a non-technical
user) and fixes:

- BLOCKER: every fresh desktop install died at migration
  annotations/0074 because the embedded pgserver Postgres bundles no
  pg_trgm. The migration now probes pg_available_extensions via
  RunPython and skips the extension + GIN index when absent (runtime
  queries are plain icontains and work unindexed); real Postgres
  deployments get the identical extension + index. Reverse now drops
  only the index, removing the documented DROP EXTENSION hazard.
- oc-desktop.py routes through desktop/bootstrap.py: python-version
  gate, private venv under app-data, automatic dependency install,
  re-exec. One command, no pip/venv/Node knowledge needed.
- desktop_bootstrap: password now comes from OC_DESKTOP_PASSWORD or an
  interactive first-run prompt (min 8 chars, confirmed); a
  password-less account left over from a headless run self-heals on
  the next launch.
- launcher: SPA auto-acquisition via spa_dist (release bundle download,
  yarn fallback); stable default port 8406 with ephemeral fallback;
  startup banner with URL, the previously-undocumented 'desktop'
  username, and Ctrl+C stop instructions.
- Discoverability: README 'Desktop (no Docker)' Quick Start section,
  docs/quick_start.md cross-link, desktop doc rewritten around the
  one-command flow and added to the mkdocs nav.
- Tests: 39 pass against a real embedded Postgres (full migration run),
  incl. new TrigramMigrationGuardTests and prompt/self-heal coverage.
- Drop unused 'import sys' in spa_dist (review bot).
…e download

An explicit CA override (corporate proxy, custom bundle) must win; certifi
stays as the fallback for interpreters with no usable system roots.
…p-simplify-plgwgj

# Conflicts:
#	opencontractserver/pipeline/parsers/warp_ingest_parser.py
These paths (venv creation, pip install, re-exec, release download, yarn
build) are exercised end-to-end by the live desktop launch, not unit
tests — same rationale as the existing launcher.py coverage omit.
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review

Went through the full diff (settings profile, launcher, bootstrap, SPA acquisition, migration change, and the new parser/tests). This is a large but well-scoped PR, and it's clear a lot of iteration already went into hardening it (secret handling, zip-slip guard, path-traversal guard, hostile-env import test, retry-safe bootstrap). Nice test coverage overall (test_desktop_packaging.py, test_warp_ingest_parser.py, TrigramMigrationGuardTests).

Correctness

Cached SPA bundle is never version-checked, so a backend upgrade can silently keep serving a stale frontend.

opencontractserver/desktop/spa_dist.py::ensure_spa:

def ensure_spa(repo_root: Path, version: str) -> Path | None:
    repo_dist = repo_root / "frontend" / "dist"
    if (repo_dist / "index.html").is_file():
        return repo_dist

    cached = _dist_dir_within(paths.subdir("spa"))
    if cached:
        return cached          # <-- `version` is never consulted here

    return download_spa(version) or build_spa_with_yarn(repo_root)

download_spa() extracts into the same app-data/spa directory on every fresh download, but nothing stamps that directory with the version it was fetched for. So: a user runs oc-desktop.py once, it downloads and caches the v3.0.0 frontend bundle under app-data/spa/. Later they git pull to a newer backend (new GraphQL schema, new frontend features) and relaunch — ensure_spa finds the old cached dist/ and returns it immediately, without ever calling download_spa(new_version). The module's own docstring says resolution is "download the release asset (the tag matching this checkout's version, then the latest release)", but that version-matching only applies on a cache miss; once anything is cached, the version parameter is effectively dead for the rest of that install's lifetime. There's also no log line telling the user a cached copy is being reused, so this fails silently — the only recovery path is knowing to delete the app-data spa/ (or the whole app-data) directory.

Given the backend/frontend GraphQL contract can change between releases, this could produce confusing runtime errors (missing fields, broken mutations) after what looks like a normal upgrade. Suggest stamping the extracted dist with the version it was built for (e.g. a small .version file written next to dist/) and having ensure_spa/_dist_dir_within compare it against the requested version before accepting the cache, falling back to download_spa on mismatch.

Minor / non-blocking

  • opencontractserver/annotations/migrations/0074_annotation_raw_text_trigram_index.py is edited in place rather than via a new migration. Normally editing an already-applied migration is risky (state/DB history divergence for anyone who already migrated with the old content), but since 0074 only just landed on main via Authority packs: baseline-origin collision guard + multi-YAML taxonomy merge (#2057) #2133 immediately before this PR, the blast radius looks negligible here — flagging only in case any environment already ran migrate against the pre-PR version of this file.
  • WarpIngestLocalParser.Settings documents apply_ocr/disable_ocr as "mutually exclusive" but nothing raises/warns if both are set True at once — presumably deferred to the underlying warp-ingest library; worth a one-line guard or at least a code comment on which one wins if both are set.
  • _is_transient_error's MRO-name matching (warp_ingest_local_parser.py) duplicates the spirit of the transient/permanent classification already done by hand in the sibling warp_ingest_parser.py (REST client). Not proposing you unify them — the REST client works against known requests exception types while this one has to handle an opaque third-party call stack — but a short cross-reference comment between the two would help future readers understand why the same concept is implemented two different ways.

What looks solid

  • config/spa.py::spa_fallback + safe_join traversal guard, and spa_dist.safe_extract_zip's is_relative_to check — both verified to correctly reject absolute-path zip members too (pathlib's / operator drops the left operand for an absolute right operand, but the post-join is_relative_to(dest) check still catches it).
  • Secret handling: SECRET_KEY sourced from the OS keyring with a bounded timeout (avoids hanging on a locked keyring) and an ephemeral fallback with a loud warning; no secrets written to plaintext files.
  • config/settings/desktop.py forcing USE_AUTH0/STORAGE_BACKEND/DATABASE_URL via os.environ before importing base (with a clear comment on why setdefault wouldn't be enough) — backed by DesktopSettingsImportTests.test_imports_under_hostile_env, a good regression guard for exactly this class of bug.
  • First-run bootstrap is retry-safe (marker only written on clean exit) and the password self-heal path is covered by tests.
  • Good defensive detail in the SPA catch-all regex (whole-segment exclusions so a future /mcp-guide route still falls through to index.html).

@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review

Reviewed the full diff (config/settings/desktop.py, config/spa.py/urls.py, opencontractserver/desktop/{bootstrap,spa_dist,launcher,paths,db}.py, desktop_bootstrap.py, warp_ingest_local_parser.py, migration 0074, the new release workflow job, and the test suite). This is a large but very well-scoped PR, and it's clear several rounds of self-review (CodeQL, mypy, coverage, usability testing) already happened before this reached the PR stage — the commit history shows real bugs found and fixed along the way (SECRET_KEY persistence, pg_trgm availability, import-time env crash, fd leak on spawn failure, etc.).

Strengths

  • Security posture is good. No secrets ever hit disk in plaintext (SECRET_KEY → OS keyring with a hard 10s timeout guard against a locked keyring hanging the launcher; login password → hash only, never generated/stored/printed). safe_extract_zip in spa_dist.py correctly guards against zip-slip via Path.resolve() + is_relative_to(), and there's a direct test for it (test_safe_extract_blocks_traversal). The SPA catch-all regex in config/urls.py uses a whole-segment negative lookahead ((?!(?:api|graphql|...)(?:/|$))), which correctly avoids the classic prefix-exclusion bug (e.g. a hypothetical /apiary route wouldn't be wrongly excluded).
  • The pg_trgm migration fix (0074) is a legitimate, well-tested bug fix — probing pg_available_extensions before creating the extension is the right approach for the embedded pgserver build, and the reverse operation no longer risks the documented DROP EXTENSION hazard. Good test coverage in TrigramMigrationGuardTests.
  • Test coverage is thorough for all the pure/testable logic: path resolution per-OS, the SPA traversal guard, password prompt/self-heal flows, the SQLAlchemy URL mapping, the settings hostile-env import test (subprocess-based, appropriately so since it's testing import-time behavior), bootstrap version-window/venv-path/fingerprint logic, and the zip-slip guard. The coverage omit additions for launcher.py/network-bound functions in bootstrap.py/spa_dist.py are consistent with the existing precedent for manage.py/wsgi.py-style entry points, and are called out explicitly in the changelog rather than silently lowering the bar.
  • WarpIngestLocalParser correctly streams the stored PDF via shutil.copyfileobj instead of buffering the whole file in memory, and classifies transient vs. permanent errors by walking the exception MRO (handles requests/httpx errors without importing them) — a nice touch since Warp-Ingest is otherwise deterministic/local so most failures should be permanent.

Minor / non-blocking observations

  1. Migration 0074 is being edited in place rather than superseded by a new migration. This is an already-existing (presumably previously-merged) migration file, not a new one. Editing an applied migration's code is generally something to be cautious about — any environment that already ran the old version of 0074 won't re-run it (Django tracks by name, not content hash), so this only affects fresh installs/CI/desktop. That's almost certainly fine here since the change is backward-compatible for real Postgres (same net effect: extension + index), but worth a sentence confirming no long-lived environment is stuck on the old behavior in a way that matters, or whether a fresh migration would have been safer practice going forward.

  2. spa_dist.safe_extract_zip doesn't explicitly guard against symlink zip members (only against ../-style path traversal in entry names). Since Python's zipfile.extractall doesn't create real symlinks from zip entries by default, this is low-risk in practice, especially since the archive's provenance is the project's own CI-built release asset (not attacker-controlled input) — but if this pattern gets reused later for user-supplied uploads, that assumption wouldn't hold.

  3. No integrity check on the downloaded SPA asset (download_spa in spa_dist.py) beyond TLS — no checksum/signature verification against the GitHub release metadata. Given the asset comes from the project's own release pipeline over HTTPS, this is consistent with the trust model of pip install from PyPI, so it's not a new class of risk, just worth noting if this pattern expands.

  4. bootstrap.py's dependency-install failure message is slightly imprecise: _install_dependencies can fail either at python -m venv creation or at pip install, but the caught subprocess.CalledProcessError in main() always prints "Automatic dependency install failed (see the pip output above)" — a venv creation failure (e.g. missing python3-venv on some Debian/Ubuntu minimal installs) would show pip-flavored guidance for a non-pip problem. Minor cosmetic issue, not a functional bug.

  5. config/settings/desktop.py's ephemeral-SECRET_KEY fallback uses warnings.warn(...) rather than logging/print. Since this is a security-relevant signal (sessions/secrets won't survive restart), and Python warnings can be filtered/suppressed depending on invocation context, consider whether a print/logger.warning (as used elsewhere in this PR, e.g. launcher._stable_secret_key) would be more reliably visible to an end user running python oc-desktop.py directly. Low severity since the launcher normally pre-empts this path by exporting DJANGO_SECRET_KEY before the settings module loads.

Nothing above is blocking. The overall design (stdlib-only bootstrap before deps exist, private venv, defensive zip extraction, keyring-backed secret persistence, whole-segment route exclusion, idempotent/self-healing first-run bootstrap) is solid and the test suite backs up the claims made in the changelog fragments.

@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review: One-step desktop setup (python oc-desktop.py)

Overall this is a well-scoped, carefully documented PR. The stdlib-only bootstrap layer, the deliberate-exception comments in config/settings/desktop.py, the pg_trgm migration fix, and the zip-slip guard in spa_dist.py all show good judgement about the sharp edges of this kind of packaging work. Test coverage for the pure-logic pieces (paths.py, spa_dist.py, bootstrap.py, the migration guard, desktop_bootstrap's password/self-heal logic) is solid. Comments below are mostly nits/confirmations rather than blockers.

Correctness / potential issues

  • PR description vs. diff mismatch: The PR body lists "interactive first-run password prompt," "SPA auto-acquisition wiring," "startup banner," and "oc-desktop.py routed through the bootstrap module" under "Coming in follow-up commits on this PR" — but the diff already contains all of these (launcher.py's banner + _resolve_spa_dir, desktop_bootstrap.py's _resolve_password, oc-desktop.py calling bootstrap.main()). Worth updating the PR description before merge so reviewers/future readers aren't confused about what's actually shipped in this diff vs. pending.

  • docker-build-release.ymlbuild-frontend-dist job: it references ${{ github.event.release.tag_name }} unconditionally. Since the diff only shows the jobs: section, I couldn't confirm from the diff alone whether this workflow's on: trigger is scoped strictly to release: types: [published] (in which case this is fine) or also fires on something like workflow_dispatch/push (in which case github.event.release would be null and the gh release upload step would fail with an unbound reference). Worth a quick double-check if the workflow has any non-release triggers.

  • opencontractserver/desktop/bootstrap.py — concurrent first launches: if a user double-launches oc-desktop.py (e.g., double-clicking twice), there's no lock around venv creation / pip install, so two concurrent python -m venv + pip install runs against the same venv_path could race and produce a corrupted environment. Probably fine to defer to Phase 1, but worth a one-line note in the docs ("don't launch twice while the first run is installing") since the failure mode (partially-installed venv) is confusing for a non-technical user.

  • launcher.py::_free_port: the documented TOCTOU window (release the probe socket, then rebind in Daphne) is explicitly acknowledged as an accepted Phase-0 risk — reasonable given it's loopback-only and single-user, no action needed.

Security

  • Zip extraction (spa_dist.py::safe_extract_zip): correctly guards against traversal via Path.is_relative_to, including the pathlib gotcha where dest / member silently resets to an absolute path if member itself is absolute (e.g. /etc/passwd) — the final is_relative_to check still catches that case since the resolved absolute path won't be relative to dest. Good.
  • config/spa.py: uses django.utils._os.safe_join (a private/underscore-prefixed API) for the traversal guard. This mirrors what Django's own django.views.static.serve does internally, so it's a reasonably safe precedent, but flagging that it's not a public API in case a future Django major version relocates/removes it.
  • SECRET_KEY handling: the OS-keyring-backed stable key with a 10s timeout + ephemeral fallback (launcher.py::_stable_secret_key) is a nice touch — avoids hanging the launcher on a locked Linux keyring while still explaining the tradeoff (sessions/PipelineSettings secrets won't survive restart) loudly.
  • Password prompt (desktop_bootstrap.py): never printed/logged/written to disk, getpass-based, EOFError handled gracefully with self-heal on next run. Good.
  • No shell=True anywhere in the new subprocess calls — good, avoids injection risk from path/argument content.

Test coverage

  • Good coverage of pure logic: per-OS path resolution, requirements fingerprinting, python-version gate, SPA resolution order/zip-slip, migration guard behavior (pg_trgm available/unavailable), and the desktop_bootstrap password/self-heal state machine (including the "must not re-prompt when password already usable" case, which is a nice regression guard).
  • launcher.py is explicitly excluded from coverage (setup.cfg) with a rationale comment consistent with how manage.py/asgi.py/wsgi.py are treated elsewhere in the repo. Reasonable for Phase 0, but since this file owns process supervision, signal handling, and shutdown ordering (the riskiest part to get wrong on Windows per the PR's own note about Popen.terminate()), a lightweight smoke test (e.g., spawn main() against fake child commands and assert shutdown ordering) would be valuable as a near-term follow-up rather than leaving it entirely untested indefinitely.
  • No CI job actually exercises python oc-desktop.py end-to-end (understandably hard to do in CI without a display/browser, but even a headless "does it reach /api/health/ and exit cleanly on SIGTERM" smoke test would catch regressions in the supervision loop).

Style / conventions

  • Follows CLAUDE.md guidance closely: no magic numbers (constants like DEFAULT_PORT, MIN_PASSWORD_LENGTH, _KEYRING_TIMEOUT_SECONDS are named), the CELERY_BROKER_TRANSPORT_OPTIONS reassignment in desktop.py is explicitly called out as a deliberate exception to the "merge, don't reassign" rule established in base.py's own comment — exactly the kind of documentation the project asks for.
  • Docstrings throughout explain why, not just what (e.g., the keyring timeout rationale, and the RunPython vs TrigramExtension reversal hazard, are both well explained).
  • Changelog fragments (changelog.d/*.md) are present and follow the required format/categories.

Minor nits

  • opencontractserver/desktop/bootstrap.py::_install_dependencies runs plain pip install -r requirements/desktop.txt (no --upgrade). Given the range-pinned deps (pgserver>=0.1.4,<0.3, warp-ingest[ocr]>=1.0.0,<2), this is probably intentional (avoid drive-by upgrades), but worth a one-line comment confirming that's deliberate so a future contributor doesn't "fix" it by adding --upgrade and accidentally defeat the fingerprint-gated reinstall logic.
  • desktop_bootstrap.py's MIN_PASSWORD_LENGTH = 8 with no complexity requirement is explicitly justified ("single-user, local only... this account is a superuser, choose a strong password") — reasonable given the threat model (loopback-only, local desktop), just flagging since it is a superuser account.

Nice work overall — the phased approach (Phase 0 processes-as-subprocesses before any native-shell/installer investment) and the extensive inline rationale make this easy to review and safe to build on.

- spa_dist: stamp downloaded bundles with the checkout version and
  refresh on mismatch, so a backend upgrade can't silently keep serving
  a stale frontend; stale cache is still used (with a warning) when the
  refresh fails offline. Tests for match/refresh/offline-fallback.
- bootstrap: venv-creation failure now gets its own actionable message
  (Debian/Ubuntu python3-venv hint) instead of pip-flavored guidance;
  comment documenting the deliberate absence of pip --upgrade.
- warp_ingest_local_parser: warn when apply_ocr and disable_ocr are both
  set; cross-reference comment explaining why transient-error detection
  differs from the REST sibling.
- docs: note not to double-launch during first-run install (no
  cross-process lock yet, Phase-1 item).
- test_warp_ingest_parser: smoke test also requires punkt/punkt_tab
  corpora before running.
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review

Reviewed the full diff (gh pr diff 2137, 24 files). This is Phase 0 of single-user desktop packaging plus a "one-step setup" layer on top (bootstrap.py self-installing venv, spa_dist.py SPA acquisition, a new release CI job). Overall this is a well-engineered, thoroughly-commented PR — nice work on the security-conscious defaults (loopback-only ALLOWED_HOSTS, Daphne bound to 127.0.0.1 only, zip-slip guard in spa_dist.safe_extract_zip/tested in SpaFallbackTests/SpaDistTests, no plaintext secret storage, stable SECRET_KEY via OS keyring with a bounded timeout) and the self-healing bootstrap flow (idempotent desktop_bootstrap, fingerprinted venv reinstall, unwritten first-run marker on partial failure).

Correctness / risk items

  1. Editing an already-shipped migration (opencontractserver/annotations/migrations/0074_annotation_raw_text_trigram_index.py) rather than adding a new one. This file already exists on main (it's a modification, not a new file) and presumably has already run in some deployed databases via the old unconditional TrigramExtension() + RunSQL. Since Django tracks applied migrations by name only (not content hash), previously-migrated databases are unaffected, and the new guarded RunPython only changes behavior for not-yet-migrated (including all desktop) installs — so this is probably safe — but rewriting a merged migration's body is unusual enough that it's worth a second pair of eyes / explicit sign-off, since the changelog fragment (changelog.d/desktop-pg-trgm.fixed.md) is the only place this tradeoff is justified.

  2. No integrity check on the downloaded SPA bundle (opencontractserver/desktop/spa_dist.py::download_spa). The zip is fetched over HTTPS (good) and extraction is guarded against path traversal (good, and tested), but there's no checksum/signature verification before zipfile.extractall. For a first-run, trust-on-first-install flow pulling arbitrary content from a GitHub release into a fresh venv's runtime, consider at least verifying a published SHA256 (could be uploaded alongside the zip in the same release job) to guard against a compromised/re-uploaded release asset.

  3. Unauthenticated GitHub API calls in spa_dist._release_asset_url (api.github.com/repos/.../releases/tags/... and /latest) are subject to the anonymous rate limit (60 req/hr per source IP). Not a bug, but users behind a shared/corporate NAT could intermittently get "no bundle found" and silently fall through to the yarn build path (which then fails for non-developers) — might be worth a friendlier error message distinguishing "rate limited" from "no release exists yet."

  4. _write_env_config (launcher.py) mutates whatever spa_dir resolves to. When a developer already has a repo-local frontend/dist built, oc-desktop.py will silently overwrite frontend/dist/env-config.js inside the checkout on every launch. Probably harmless (build output, likely gitignored) but worth an explicit comment since it's a surprising side effect for a "launcher" to write into the source tree.

  5. sqlalchemy_result_backend_url (desktop/db.py) only special-cases the bare postgres/postgresql/postgis schemes; a user-supplied external DATABASE_URL using a driver-qualified scheme (e.g. postgresql+psycopg2://) would fall into the passthrough branch and produce a malformed db+postgresql+psycopg2:// result-backend URL. Minor/edge-case since the documented external-DB path is a plain Postgres URL, but a guard or doc note would help.

  6. Please double check the build-frontend-dist job's trigger assumption. The new job (docker-build-release.yml) reads github.event.release.tag_name, which is only populated when the workflow trigger includes release: types: [published]. The diff hunk starts at line 10 so the on: block isn't visible in the diff — worth confirming this workflow isn't also fired by push/workflow_dispatch, where that context field would be empty and gh release upload "" would fail the job.

Minor / style

  • opencontractserver/desktop/bootstrap.py::_reexec_in_venv swallows KeyboardInterrupt in a while True wait-loop so a hung child can't be force-stopped by repeated Ctrl+C from the wrapper process. This is an acceptable single-user tradeoff (and partially documented), but a second Ctrl+C escape hatch (or a comment pointing at "close the terminal") would help a non-technical user who doesn't know to wait.
  • setup.cfg excludes desktop/launcher.py from coverage entirely (reasonable — it's orchestration/IO like manage.py), but a few of its non-trivial pure helpers (_free_port, _keyring_username's hashing, the keyring-timeout fallback logic in _stable_secret_key) currently have zero test coverage. Consider splitting the genuinely pure bits into paths.py/db.py-style testable modules the way sqlalchemy_result_backend_url already was, rather than excluding the whole file.
  • Nice touch: config/settings/base.py gets an explicit comment documenting the desktop profile's deliberate exception to the "always merge, never reassign CELERY_BROKER_TRANSPORT_OPTIONS" rule — that kind of forward documentation is exactly what avoids future confusion.

Test coverage

Test coverage for the pure/testable surfaces (paths.py, spa_dist.py's tag-matching/zip-safety, db.py's URL mapping, desktop_bootstrap's password/self-heal state machine, the 0074 migration guard, WarpIngestLocalParser's transient-vs-permanent error classification) is thorough and well-targeted — including the httpx/requests-style exception-name matching test for transient network errors, which is an easy thing to get wrong and is nice to see explicitly verified. The main gap is the launcher orchestration itself (items above), which is inherently harder to unit test and is a reasonable thing to defer given it's flagged in the PR description as still evolving in follow-up commits.

Summary

No blocking issues found. The main things I'd want resolved or at least explicitly acknowledged before merge are (1) confirming the migration-rewrite is intentional and safe for any environment that already ran 0074, and (6) confirming the release workflow trigger actually populates github.event.release.tag_name for this new job. Everything else is a suggestion, not a blocker.

@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review: One-step desktop setup (python oc-desktop.py)

Overall this is a well-scoped, carefully documented piece of work. The commit history shows real iteration against CodeQL/mypy/codecov feedback and two rounds of clean-room usability testing, and it shows in the code: security-sensitive decisions (secret storage, zip extraction, SSL context, env-var ordering) are each explained in a comment at the point of risk rather than left implicit. Below are the things worth double-checking before merge; nothing here looks blocking.

What it does

  • opencontractserver/desktop/bootstrap.py: stdlib-only Python-version gate (3.10–3.12), private venv creation, dependency install with a content-fingerprint refresh, re-exec of the launcher inside the venv.
  • opencontractserver/desktop/spa_dist.py: acquires the built SPA without Node — repo frontend/dist → app-data cache → GitHub release asset → yarn fallback — with zip-slip guarding and version-stamped cache invalidation.
  • annotations/0074 migration: probes pg_available_extensions and skips pg_trgm + its GIN index when unavailable (the embedded pgserver build has no contrib extensions), fixing what the PR describes as a hard blocker on every fresh desktop install.
  • config/settings/desktop.py, config/spa.py, config/urls.py: a Redis-free, Auth0-off, locally-stored settings profile plus a safe_join-guarded SPA catch-all view.
  • desktop_bootstrap management command: local superuser + password prompt (env var or interactive, self-healing for a password-less leftover account), pipeline settings seeding, nltk corpora.
  • warp_ingest_local_parser.py: in-process PDF parser, split out from the pre-existing REST warp_ingest_parser.py after the merge conflict.

Correctness / robustness

  • spa_dist.safe_extract_zip (spa_dist.py:106) validates every member's resolved path with is_relative_to(dest) before calling extractall — correct zip-slip guard, and it validates all members up front rather than bailing on the first bad one.
  • bootstrap._reexec_in_venv runs the launcher as a child process (not os.execv) specifically to keep Windows behavior sane, and loops on child.wait() across KeyboardInterrupt so repeated Ctrl+C delivery to the process group doesn't drop the exit code — a subtle but correct choice.
  • launcher._stable_secret_key bounds the OS-keyring call with a 10s thread-join timeout so a locked GNOME Keyring/KWallet can't hang the launcher on an interactive unlock prompt. Good defensive engineering for something that would otherwise be a very confusing hang for an end user.
  • launcher._spawn (launcher.py:315) closes the per-child log file handle if Popen raises, avoiding an fd leak — matches the commit history noting this was a CodeQL/static-analysis catch.
  • The migration correctly uses SeparateDatabaseAndState so Django's migration state still records the GinIndex (keeping makemigrations consistent with the model Meta) even when the database-side CREATE EXTENSION/CREATE INDEX is skipped. One thing worth confirming: since the state always claims the index exists, a future migration that does something index-aware (e.g., an AlterField that Django decides needs to drop/recreate indexes on raw_text, or a squash) could emit a DROP INDEX/CREATE INDEX pair assuming the index is present on every backend. Not a bug today, but worth a one-line note in the docstring for whoever touches this column next, since the "index may not actually exist in the DB" invariant is easy to forget.

Minor / non-blocking

  • spa_dist._release_asset_url hits the unauthenticated GitHub REST API (up to 3 requests: two tag lookups + /latest). That's subject to GitHub's 60 req/hour/IP anonymous rate limit. Very unlikely to matter for a single desktop user, but if this is ever iterated on for a "beta channel" or CI smoke-tests it, worth keeping in mind (a GITHUB_TOKEN-style override could be added later if needed — not needed now).
  • desktop_bootstrap._seed_user's MIN_PASSWORD_LENGTH = 8 comment says "the desktop profile skips Django's password validators" — config/settings/desktop.py doesn't actually override AUTH_PASSWORD_VALIDATORS (it's inherited from base.py); it's create_superuser() that bypasses them (validators are form/serializer-level, not enforced by the model manager). The behavior is correct, just a slightly misleading comment — could tighten to "Django's validators are never invoked here (create_superuser bypasses them), so enforce a floor ourselves."
  • paths.pg_data_dir() (and the other app-data subdirs) are created with default mkdir permissions rather than an explicit restrictive mode (e.g. 0o700). On a genuinely single-user desktop OS account this is low risk, but the pgdata directory does hold the full local database — an explicit 0o700 on POSIX would be cheap defense-in-depth against other local accounts on a shared machine.
  • No integrity check (checksum/signature) on the downloaded opencontracts-frontend-dist.zip beyond TLS — consistent with how most desktop installers work and not a real gap given HTTPS + GitHub's own hosting, just noting it since the PR is otherwise very security-conscious about this file (SSL context, zip-slip, cache versioning).

Test coverage

Coverage looks thorough and matches the surface area: test_desktop_packaging.py covers path resolution per-OS, the SPA fallback view (including the traversal-blocked case), bootstrap password flows (prompt/self-heal/no-reprompt/EOF-safety), the trigram migration guard (skip path, create path, real-Postgres availability probe), the SQLAlchemy result-backend URL mapping, and — nice catch — a subprocess test (DesktopSettingsImportTests) that imports config.settings.desktop under a hostile inherited env (USE_AUTH0=true, STORAGE_BACKEND=GCP) to prove the os.environ[...] = hard-sets in that module actually run before from .base import *. test_warp_ingest_parser.py covers the transient-vs-permanent error classification including the non-builtin (httpx-style) exception case.

Coverage pragmas (setup.cfg) omit desktop/launcher.py with a documented rationale (process-supervision entry point, like manage.py/wsgi.py) — reasonable, consistent with the codebase's existing convention for that kind of file.

Security

The security-relevant surfaces (secret storage via OS keyring instead of plaintext, SECRET_KEY/password never written to disk, zip-slip guard, safe_join in the SPA view, ALLOWED_HOSTS restricted to loopback, CSRF/session cookies correctly left non-Secure only because the app is loopback-only HTTP) all look handled correctly and are exactly the set of issues the commit history shows being fixed in response to CodeQL findings. Nothing new stood out in this pass beyond the minor points above.

No concerns with the migration's atomic = False + CREATE INDEX CONCURRENTLY combination — that's the correct pattern for a non-blocking index build on Postgres.

- docker-build-release.yml publishes opencontracts-frontend-dist.zip.sha256
  alongside the bundle; download_spa verifies it before extracting and
  refuses a mismatched asset (older releases without the sibling fall back
  to TLS-only trust). Rate-limit hint added to the not-found message.
- bootstrap: third Ctrl+C force-kills a hung child (escape hatch).
- paths.subdir: created app-data dirs are user-private (0o700 on POSIX) -
  the tree holds the full local database and uploaded documents.
- launcher._write_env_config: document the deliberate env-config.js
  rewrite side effect on repo-local dists.
- desktop_bootstrap: correct the MIN_PASSWORD_LENGTH comment (validators
  are bypassed by create_superuser, not skipped by the profile).
- migration 0074: docstring invariant note - state claims the trigram
  index exists even where the DB skipped it; future index-aware
  operations on raw_text must stay tolerant of its absence.

JSv4 commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Consolidated response to the review rounds above — adopted items are in commits 4e0aae1 and 9452e06:

Adopted

  • Stale SPA cache: downloads are now stamped with the checkout version (spa/.version); ensure_spa refreshes on mismatch and only falls back to the stale copy (with a warning) when the refresh fails offline. Covered by test_ensure_spa_uses_version_matched_cache / test_ensure_spa_refreshes_stale_cache / test_ensure_spa_falls_back_to_stale_cache_offline.
  • Bundle integrity: the release job now publishes opencontracts-frontend-dist.zip.sha256 and download_spa verifies it before extracting, refusing a mismatch (older releases without the sibling asset fall back to TLS-only trust). Covered by test_verify_checksum.
  • venv-creation failure message: now distinct from pip failure, with the Debian/Ubuntu apt install python3-venv hint.
  • Third-Ctrl+C force-kill escape hatch in the bootstrap wait loop; 0o700 on created app-data dirs; OCR-flags-conflict warning + transient-error cross-reference comment in WarpIngestLocalParser; env-config.js side-effect comment; corrected MIN_PASSWORD_LENGTH comment; rate-limit hint in the bundle-not-found message; double-launch warning in the docs; state-vs-DB index invariant note in the 0074 docstring.

Acknowledged, no change

  • Editing 0074 in place: intentional. 0074 first landed on main via Authority packs: baseline-origin collision guard + multi-YAML taxonomy merge (#2057) #2133 immediately before this PR, so the population of databases that ran the old body is tiny and — because the new body produces identical objects wherever pg_trgm exists — behaviorally indistinguishable from having run the new one. A superseding migration can't fix the actual failure (fresh desktop installs crash inside 0074 before any later migration runs).
  • sqlalchemy_result_backend_url and driver-qualified schemes: db+postgresql+psycopg2://… is a valid Celery SQLAlchemy backend URL (the db+ prefix is stripped and the remainder is a legal SQLAlchemy URL), and a driver-qualified scheme wouldn't get through django-environ's env.db() as DATABASE_URL in the first place — so the passthrough branch is correct as written.
  • from .base import * in config/settings/desktop.py (code-quality bot): this is the settings-inheritance idiom every other profile in the repo uses (local.py, production.py, test.py); diverging in one profile would be worse than the wildcard.
  • safe_join private API: mirrors django.views.static.serve's own usage; if Django relocates it the SPA tests fail loudly.
  • Launcher unit-testing and a headless CI smoke test (/api/health/ + SIGTERM) are good Phase-1 follow-ups; the launcher's supervision loop is currently exercised by the live end-to-end usability runs documented in the PR.

Generated by Claude Code

- nltk corpora download now prints a progress line instead of running
  silently (read as a hang).
- keyring-fallback warning rewritten in plain language (what breaks and
  why), with the env-var fix labeled as the advanced option.
- no-UI warning distinguishes offline from a pre-release checkout with no
  published bundle and gives the yarn recipe for the latter.
…wn comment

- bootstrap: prompt/error strings precomputed outside the password flow
  so no print() sits on password-tainted data (CodeQL
  py/clear-text-logging-sensitive-data on line 80 was a taint-flow FP -
  the strings never contained the password - but the hoist makes that
  provable).
- spa_dist: 500 MB safety cap on the bundle download (defense-in-depth
  against a compromised/oversized release asset).
- launcher: explanatory comment in the deliberate empty except during
  teardown drain (quality bot).
Comment thread opencontractserver/desktop/bootstrap.py Fixed
py/clear-text-logging-sensitive-data treats any /password/i identifier as
sensitive data; MIN_PASSWORD_LENGTH interpolated into the printed prompt
strings tripped it twice (alerts 86/87) despite no secret in the output.
The rename makes the non-flow provable to the heuristic; comment documents
the constraint for future editors.
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review: One-step desktop setup (python oc-desktop.py)

Great piece of work — the phased plan is well thought out, the pg_trgm/pgserver fix is a real showstopper resolved cleanly, and the test suite (path resolution, SPA zip-slip guard, trigram migration guard, password prompt/self-heal flows) is unusually thorough for an infra PR. A few notes below, roughly in priority order.

Findings

1. OC_DESKTOP_PASSWORD isn't actually scrubbed from the long-running launcher's own environment (minor security gap vs. the stated intent)

opencontractserver/desktop/bootstrap.py::maybe_prompt_first_run_password mutates the real process environment (os.environ["OC_DESKTOP_PASSWORD"] = password), not a local copy. In opencontractserver/desktop/launcher.py::main, the comment says "the launcher drops the env var before spawning the long-lived children," and indeed:

env = _base_env()          # env = os.environ.copy() — a local dict
...
env.pop("OC_DESKTOP_PASSWORD", None)   # only removes it from this local dict

env.pop(...) only removes the key from the local dict that gets passed to Daphne/celery worker/celery beat — it doesn't touch os.environ of the launcher process itself. Since:

  • the password was set directly on os.environ by the bootstrap step, and
  • the launcher either runs in the very same process (deps_ready()launch() in-process) or inherits it via subprocess.Popen(..., env=env) in _reexec_in_venv,

the top-level, long-running launcher process (the one supervising Postgres/Daphne/Celery until Ctrl+C) keeps the plaintext password in its own environment table for the entire session — visible via /proc/<pid>/environ on Linux (same user/root) or the process environment view on Windows/macOS. Grandchildren (Daphne/worker/beat) are correctly protected, but the launcher itself isn't, which contradicts the comment's stated guarantee.

Fix is small: os.environ.pop("OC_DESKTOP_PASSWORD", None) (mutating the actual environment, not just the local env dict copy) right after _first_run_bootstrap(env) in launcher.main(). Given the threat model (loopback-only, single local user), impact is low, but worth tightening since the code explicitly claims to do this already.

2. launcher.py is fully excluded from coverage, including pure/testable logic

setup.cfg omits */desktop/launcher.py entirely with the rationale "orchestration/IO with no unit-testable logic, like manage.py/wsgi.py/asgi.py." That's true of most of the file, but a few pieces are pure enough to test directly, e.g. _free_port()'s stable-port-then-fallback logic and _keyring_username()'s hashing. Consider either extracting these into paths.py/db.py-style pure modules (which are tested) or adding a couple of targeted tests before the coverage omission — similar to how spa_dist.py's pure helpers got isolated and tested despite the module doing network I/O overall.

3. _stable_secret_key's keyring timeout can race a background persist

If the daemon thread in _stable_secret_key (launcher.py) doesn't finish within _KEYRING_TIMEOUT_SECONDS, the function falls back to an ephemeral key for this session, but the thread keeps running in the background and may still call keyring.set_password(...) with a different freshly-generated key once it (eventually) completes. That's harmless today (worst case: an extra orphaned keyring entry, and the next launch picks up whatever got persisted), but it's a subtle interaction worth a one-line comment if it was considered — it wasn't obviously mentioned in the docstring alongside the timeout rationale.

Nice touches worth calling out

  • spa_dist.safe_extract_zip's combined path-traversal + symlink-member guard, with tests for both the good and evil-zip cases.
  • download_spa's stage-then-atomically-swap pattern so a failed refresh never destroys a working cached SPA.
  • The annotations/0074 migration fix is exactly right: probe pg_available_extensions, skip the index (not fail the whole migration) when absent, and the reverse operation no longer risks the DROP EXTENSION ... other objects depend on it failure mode.
  • desktop_bootstrap's explicit MIN_PASSWORD_LENGTH enforcement on the OC_DESKTOP_PASSWORD env path — closing the gap where Django's validators never run for create_superuser.
  • Good defensive touches: the SPA bundle size cap (MAX_BUNDLE_BYTES), the SHA-256 checksum verification (with a documented no-checksum fallback for older releases), and TLS cert handling for python.org macOS builds via certifi.

Minor / non-blocking

  • config/settings/desktop.py's _csp = SECURE_CSP_DIRECTIVES.copy() is a shallow copy; harmless today since only connect-src is reassigned (not mutated in place) and Auth0 is force-disabled on this profile, but worth keeping in mind if a future change starts appending in place to a shared list.
  • oc-desktop.py only bails early for Python < 3.8; the real supported window (3.10–3.12) is enforced later in bootstrap.python_version_error. That's intentional (the file needs from __future__ import annotations to parse on 3.8/3.9), just worth a one-line comment noting the two checks serve different purposes so a future reader doesn't "fix" the seeming inconsistency.
  • desktop_bootstrap._seed_pipeline_settings logs the raised exception via logger.error(..., exc_info=True) and also surfaces the exception text in the user-facing warning string — fine for internal exceptions here, but worth double-checking that migrate_pipeline_settings never raises with a message that could embed a secret value (e.g. a malformed API key) before it's echoed to stdout.

Test coverage

Coverage of the new modules is strong — paths.py, spa_dist.py, the trigram migration guard, and the password prompt/self-heal/no-reprompt flows in desktop_bootstrap are all exercised, including the zip-slip and traversal-blocking edge cases in config/spa.py. The main gap is launcher.py per point 2 above.

Nothing here blocks merging — the core fixes (pg_trgm crash, one-command flow, Node-free SPA acquisition) look correct and well-tested; item 1 is the only one I'd actually want addressed before shipping, and even that is low severity given the single-user/loopback threat model.

@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review: One-step desktop setup (python oc-desktop.py)

Reviewed the full diff (bootstrap/launcher/spa_dist/paths/db, config/settings/desktop.py, config/spa.py, the 0074/0028 migration changes, warp_ingest_local_parser.py, and test_desktop_packaging.py). This is a large but very carefully built change — the docstrings anticipate most of the sharp edges (TOCTOU on the port, zip-slip, CodeQL's clear-text-logging heuristic, keyring hangs, chord/result-backend requirements) before a reviewer would even ask about them. Nice work.

Strengths

  • Security-conscious defaults: safe_extract_zip guards both path traversal and symlink members; the SPA bundle download is capped (MAX_BUNDLE_BYTES), verified against a published SHA-256 when available, and staged into a sibling dir so a failed refresh can never clobber a working cached copy.
  • Migration fix (0074) is well targeted: state always claims the index exists (keeps makemigrations quiet), while the DB operation probes pg_available_extensions and skips cleanly on the embedded Postgres. The reversal fix (drop only the index, don't DROP EXTENSION) is a legitimate independent bug fix, not just a desktop accommodation.
  • Secret handling: SECRET_KEY persisted via the OS keyring (not a plaintext file) with a hard thread-join timeout so a locked keyring can't hang the launcher; the first-run password is scrubbed from the env before it's inherited by the long-lived Daphne/worker/beat children.
  • Test coverage (test_desktop_packaging.py) is thorough for the pure-logic surfaces: per-OS path resolution, the SPA fallback view's traversal guard, tag-mapping, zip-slip, and both branches of the trigram migration guard (including a real-Postgres assertion that pg_trgm actually is available in CI, so the "skip" branch doesn't bitrot unnoticed).

Minor findings

  1. Test file naming collision riskopencontractserver/tests/test_warp_ingest_parser.py (new) tests WarpIngestLocalParser, but the name reads as the test file for warp_ingest_parser.py (the pre-existing REST client, actually covered by test_doc_parser_warp_ingest.py). Given the two parsers already have easily-confused names (WarpIngestParser vs WarpIngestLocalParser), naming the new test test_warp_ingest_local_parser.py would remove the ambiguity for the next person grepping for "which test covers which parser."

  2. config/settings/desktop.py mutates process-wide os.environ (USE_AUTH0, STORAGE_BACKEND, DATABASE_URL setdefault) as an import side effect, justified in the comment by base.py's import-time branching. That's a reasonable, well-documented trade-off for a single-process desktop launcher, but worth a one-line callout in docs/deployment/desktop_packaging.md (or a code comment) that this settings module should never be imported in the same interpreter as another settings module (e.g. a hypothetical test that imports both config.settings.desktop and config.settings.production/test back to back) — it would leak env vars across them. Not exploitable today since nothing in the test suite does that, just a latent footgun for whoever touches this next.

  3. Residual trust-on-first-download risk in spa_dist.download_spa — when a release has no .sha256 sidecar (pre-existing releases, or the checksum-upload step failing independently of the zip upload), the bundle is accepted on TLS trust alone before being unzipped and later served same-origin as the app's own frontend. This is already called out in the docstring as a deliberate fallback and the source is your own GitHub Actions pipeline, so the practical risk is low — just flagging it as the one place where the "verify before trust" posture established elsewhere (zip-slip guard, byte cap) has a gap for older/degraded releases.

  4. launcher._free_port TOCTOU is explicitly documented as an accepted Phase-0 risk (single-user, loopback-only) with a note that a bind-retry loop is a Phase-1 follow-up — no action needed now, just confirming the tradeoff read as intentional rather than overlooked.

Nits (non-blocking)

  • requirements/desktop.txt pins psycopg2-binary==2.9.12 — worth double-checking that version actually exists on PyPI before merge (I couldn't verify against the index from this environment); if it doesn't, first-run pip install fails, which is exactly the "cliff" this PR is trying to eliminate.

Overall

No correctness or security blockers found. The defensive coding (staged installs, checksum verification, idempotent bootstrap, self-healing password state, non-destructive migration skip) matches the repo's stated bar. Good to merge once someone double-checks the psycopg2-binary pin resolves.

- launcher.main: os.environ.pop(OC_DESKTOP_PASSWORD) after bootstrap -
  env.pop() only cleaned the dict handed to children, while the
  supervising process (set via bootstrap's early prompt on os.environ)
  kept the plaintext password in /proc/<pid>/environ for the whole
  session, contradicting the comment's guarantee.
- _stable_secret_key docstring: document the accepted timed-out-thread
  persist race.
- Tests for launcher's pure helpers (_free_port stable-default/fallback,
  _keyring_username determinism + data-dir scoping) despite the
  coverage omit.
- Rename test_warp_ingest_parser.py -> test_warp_ingest_local_parser.py:
  it tests WarpIngestLocalParser; the REST client's tests live in
  test_doc_parser_warp_ingest.py (reviewer-flagged ambiguity).
  (psycopg2-binary==2.9.12 pin verified by three real installs in the
  live usability runs.)
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review: One-step desktop setup (python oc-desktop.py)

Reviewed against the merge of this branch into main (28 files, +3481/-36). This is a well-scoped, carefully documented piece of infra work — the docstrings consistently explain why (not just what), which matches this repo's conventions, and the security-sensitive paths (zip extraction, checksum verification, password handling, env-var scrubbing) show real threat-modeling rather than box-checking.

Overview

  • opencontractserver/desktop/bootstrap.py (new): stdlib-only venv bootstrap + Python-version gate + re-exec into the venv.
  • opencontractserver/desktop/spa_dist.py (new): resolves/downloads/extracts the pre-built SPA from a GitHub release, with zip-slip/symlink guards and SHA-256 verification.
  • opencontractserver/desktop/launcher.py, db.py, paths.py: process supervision, OS-keyring-backed stable SECRET_KEY, XDG/AppData path resolution.
  • documents/management/commands/desktop_bootstrap.py: idempotent first-run user/pipeline/nltk seeding with self-healing password logic.
  • annotations/migrations/0074 and corpuses/migrations/0028: made tolerant of a pg_trgm-less embedded Postgres and a permission-table-not-yet-created fresh install, respectively.
  • New warp_ingest_local_parser.py: in-process sibling of the existing REST warp_ingest_parser.py, used only by the desktop pipeline settings.
  • .github/workflows/docker-build-release.yml: new job attaches a built, environment-agnostic frontend/dist zip (+ .sha256) to every release.

Potential issue: unhandled IndexError can crash the SPA download instead of degrading gracefully

spa_dist._verify_checksum (opencontractserver/desktop/spa_dist.py:116-128) does:

expected = response.read().decode("utf-8", "replace").split()[0].lower()

If the .sha256 response body is empty or otherwise doesn't tokenize (truncated connection, unexpected server response, proxy interstitial page), .split()[0] raises IndexError. The caller, download_spa (line ~238), only catches (urllib.error.URLError, OSError, ValueError, zipfile.BadZipFile)IndexError isn't in that tuple, so it propagates all the way up through spa_dist.ensure_spalauncher._resolve_spa_dirlauncher.main, neither of which wrap the call in try/except either. That would crash the whole launcher on first run instead of falling back to "no UI, API only" (the documented degrade path), which cuts directly against this PR's stated goal of no hard failures for a non-technical user on a flaky connection. test_verify_checksum (opencontractserver/tests/test_desktop_packaging.py:553) only covers the well-formed match/mismatch cases, not a malformed response — worth adding that case once fixed. Easy fix: catch IndexError alongside the others (or normalize the parse to raise ValueError on empty/malformed input).

Things done well (worth calling out, not just criticizing)

  • safe_extract_zip (spa_dist.py:131-152) guards both path traversal and symlink members before calling extractall — genuinely defensive for a zip pulled over the network.
  • Checksum verification + a MAX_BUNDLE_BYTES cap + staged download-then-atomic-rename (never clobbering a working cached SPA on a failed refresh) is a solid pipeline.
  • annotations/migrations/0074: replacing the unconditional TrigramExtension() with a pg_available_extensions probe is the right fix, and the migration's own docstring calls out the resulting "state says index exists, DB may not" invariant for future maintainers — exactly the kind of forward-looking comment this codebase wants.
  • launcher._stable_secret_key: reasoned handling of a hung/locked OS keyring (daemon-thread + timeout + ephemeral fallback), and the explicit acknowledgment of the residual race (a late keyring write racing the ephemeral fallback) rather than hiding it.
  • Password handling: min-length floor applied uniformly regardless of source (env var or prompt), passwords never logged/persisted, and the OC_DESKTOP_PASSWORD env var is explicitly scrubbed from both the child env and the supervising process's own os.environ after use (launcher.py:456-459) so it doesn't linger in /proc/<pid>/environ.
  • _first_run_bootstrap/desktop_bootstrap only write the completion marker on success, so a transient failure (e.g. migrate_pipeline_settings) retries on next launch instead of permanently wedging the install — nice idempotency story, and it's actually tested (test_handle_raises_on_pipeline_failure_so_marker_is_not_written).
  • config/urls.py's SPA catch-all uses a negative-lookahead regex to exclude api/graphql/admin/mcp/sse/ws/static/media by path segment, registered last — correctly avoids shadowing existing routes.

Minor / non-blocking notes

  • _free_port's TOCTOU window between the probe-socket release and Daphne's bind is called out explicitly in a comment as an accepted risk for a single-user loopback app with a "Phase-1 follow-up" — reasonable, just flagging that I agree with the acceptance rather than missing it.
  • warp_ingest_local_parser.py re-implements transient-vs-permanent error classification via exception-class-name matching (_TRANSIENT_ERROR_NAMES) rather than reusing the sibling REST parser's requests-exception-type approach. The docstring explains why (avoiding an unnecessary import of the optional requests/httpx deps) — a reasonable call given the constraint, just noting it's a second implementation of a similar concept rather than a shared helper.
  • setup.cfg excludes desktop/launcher.py from coverage with a clear rationale (process orchestration, like manage.py/asgi.py) — consistent with existing exclusions, and its pure helpers (paths.py, db.py) are covered separately. Good call.
  • Nice touch in bootstrap.py: the CodeQL false-positive workaround for py/clear-text-logging-sensitive-data (precomputing prompt strings so no *password*-named identifier feeds a print) is a bit unusual but well-commented, so a future reader won't "fix" it back into a lint failure.

Test coverage

Coverage is broad and well-targeted: path resolution per-OS, SPA resolution order (repo dist → cache → download → yarn fallback), zip-slip guard, migration guard (skip vs. create, plus a real-Postgres availability probe), and the password prompt/self-heal/no-reprompt state machine. The one gap I'd add is the malformed-checksum-response case noted above.

Overall: solid, well-reasoned change with good defense-in-depth on the one genuinely new attack surface (downloading + extracting a zip from the network). The checksum IndexError is the only thing I'd want fixed before merge.

…rashing

An empty/mangled .sha256 response (truncated connection, proxy
interstitial) hit .split()[0] -> IndexError, which escaped download_spa's
catch tuple and crashed the whole launcher on first run. Raise ValueError
instead (already on the graceful-degrade path: refuse the bundle, fall
back to cache/yarn/API-only). Test added for the malformed-response case.
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review: One-step desktop setup (python oc-desktop.py)

This is a large, carefully iterated PR — the commit history shows the CodeQL/mypy/coverage/usability-test feedback loop was already run several times, and most of the obvious sharp edges (zip-slip, secret persistence, TOCTOU port binding, fd leaks, EOF-safety, checksum verification) are already handled well. Below are the things I found that survived that process.

Potential bug: app-data directory tree isn't actually 0o700 on a typical first run

opencontractserver/desktop/paths.py::subdir() and launcher.py::_ensure_dirs() both rely on Path.mkdir(mode=0o700, ..., exist_ok=True) to keep the per-user app-data tree private ("the tree holds the full local database and uploaded documents" per the PR description and several inline comments).

The problem: Path.mkdir(mode=..., exist_ok=True) only applies mode when it creates the directory — if the directory already exists, it's a silent no-op with no chmod. On a fresh install, the very first thing that touches the app-data tree is bootstrap.py::_install_dependencies(), which shells out to python -m venv <app_data_dir>/venv (bootstrap.py:187-190, via venv_dir() = paths.subdir("venv") — called without create=True, so nothing in paths.py creates it first). Python's venv module creates env_dir (and any missing parents, i.e. app_data_dir itself) via a plain os.makedirs() with the process's default umask-derived mode (typically 0o755), not 0o700.

Only afterwards, once bootstrap re-execs into the venv and launcher.main() runs, does _ensure_dirs() call Path(paths.app_data_dir()).mkdir(mode=0o700, ..., exist_ok=True) (launcher.py:163-173) — but by then app_data_dir already exists (from the venv creation), so this is a no-op that never tightens the permission.

Net effect: on a typical first launch, the top-level app-data directory (and the venv/ subdirectory) keep the OS-default, world-readable+executable mode instead of the documented 0o700. Leaf directories created after app_data_dir already exists (pgdata, media, staticfiles, logs, celery-broker/*) do get correctly created with 0o700 since they're new single-level children of an already-existing parent — so the actual sensitive payloads (DB files, uploaded documents) stay protected — but the directory names under app_data_dir (and the venv contents) are listable by any other local account on a shared machine, which contradicts the "user-private app-data tree" invariant the code comments assert.

A related, smaller version of the same gotcha: Path.mkdir(parents=True, mode=...) never applies mode to intermediate parents it creates along the way either (per the stdlib docs) — so any future subdir(..., create=True) call that needs to create more than one new path segment at once would have the same problem for the intermediate levels.

Suggested fix: after creating app_data_dir (whichever code path gets there first), explicitly os.chmod(app_data_dir, 0o700) rather than relying on mkdir's mode= under exist_ok=True/parents=True. Simplest robust fix is probably a small helper in paths.py that does path.mkdir(...); os.chmod(path, 0o700) unconditionally (idempotent, cheap), and to have bootstrap.venv_dir()/_install_dependencies() create the parent via that helper before shelling out to python -m venv.

This isn't exploitable to leak document contents directly (the sensitive leaves are still 0o700), so I'd call it medium severity — a defense-in-depth gap on the exact property (single-user machine sharing) the PR calls out as the reason for adding the mode in the first place. Worth a regression test (os.stat(...).st_mode) in test_desktop_packaging.py, which currently has no coverage for _ensure_dirs() or directory permissions at all.

Minor / nits

  • spa_dist.py::download_spa() creates the spa/ and spa.new staging directories via plain staging.mkdir(parents=True, exist_ok=True) with no explicit mode — inconsistent with the "everything under app-data is 0o700" story elsewhere, though low-severity since the SPA bundle isn't sensitive data.
  • opencontractserver/desktop/bootstrap.py and desktop_bootstrap.py rename MIN_PASSWORD_LENGTHLOGIN_MIN_LENGTH purely to dodge CodeQL's /password/i name heuristic (documented in the comment at bootstrap.py:46-50). Understandable given the false-positive, but worth double-checking CodeQL doesn't just re-flag it later under a different pattern — a # nosec/query-suppression comment tied to the specific alert ID might be more durable than a naming convention that has to be remembered by every future editor touching this file.
  • opencontractserver/corpuses/migrations/0028_fix_grant_permission_to_corpus_creators.py:90 has a pre-existing print(f"\nResults:") (f-string with no placeholders) right next to code this PR touches — not introduced by this PR, but flake8/ruff would normally flag it; easy drive-by fix if you're already in the file.

Security

  • Zip extraction (safe_extract_zip), SHA-256 verification with graceful degradation, SSL context handling (SSL_CERT_FILE/SSL_CERT_DIR override with certifi fallback), SECRET_KEY persistence via OS keyring (not plaintext), password never written to disk/logged, and the password being scrubbed from the supervisor's own os.environ (not just the child env dict) are all handled correctly and match the stated threat model for a single-user desktop app.
  • ALLOWED_HOSTS = ["localhost", "127.0.0.1"] with no "0.0.0.0" and cookies over plain HTTP scoped to loopback-only binding is appropriate for this deployment shape.
  • The one open question is the directory-permission gap above — everything else in the "keep local secrets/data private" story is solid.

Test coverage

  • test_desktop_packaging.py is thorough for the pure-function surfaces (path resolution, fingerprinting, tag mapping, zip-slip/symlink guards, checksum edge cases, migration guard). The # pragma: no cover omissions on process/network orchestration (_install_dependencies, _reexec_in_venv, download_spa, launcher child-process management) are reasonably justified given they're exercised by the documented live end-to-end runs instead — consistent with how launcher.py's coverage is already handled.
  • As noted above, there's no test that would have caught the permission-mode gap (nothing asserts stat().st_mode anywhere), which is exactly the kind of thing that's easy to eyeball as "looks right" in the mkdir call but is wrong due to the exist_ok=True semantics. A single test creating a pre-existing app_data_dir with permissive mode, then calling _ensure_dirs() and asserting 0o700, would have caught this immediately.

Overall

Good design, and the amount of adversarial self-review baked into the commit history is unusually high — most of what I'd normally flag (secret handling, zip-slip, TOCTOU windows, fd leaks) was already caught and fixed in earlier rounds. The directory-permission ordering issue is the one concrete, previously-unaddressed bug I found; everything else is minor polish.

…ever tightens

Reviewer-caught ordering bug: on a first run, 'python -m venv' is the
first thing to create the app-data ROOT (with umask defaults, typically
0755), and every later mkdir(mode=0o700, exist_ok=True) silently
no-ops on existing dirs - so the documented user-private invariant
failed at the root and venv levels (sensitive leaves were still 0700).
parents=True has the same gap for intermediate levels.

- paths.ensure_private_dir: mkdir -p + explicit idempotent chmod 0o700
  on the root and every level below it (suppressed on Windows);
  subdir(create=True) routes through it.
- bootstrap pre-creates the venv dir via the helper BEFORE python -m venv.
- launcher _ensure_dirs/pgdata and spa_dist staging use the helper.
- Regression tests assert st_mode on pre-existing permissive trees and
  intermediate parents (the exact eyeball-proof gap).
- Drive-by: placeholder-less f-string in corpuses/0028 print.
Comment thread opencontractserver/tests/test_desktop_packaging.py Fixed
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review: One-step desktop setup (#2137)

This is a big, well-iterated PR — the commit history shows ~15+ rounds of CodeQL/mypy/reviewer-feedback already addressed (zip-slip, symlink zip members, secret persistence, permission races, keyring hangs, etc.). I focused on what might still be wrong after all that polish, reviewing launcher.py, bootstrap.py, paths.py, db.py, spa_dist.py, desktop_bootstrap.py, the settings/URL/SPA-serving wiring, migrations 0074/0028, warp_ingest_local_parser.py, and the test suite. Overall the security-sensitive paths (SPA catch-all traversal guard, zip extraction, ALLOWED_HOSTS/CSRF for the loopback-only server, settings import ordering) hold up under adversarial reading. A handful of real issues remain:

1. spa_dist.py — failed swap can leave ensure_spa's cache fallback pointing at a deleted directory

download_spa() (opencontractserver/desktop/spa_dist.py, the "Success — swap" block) does:

if spa_root.exists():
    shutil.rmtree(spa_root)
staging.rename(spa_root)

If rename (or the preceding rmtree) raises OSError — e.g. a file transiently locked by AV/indexing on Windows/macOS, which is exactly the audience this feature targets — the exception is caught by the outer except (urllib.error.URLError, OSError, ValueError, zipfile.BadZipFile) clause, which cleans up staging and returns None. But spa_root (the previously-working cache) has already been removed by the rmtree before the failed rename. Back in ensure_spa, cached was computed before calling download_spa, so the fallback if cached: return cached returns a Path that no longer exists on disk — directly contradicting the documented guarantee ("a failed refresh must never destroy a previously working cached copy"). Consider re-resolving _dist_dir_within(spa_root) after a failed download_spa() rather than trusting the pre-download cached value.

2. launcher.py::main() — health check doesn't notice a dead child, so a crash-on-boot still "succeeds"

_wait_for_http(f"{base}/api/health/", timeout=60) only polls the HTTP endpoint; it never checks whether the Daphne child in _children has already exited. If Daphne dies immediately (bad OC_DESKTOP_FRONTEND_DIR, ASGI import error, etc.), the launcher still burns the full 60s, prints the "WARNING… opening the browser anyway" message, then the success banner, then opens a browser tab that shows a connection error — only for the supervise loop below (which polls every 1s) to tear the whole thing down moments later. Worth short-circuiting _wait_for_http/main() on a dead tracked child instead of waiting out the full timeout.

3. bootstrap.py — no lock around first-run venv creation/dependency install

main()_venv_is_current()_install_dependencies() has no lockfile/mutex. A user double-clicking the launcher script twice (plausible — there's no console output for the first several seconds) starts two concurrent python -m venv / pip install -r requirements/desktop.txt runs into the same venv directory, which can interleave partial writes and corrupt it, requiring the non-technical target user to manually delete the app-data venv dir to recover. A simple os.open(..., O_CREAT | O_EXCL) sentinel or advisory lock file under the venv dir for the duration of install would close this.

4. Minor / hardening nits

  • spa_dist.py::_verify_checksum reads the .sha256 sidecar with response.read() and no size cap, unlike the paired bundle download which enforces MAX_BUNDLE_BYTES (500MB) specifically as defense-in-depth. A misbehaving asset host/proxy returning a huge body would be read fully into memory. Worth an analogous small cap (a real sidecar is a few bytes).
  • paths.py::ensure_private_dir calls path.mkdir(parents=True, exist_ok=True) without mode=0o700, then chmods every level afterward. Between the mkdir and the matching chmod, a newly-created directory (e.g. the app-data root on a true first run) briefly sits at the default umask mode — a narrower variant of the exact bug this helper was written to close. Passing mode=0o700 to the mkdir call in addition to the chmod loop would remove the window.
  • warp_ingest_local_parser.pypdf_ingestor.parse_to_opencontracts(...) is only guarded against raising; if the external warp_ingest library ever returns None/falsy instead (e.g. for a degenerate PDF), the subsequent export.get("title") throws an unguarded AttributeError instead of the documented, classified DocumentParsingError. Worth a defensive if export is None: raise DocumentParsingError(..., is_transient=False) if that's a real possibility in the library's contract.
  • test_desktop_packaging.py::SpaFallbackTests.test_client_route_returns_index / test_traversal_is_blocked only assert status_code == 200 and Content-Type contains text/html — neither checks that the response body actually contains the index.html fixture content ("SPA-INDEX", written in setUp). A regression that serves an empty text/html 200 instead of real index.html bytes would pass both tests despite the traversal-guard fallback being effectively broken.
  • Small style nit: launcher.py has a named _KEYRING_TIMEOUT_SECONDS constant but bare-literal timeouts elsewhere (timeout=60 for the health check, + 10 for the shutdown grace period) — per the repo's "no magic numbers" convention these could be promoted alongside it.

None of these are blockers given the scope and iteration already invested, but #1#3 are genuine correctness/reliability gaps worth a follow-up (or a fix in this PR if you'd rather not split it further).

Three reviewer-confirmed gaps closed:

- bootstrap: O_CREAT|O_EXCL install lock around venv creation + pip
  install (double-launched first runs interleaved writes into the same
  venv); stale locks reclaimed via PID liveness (POSIX) or age; state
  re-checked under the lock.
- launcher: _wait_for_http short-circuits when a tracked child has died,
  and main() prints a logs-pointing ERROR instead of a success banner
  over a crashed Daphne; health/shutdown timeouts promoted to named
  constants.
- spa_dist: the swap moves the old cache ASIDE and restores it if the
  rename fails (AV/indexer file locks), instead of rmtree-before-rename;
  ensure_spa re-resolves the cache after a failed refresh rather than
  trusting the pre-download Path; 4KB cap on the checksum sidecar read.

Hardening nits: mode=0o700 on ensure_private_dir's mkdir (narrows the
pre-chmod window), None-return guard in WarpIngestLocalParser, SPA
fallback tests now assert the actual index.html body (not just 200 +
content type).
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review: Single-user desktop packaging (Phase 0)

This is a big PR (30 squashed commits, ~3.7k additions across 26 files) but it's unusually careful work for its risk surface (subprocess orchestration, an embedded DB, and edits to two already-shipped migrations). I checked out the merged tree against the pre-PR base (dc214be2) and read the actual diffs rather than working from the raw patch. Overall: solid engineering, no injection/traversal/binding issues found, and the two edited migrations are safe. A handful of gaps below are worth a look or at least a conscious "yes, known" from the author.

Things done well

  • All subprocess calls use list-argument Popen/subprocess.run — no shell=True, no injection surface.
  • SPA path traversal is blocked in two independent places: safe_join in config/spa.py, and a manual is_relative_to check in spa_dist.py's zip extraction (zip-slip guard).
  • Daphne binds only to 127.0.0.1; secrets (session password, secret key) are never logged; credential files get chmod 0600.
  • config/urls.py's SPA catch-all (^(?!(?:api|graphql|admin|mcp|sse|ws|static|media)(?:/|$))(?P<resource_path>.*)$) is anchored correctly and registered last, so it can't shadow real routes — and it's belt-and-suspenders anyway since /mcp, /sse are intercepted earlier in config/asgi.py and /ws/ is a separate protocol scope.
  • Migration safety, verified: editing 0074_annotation_raw_text_trigram_index.py and 0028_fix_grant_permission_to_corpus_creators.py is safe for already-applied databases — Django's executor keys off (app, name) in django_migrations, never re-diffing file content, so no already-migrated DB (compose/CI/prod) re-runs either. On fresh installs the new _pg_trgm_available() gate preserves prior behavior (still creates the extension/index) wherever pg_trgm exists, and only skips it on the embedded desktop Postgres that genuinely lacks contrib extensions. The old unconditional TrigramExtension().reverse() (DROP EXTENSION pg_trgm unconditionally) was itself a latent hazard — good catch fixing that too.
  • warp_ingest_local_parser.py follows the existing lazy-import optional-dependency convention correctly, classifies transient vs. permanent parse errors sensibly, and has a real-parser smoke test that self-skips when the package/corpora aren't installed.
  • Changelog fragments are present and correctly named per the changelog.d/<slug>.<type>.md convention.

Worth a look

  1. No protection against orphaned subprocesses on a hard kill/crash. opencontractserver/desktop/launcher.py relies entirely on atexit (_stop_pg, _shutdown) for teardown, which never runs on SIGKILL/OOM-kill/hard crash. If the launcher dies uncleanly mid-session, the embedded pgserver process and/or Daphne/Celery children can be orphaned, holding the loopback port and the pgdata lock — the next launch may fail to rebind the port or start Postgres until the user manually kills the leftover processes. This is explicitly called out under "Phase 1" in the docs as a known follow-up, so it reads as a disclosed gap rather than an oversight, but flagging since there's currently no process-group kill / Job Object / PR_SET_PDEATHSIG mitigation.

  2. launcher.py (558 lines, the largest and riskiest new file) is excluded from coverage (setup.cfg's new */desktop/launcher.py omit) and has almost no behavioral test coverage beyond _free_port()/_keyring_username() in test_desktop_packaging.py. The subprocess-spawn/teardown logic — _shutdown()'s SIGINT/SIGTERM path, _spawn()'s failure cleanup, crash-during-startup detection — is validated only by manual usability testing per the PR description. A lightweight integration test that injects fake _spawn commands (e.g. sleep) and asserts _shutdown actually terminates/kills them would catch regressions here without needing a real Postgres/Daphne stack.

  3. _stable_secret_key()'s keyring race is untested. If the keyring lookup doesn't finish within its timeout, the launcher falls back to an ephemeral key for the session while the background thread keeps running and can still persist a different freshly-generated key afterward — so the persisted key and the key actually used to encrypt this session's PipelineSettings secrets can diverge. The docstring treats this as accepted, but there's no test pinning the fallback-then-late-write behavior, so a future refactor could silently make it worse (e.g. overwrite a good existing key with the ephemeral one).

  4. deps_ready() skips the private venv based on importability, not version compliance (opencontractserver/desktop/bootstrap.py). If all five sentinel modules (django, daphne, celery, pgserver, warp_ingest) are importable in the ambient interpreter — e.g. a machine with a previously pip installed older/incompatible pgserver/warp-ingest — the launcher runs directly against that environment, bypassing the pinned, fingerprinted install and the "we don't touch your system Python" guarantee in the README. Low real-world likelihood, but worth confirming it's intentional (the docstring mentions a "dev environment" case) since it means silent version drift is possible.

  5. Minor: OC_DESKTOP_PASSWORD is briefly readable via /proc/<pid>/environ while the desktop_bootstrap management-command subprocess runs (same-user-only exposure, short window, and the code is otherwise careful to scrub it from long-lived children afterward). Probably an acceptable trade-off for a single-user local app — just flagging since everything else here goes out of its way to avoid ever writing/logging the password.

Nothing above blocks merging in my view — 1–2 are the ones I'd actually want a "yes, known, tracked for Phase 1" acknowledgment on before shipping to non-technical end users, since a stuck loopback port or pgdata lock is exactly the kind of cliff the PR's own usability testing was designed to catch.

… trade-off doc

- LauncherShutdownTests: _spawn a real child, assert _shutdown stops it
  and leaves the durable per-child log (the riskiest launcher logic now
  has a regression net despite the coverage omit).
- StableSecretKeyFallbackTests: pin the fallback contract (hung keyring
  -> ephemeral key within timeout; broken backend -> ephemeral; healthy
  -> persisted key) so a refactor can't silently make the accepted race
  worse.
- deps_ready docstring documents the intentional importability-over-
  version-pinning trade-off (ambient interpreters bypass the venv).

JSv4 commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Re: the latest review — implemented 2 and 3, acknowledging 1, 4, 5 (commit 888f173):

  1. Orphaned children on SIGKILL/OOM — yes, known, tracked for Phase 1 (docs/deployment/desktop_packaging.md Phase-1 list: "harden embedded-Postgres supervision (stale-lock recovery …)"; process-group/Job-Object teardown belongs there with it). Two mitigations already soften the cliff: _free_port() falls back to an ephemeral port if 8406 is squatted by a leftover Daphne, and pgserver.get_server() reuses/recovers a running instance for the same pgdata rather than failing on the lock in the common case.
  2. Launcher teardown now has a behavioral test: LauncherShutdownTests spawns a real child via _spawn, asserts _shutdown() actually stops it and that the durable per-child log exists — a regression net for the riskiest logic despite the coverage omit.
  3. Keyring fallback contract pinned: StableSecretKeyFallbackTests covers hung-keyring (ephemeral within the timeout), broken-backend (ephemeral), and healthy (persisted key) paths, so a refactor can't silently overwrite a good persisted key with the ephemeral one without a test going red.
  4. deps_ready() importability-over-version check — intentional, now documented in its docstring: it's what lets the venv child, dev environments, and future installer payloads run directly; the ambient-interpreter drift case is the accepted trade-off.
  5. OC_DESKTOP_PASSWORD in the bootstrap child's /proc/<pid>/environ — accepted for the single-user threat model (same-UID-only visibility, seconds-long window); it's the transport between the early prompt and the DB-owning command, and it's scrubbed from the supervisor and all long-lived children immediately after.

Generated by Claude Code

@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review of PR #2137 (single-user desktop packaging)

This is a big, well-scoped change (settings profile, launcher, in-process PDF parser, migration fixes, docs/CI). I reviewed it in parallel across four areas: settings/launcher/security, migrations + bootstrap command, the new PDF parser, and docs/CI/tests. Overall the security-sensitive parts (subprocess handling, secret-key storage, SPA path traversal, URL catch-all scoping) are handled carefully — no injection, traversal, or hardcoded-secret issues found. The findings below are the residual gaps worth a look before merge.

Correctness / robustness

  1. opencontractserver/pipeline/parsers/warp_ingest_local_parser.py — no size/timeout guard on in-process PDF parsing. The sibling REST parser (warp_ingest_parser.py) explicitly enforces max_file_size_mb (checked via default_storage.size()) and an HTTP request_timeout before parsing. warp_ingest_local_parser.py streams the upload to a temp file and calls pdf_ingestor.parse_to_opencontracts() synchronously with no size cap and no timeout. Since the class is "registered for any deployment" (not just desktop) and parsing now runs in-process rather than over HTTP, a large/pathological PDF (deep nesting, huge embedded images feeding OCR) can hang or OOM the Celery worker indefinitely with no way to recover. Worth adding the same guard the REST variant has.

  2. Same file — unvalidated export shape. Only export is None is checked before use; anything falsy-but-not-None (e.g. [], "") or a wrong type still reaches export.get("title"), raising a raw AttributeError instead of a classified DocumentParsingError. The REST sibling's _extract_export validates isinstance(export, dict) and required keys first — this parser has no equivalent, so a mismatched/misbehaving warp-ingest version surfaces as an unclassified crash instead of a clean permanent-failure signal to the pipeline.

  3. opencontractserver/desktop/bootstrap.py / paths.py — first-run directory creation isn't wrapped in the same friendly-error pattern as everything else. paths.ensure_private_dir()'s mkdir() call (and launcher.py's _ensure_dirs) has no surrounding try/except, unlike the venv-creation and pip-install paths, which all catch subprocess.CalledProcessError with actionable messages. A read-only home dir, full disk, or restricted $XDG_DATA_HOME/%LOCALAPPDATA% — all plausible for the non-technical-user audience this launcher targets — will surface as a raw traceback instead of a guided message.

  4. opencontractserver/desktop/launcher.py — unbounded log growth. daphne.log/celery-worker.log/celery-beat.log are opened with open(log_path, "a") and never rotated or size-capped. On a long-lived desktop install restarted many times, these can grow without bound.

Test coverage

  1. test_warp_ingest_local_parser.py — the only test that exercises the real warp-ingest dependency is skipUnless-gated, and that dependency isn't installed in the standard Django test image, so it never runs in CI. The PR's core correctness claim (that parse_to_opencontracts really emits PAWLS tokens / OC_PARENT_CHILD hierarchy matching OpenContractDocExport) is only checked against a hand-written fixture dict, not real parser output, in the CI that actually runs. No coverage either for empty/no-text PDFs, genuine multi-page docs, or the OCR routing path having any effect (only that apply_ocr/disable_ocr are forwarded as kwargs).

  2. test_desktop_packaging.py::LauncherPureHelperTests.test_free_port_prefers_stable_default (around line 726) binds a real socket to launcher.DEFAULT_PORT on 127.0.0.1 to test the fallback path. If that port happens to be in use by something else on the CI runner, the bind raises OSError and fails the test for reasons unrelated to the logic under test. Low severity, but a plausible source of flakiness — consider using an ephemeral port (bind(("127.0.0.1", 0))) to derive a "known busy" port instead of a fixed one, or mark it serial/skip-on-OSError.

Minor / low severity

  1. Inconsistent apply_ocr/disable_ocr conflict handling between the two parser variants, untested in either: the REST parser raises DocumentParsingError when both are set; the local parser only logs a warning and forwards the contradictory flags to the library.
  2. paths.py's ensure_private_dir docstring overclaims on Windows — it asserts other local accounts get no access, but os.chmod is a documented no-op on Windows (failures are suppressed), so the actual guarantee there rests on default NTFS ACLs, not this code path. Worth tightening the comment so it doesn't overstate the guarantee.
  3. bootstrap.py's stale-lock detection (_lock_is_stale) treats a lock as still-held if os.kill(pid, 0) succeeds, with no age-based fallback once a PID is present — a crashed install's PID being reused by an unrelated process (rare, but possible) blocks auto-recovery. It degrades gracefully (the exit message tells the user how to delete the lock file manually), so this is a nice-to-have rather than a bug.

What looks solid

  • Migration opencontractserver/annotations/migrations/0074_annotation_raw_text_trigram_index.py: the fix correctly queries pg_available_extensions (not pg_extension) to detect whether pg_trgm can be installed, skips extension/index creation gracefully on the embedded desktop pgserver, and still creates the extension + CREATE INDEX CONCURRENTLY on every deployment that has it (docker-compose/production) — no functional regression there, and nothing downstream assumes the index exists (only icontains is used elsewhere). Good test coverage via TrigramMigrationGuardTests.
  • Migration 0028 in this PR is a no-op message/lint-only change; the underlying idempotent logic is untouched.
  • desktop_bootstrap is idempotent across reruns and doesn't bake in a default/weak password — it comes from OC_DESKTOP_PASSWORD or an interactive prompt, with a clear warning if neither is provided.
  • Settings/launcher security posture is good: secret key persisted via OS keyring (not plaintext), ALLOWED_HOSTS scoped to loopback, DEBUG defaults False, subprocess calls use list-form args (no shell=True), config/spa.py correctly uses safe_join against path traversal, and the SPA catch-all regex in config/urls.py correctly excludes api/, graphql/, admin/, mcp, sse, ws/, static/, media/ by whole path segment.
  • .github/workflows/docker-build-release.yml only adds a new, independent build-frontend-dist job (triggered only on release: published, same as before) — no risk to the existing production image release path, and no desktop-only deps leak into production Docker builds.
  • Docs (README.md, docs/quick_start.md, docs/deployment/desktop_packaging.md) match the actual code/commands, desktop_packaging.md is wired into mkdocs.yml nav, and all four changelog fragments follow the changelog.d/<slug>.<type>.md convention with accurate file references.

Nice work on the usability-driven approach (baseline vs. fixed clean-room tests) — that's a good way to validate this kind of change. The parser hardening (#1/#2) and the CI dependency gap (#5) are the two I'd prioritize before merge; the rest are minor polish.

- WarpIngestLocalParser: max_file_size_mb guard (default 200, matching
  the REST variant - parsing is in-process, so the cap bounds worker
  memory/CPU exposure; parse TIME stays bounded by Celery task limits,
  an in-process alarm would be POSIX-only); apply_ocr+disable_ocr now
  raises a classified non-transient error like the REST sibling instead
  of forwarding contradictory flags; export shape validated with
  isinstance(dict), not just None.
- launcher: one-generation log rotation at spawn (10 MB cap) so
  daphne/worker/beat logs can't grow unbounded across restarts; friendly
  disk-full/read-only message around _ensure_dirs.
- bootstrap: same friendly OSError message around the locked first-run
  install (read-only home, full disk, restricted app-data).
- paths: Windows docstring no longer overclaims (chmod is a no-op there;
  protection rests on default per-user %LOCALAPPDATA% ACLs).
- tests: DEFAULT_PORT-busy runner skips instead of false-failing.
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review: One-step desktop setup (python oc-desktop.py)

Reviewed the full diff (28 files, +3854/-37) plus the actual source in this checkout (which already reflects the PR's merge state). Overall this is an unusually well-engineered PR: extensive inline rationale, careful failure-mode handling (stale locks, TOCTOU port binding, staged/atomic SPA swaps, zip-slip + symlink guards, checksum verification, keyring timeout handling), and thorough test coverage (test_desktop_packaging.py, test_warp_ingest_local_parser.py). Below are the things worth a second look.

Correctness / security

  1. OC_DESKTOP_PASSWORD leaks into the outer bootstrap process's environment for the whole session (medium confidence).
    opencontractserver/desktop/bootstrap.py::maybe_prompt_first_run_password (lines 103-121) sets os.environ["OC_DESKTOP_PASSWORD"] on the current process before re-exec'ing into the venv. _reexec_in_venv (lines 313-343) then spawns the venv launcher as a separate child process via subprocess.Popen (not execv, deliberately, for Windows compatibility) and blocks on child.wait() for the entire app session (until Ctrl+C).

launcher.py::main() (lines 483-484) is careful to scrub the var from both env and os.environ before spawning the long-lived Daphne/worker/beat children, with an explicit comment that a long-lived process's environment block (readable on Linux via its procfs entry) must not hold the password. That same reasoning applies equally to the outer bootstrap.py process (the literal python3 oc-desktop.py the user ran), which also lives until Ctrl+C - but its own os.environ["OC_DESKTOP_PASSWORD"] is never popped. So on a fresh install (the primary target scenario for this PR), the freshly-chosen login password sits in the outer wrapper process's environment (readable by the same OS user or root via the same procfs mechanism the launcher already worries about) for the whole session.

Practical severity is limited (same-uid/root already has equivalent access to the app-data dir, venv, DB, etc.), but it's an inconsistency in a design that otherwise treats this exact leak vector as worth defending against. Fix: pop OC_DESKTOP_PASSWORD from os.environ in bootstrap.main() immediately after copying it into the child's env in _reexec_in_venv, or wherever the parent no longer needs it.

Minor / informational

  1. Migration 0074_annotation_raw_text_trigram_index.py is being edited in place, not superseded. It's already on main and has presumably shipped in 3.0.0b4 pre-releases. The change is low-risk (already-migrated DBs won't re-run it since Django tracks migrations by name, and the net effect on real Postgres is unchanged - same extension, same index), but editing an already-applied migration file is usually flagged in review; worth a one-line note in the PR description confirming this was considered (it may well have been, given how careful the rest of the PR is about migration hazards).

  2. docker-build-release.yml: build-frontend-dist uses gh release upload ... --clobber. Since it's gated on release: published (not a PR-triggered event) with permissions: contents: write scoped to just this job, this is fine - flagging only because --clobber will silently overwrite an existing asset on a re-run/re-publish, which is presumably the intent (idempotent re-attach).

  3. MAX_BUNDLE_BYTES = 500 * 1024 * 1024 - the comment says the real bundle is "~15 MB" and a cap "an order of magnitude past this ceiling is wrong, full stop," but 500 MB is ~33x, not ~10x. Purely a comment/constant-naming mismatch, not a functional issue.

Strengths worth calling out

  • spa_dist.py's download path (staging dir -> checksum -> extraction -> index.html sanity check -> atomic rename with backup-and-restore-on-failure) is a genuinely good pattern for "never destroy a working cache on a failed refresh."
  • paths.ensure_private_dir correctly re-chmods pre-existing directories (Path.mkdir's mode= is a no-op on existing dirs/parents) - a real bug class that's easy to miss.
  • The pg_trgm migration fix is the right shape: probe-and-skip rather than hard-require, with an explicit invariant comment warning future maintainers not to assume the index always exists in the DB.
  • Password handling is otherwise careful: never printed/logged, LOGIN_MIN_LENGTH enforced on both the interactive and env-var paths (closing a real gap where the env-var path used to bypass validation), self-healing for password-less accounts left over from headless first runs.

Test coverage

Good breadth: pure path helpers, zip-slip/symlink guards, checksum verification (including malformed-response handling), trigram migration skip/create paths against a real Postgres, password prompt/self-heal/no-reprompt flows, keyring timeout/fallback behavior, and shutdown/child-termination. I didn't find a test exercising OC_DESKTOP_PASSWORD scrubbing across the bootstrap->launcher process boundary, consistent with finding #1 above going unnoticed.

Nice work overall - this is a substantial usability lift validated by the described clean-room testing, and the code reads like it already went through several rounds of self-review.

The launcher scrubbed OC_DESKTOP_PASSWORD from itself and its children,
but the OUTER wrapper (the literal 'python3 oc-desktop.py' the user ran)
set it via the early prompt and then lived until Ctrl+C in
_reexec_in_venv's wait loop with the password still in its environment
block - the exact procfs leak vector the launcher already defends
against. Pop it right after copying the child env, so the password
crosses each process boundary exactly once. Cross-boundary regression
test added (the coverage gap the reviewer used to spot this). Also
reword the MAX_BUNDLE_BYTES comment (500MB is ~33x the real bundle, not
'an order of magnitude').
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review

Reviewed the full diff (28 files, +3885/-37). This is an impressively thorough piece of packaging work — extensive prose documentation of why each decision was made, careful handling of edge cases (stale locks, partial installs, offline fallbacks), and 887 lines of new tests. Comments below are mostly minor/confirmatory; I didn't find any blocking correctness bugs.

Overview

Adds a one-command desktop launcher (python oc-desktop.py) that self-bootstraps a private venv, starts an embedded Postgres (pgserver), runs migrations + first-run seeding, supervises Daphne/Celery worker/beat with no Redis, serves the pre-built SPA from Daphne, and downloads the SPA bundle from GitHub releases so end users need no Node toolchain. Also fixes two pre-existing migration bugs surfaced by the desktop flow (pg_trgm unconditionally required, a scary but harmless ERROR print on fresh installs).

Strengths

  • Security-conscious throughout: safe_extract_zip blocks path traversal and symlink members; SHA-256 checksum verification on downloaded bundles with a size cap (MAX_BUNDLE_BYTES); no shell=True anywhere — every subprocess call uses list-form argv; password never printed/logged/written to disk, explicitly scrubbed from the long-lived supervisor's os.environ after handing it to the one-shot bootstrap subprocess (launcher.py::main, tested in ReexecPasswordScrubTests); SECRET_KEY persisted via OS keyring rather than a plaintext file; ALLOWED_HOSTS pinned to loopback only.
  • Good handling of concurrency/partial-failure edge cases: cross-process install lock with PID-liveness-based stale reclaim (bootstrap.py::_install_lock), atomic staging-dir swap for SPA downloads so a failed refresh can't destroy a working cached copy, first-run marker only written on clean bootstrap exit so a transient failure retries next launch.
  • Backwards compatibility of the edited migration: 0074_annotation_raw_text_trigram_index.py is an already-applied migration being edited in place (not a new migration). This is generally a smell, but here it's safe in practice: Django tracks migrations as applied-once per DB, and the new DDL (CREATE EXTENSION IF NOT EXISTS / CREATE INDEX CONCURRENTLY IF NOT EXISTS) is idempotent, so already-migrated databases never re-execute this code path at all. Worth calling out explicitly in the PR description for reviewers less familiar with the migration history, but the in-code "INVARIANT" comment already documents the constraint well for future edits.
  • Test coverage is solid for the pure/deterministic logic (paths, spa_dist resolution order, checksum verification, password floor enforcement, migration guard, keyring timeout fallback). launcher.py's top-level orchestration (main()) is coverage-omitted with a documented rationale (setup.cfg), consistent with how manage.py/asgi.py are treated elsewhere.

Issues / suggestions

  1. config/settings/desktop.py — weak password floor for a superuser account. LOGIN_MIN_LENGTH = 8 with no complexity requirement is the only validation for the desktop superuser (Django's AUTH_PASSWORD_VALIDATORS are bypassed for create_superuser). This is explicitly documented as an accepted trade-off given the app binds only to 127.0.0.1/loopback, which is reasonable for a single-user local app — just flagging so it's a deliberate, not overlooked, decision if this build is ever exposed beyond loopback (e.g., a future reverse-proxy/remote-access mode).

  2. spa_dist.safe_extract_zip has no decompression-bomb guard. Path traversal and symlinks are covered, but there's no check on total uncompressed size vs. the compressed MAX_BUNDLE_BYTES cap — a pathological zip could have a large expansion ratio. Low severity here since the bundle is fetched from this repo's own GitHub releases and is SHA-256 verified when the sidecar is published, but worth a one-line comment (or an info.file_size sum check) as defense-in-depth given the module explicitly aims for that posture elsewhere.

  3. Older releases without a published .sha256 sidecar fall back to TLS-only trust (download_spa, _release_asset_urls). This is documented behavior, not a bug, but means integrity verification is inconsistent across releases until every supported release has the sidecar. Consider requiring the checksum once the workflow has been live for a full release cycle, rather than treating "no sidecar" as a permanent fallback path.

  4. corpuses/0028_fix_grant_permission_to_corpus_creators.py f-string fix (f"\nResults:""\nResults:") is an unrelated drive-by fix bundled into this PR. Harmless and correct, but slightly outside the PR's stated scope — worth a one-line mention in the PR description (the changelog fragment already covers it well, so this is a very minor nit).

  5. _free_port() TOCTOU window between releasing the probe socket and Daphne binding it is explicitly acknowledged in the docstring as accepted risk for a single-user loopback app with a Phase-1 follow-up planned. Agreed this is fine to defer.

  6. GitHub Actions build-frontend-dist job: confirmed the workflow only triggers on: release: published, so github.event.release.tag_name is always populated when this job runs — no issue, just noting I checked since the added job's diff hunk alone doesn't show the trigger block.

Test coverage

Good breadth for stdlib-only/pure logic. One gap: _install_dependencies, _reexec_in_venv's actual subprocess spawning, and launcher.main()'s end-to-end orchestration are necessarily excluded from automated coverage (network/subprocess/process-lifecycle heavy) — the PR description mentions two manual clean-room usability tests were run instead, which is a sensible substitute given the nature of this code, but it's worth keeping those manual scripts under docs/test_scripts/ per the project's own convention for future regression checks if not already done.

Security

No blocking issues found. Zip extraction, subprocess invocation, secret handling, and settings isolation (forcing USE_AUTH0=False/STORAGE_BACKEND=LOCAL before base.py import) are all handled carefully, with tests (DesktopSettingsImportTests) specifically guarding against a hostile inherited environment.

- safe_extract_zip: cap the declared uncompressed total at
  MAX_BUNDLE_BYTES (same ceiling as the download), closing the one
  defense-in-depth gap reviewers found in the extraction pipeline; test
  included.
- docs/test_scripts/desktop_one_step_setup.md: the clean-room manual
  procedure (first launch, login verification, teardown, warm relaunch)
  per the repo's manual-test-script convention, for future regression
  checks of the orchestration code that unit tests can't cover.
@codecov

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.66454% with 11 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
opencontractserver/desktop/bootstrap.py 97.58% 3 Missing ⚠️
opencontractserver/desktop/spa_dist.py 97.27% 3 Missing ⚠️
...documents/management/commands/desktop_bootstrap.py 96.47% 3 Missing ⚠️
...erver/pipeline/parsers/warp_ingest_local_parser.py 97.14% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

claude added 2 commits July 8, 2026 00:31
codecov/patch failed at 54% because setup.cfg's coverage omit makes
launcher.py carry NO line data in coverage.xml - and Codecov counts
no-data diff lines as MISSES (232 of the 305 missing lines). The
effective mechanism is .codecov.yml's ignore list (existing precedent:
data_extract_tasks.py); added launcher.py there with the rationale,
validated instead by docs/test_scripts/desktop_one_step_setup.md.

The remaining gap was real and is now tested (desktop modules 83%->98%):
- install-lock acquire/release/contention/stale-PID-reclaim/agefallback
- venv helpers (_venv_is_current fingerprint logic, marker locations)
- prompt mismatch-retry path
- parser guards (size cap, OCR-flag conflict, non-dict export)
- spa_dist branches (certifi context, symlink member rejection, no-index,
  nothing-available)
- spa_fallback missing-index 404
- full call_command invocation of desktop_bootstrap (argument parsing +
  nltk seeding with mocked download)
- config/urls.py desktop wiring block marked no-cover (import-time only,
  inert under test settings; the registered view is fully tested)
@claude

claude Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review: One-step desktop setup (python3 oc-desktop.py)

This is a large, well-scoped PR (30 files, ~4.2k additions) that replaces the multi-step desktop install with a single self-bootstrapping entry point. Overall engineering quality is high: the zip-slip guard, SHA-256 verification-before-extraction, TLS/certifi handling, port fallback, and the migration's pg_trgm availability probe are all correctly implemented. I reviewed the diff in four parts (bootstrap/password handling, spa_dist/launcher, migrations/CI/parser rename, tests/docs) and verified the findings below directly against the checked-out source.

Security

  1. Password sits in the venv/pip install subprocess environment during the multi-minute first-run install -- opencontractserver/desktop/bootstrap.py. main() calls maybe_prompt_first_run_password() (line 359, sets os.environ[OC_DESKTOP_PASSWORD]) before _install_dependencies runs subprocess.run([sys.executable, -m, venv, ...]) (line 264) and the pip install -r requirements/desktop.txt call (line 286) -- neither passes an explicit env=, so both inherit the full parent environment, password included, for as long as the install takes. _reexec_in_venv (line 329) does scrub OC_DESKTOP_PASSWORD from the wrapper's own environment, but only after the install subprocesses have already run with it present. On Linux this is readable by other processes owned by the same user (or root) for that whole window via the standard process-environment inspection mechanism. Same class of issue in launcher.py's _manage(env, migrate, --noinput) (~line 476) -- the password-bearing env reaches the migrate subprocess before the scrub at lines 483-484. Fix: build an explicit env dict (copy + pop OC_DESKTOP_PASSWORD) for any subprocess that does not need the password.

  2. Zip-bomb size cap is not actually enforced during extraction -- opencontractserver/desktop/spa_dist.py:139-169. safe_extract_zip sums ZipInfo.file_size from archive.infolist() before calling extractall(), but file_size is attacker-controlled central-directory metadata -- zipfile's decompression loop is bounded by compress_size (bytes read), not the declared uncompressed size, and the CRC/size check only fires after the bytes are already written to disk. A small, highly compressible member can declare file_size=1 while decompressing to gigabytes. This matters because when a release has no .sha256 sidecar, the code explicitly falls back to TLS-only trust (~line 234), making this the last remaining guard -- and it does not hold. Fix: stream each member through archive.open(info) with a bounded read loop instead of trusting info.file_size.

Correctness

  1. Re-prompt invariant can be violated -- bootstrap.py::maybe_prompt_first_run_password (lines 103-121) gates purely on OC_DESKTOP_PASSWORD being set or paths.first_run_marker().exists(). The marker is only written by launcher.py::_first_run_bootstrap after the entire first-run sequence (user + nltk + pipeline settings) succeeds. If user creation succeeds but a later step transiently fails, the marker never gets written, so the password prompt reappears on every subsequent launch -- even though the account already has a usable password and desktop_bootstrap.py::_seed_user will silently discard the newly entered one. Not a security hole, but it contradicts the PR description's stated invariant (never re-prompts once a usable password exists). Consider gating the prompt on a dedicated password-already-set signal instead of full first-run completion.

  2. Non-atomic staged-swap in download_spa -- spa_dist.py:259-267 does spa_root.rename(backup) then staging.rename(spa_root) as two separate syscalls. A hard kill between them leaves spa/ missing with a good copy inert at spa.old and a verified copy at spa.new; the next launch sees no cache and silently re-downloads rather than recovering. Low-likelihood window, but worth a one-line self-heal check at the top of ensure_spa (promote spa.new if spa/ is absent and spa.new/index.html exists).

  3. Install lock has no age ceiling on POSIX -- bootstrap.py::_lock_is_stale (lines 193-210) trusts os.kill(pid, 0) liveness with no fallback age check on POSIX (only used on the non-POSIX path). PID reuse after a crash could make the lock look permanently held, requiring manual deletion.

  4. Unhandled FileNotFoundError if a requirements file is missing -- bootstrap.py::main, _venv_is_current is called outside the try/except OSError that wraps the install path, but internally reads requirements/desktop.txt and base.txt. A missing file surfaces a raw traceback instead of the module's otherwise consistently actionable error messages.

Migration safety (0074 / 0028 in-place edits)

Verified this is safe as described: Django tracks applied migrations by (app, name) not content, there is no makemigrations --check / frozen-migration CI gate in this repo, and the SeparateDatabaseAndState operations are unchanged so makemigrations will not flag drift either. The pg_trgm availability probe correctly gates on connection.vendor first, queries the always-readable pg_available_extensions catalog, and forward/reverse are symmetric (DROP INDEX CONCURRENTLY IF EXISTS can't crash if the index was never created). 0028's edit is print-only (alarm to warning, gated on Corpus.objects.exists()), no functional change. Both are well covered by test_desktop_packaging.py. One forward-looking note already flagged by the migration's own docstring: state always claims the index exists even on desktop where it doesn't, so any future migration touching this index via state-derived SQL must stay IF EXISTS-tolerant.

Parser rename / CI workflow

warp_ingest_local_parser.py is consistent with its REST sibling (same mutual-exclusion error, same max_file_size_mb guard) and has solid test coverage. The new build-frontend-dist CI job is correctly ordered (build -> zip -> sha256 -> upload under bash -eo pipefail, so a failed build can't publish a stale/corrupt bundle).

Test coverage / docs

  • docs/deployment/desktop_packaging.md:97-100 still says there is no cross-process lock around the environment setup yet... two concurrent installs can corrupt it -- this is stale; the lock is implemented (_install_lock/_lock_is_stale) and covered by InstallLockTests. Should be corrected or removed.
  • The re-exec force-kill escape hatch (third Ctrl+C -> child.kill(), bootstrap.py:344-346) and the SPA staging/backup/rename-swap sequence in download_spa (the exact logic the desktop-usability-round2.fixed.md changelog credits with failed refresh can no longer destroy a working cache) are both currently exercised only through mocks that short-circuit before the real logic runs. Both are feasibly testable (a fake long-running subprocess for the former, mocked urlopen for the latter, per existing patterns elsewhere in the file).
  • changelog.d/desktop-usability-round2.fixed.md references a MIN_PASSWORD_LENGTH floor; the actual constant is LOGIN_MIN_LENGTH (bootstrap.py:46, deliberately not MIN_PASSWORD_* per a CodeQL clear-text-logging heuristic comment in the code) -- small doc/code mismatch worth fixing.
  • Minor: spa_dist.py::_fetch_json has no size cap on the GitHub API JSON response, inconsistent with the module's otherwise careful capping of the checksum sidecar (4KB) and bundle download.

Not an issue (verified, flagging only because it looked odd at first)

opencontractserver/corpuses/migrations/0028_fix_grant_permission_to_corpus_creators.py initially looked like scope creep but is also an in-place edit of an already-landed migration (print statements only, gated to avoid alarming desktop first-run testers) -- legitimately in scope.


Nice work overall -- the security-sensitive paths (zip-slip, checksum-before-extract, TLS handling, password floor/confirm/EOF-safety, private-dir permissioning) are handled carefully and are genuinely tested. The two security findings above (transient password-in-env during install, and the zip-bomb cap not holding on the TLS-only-trust path) are the ones I'd prioritize before merge; the rest are correctness/UX polish.

… guard

Two reviewer-verified security gaps and four correctness items:

- Password window: the venv/pip install subprocesses (multi-minute) and
  the launcher's migrate subprocess inherited OC_DESKTOP_PASSWORD via
  the environment; both now receive explicitly scrubbed env copies -
  only desktop_bootstrap ever sees the password.
- Zip-bomb guard actually holds now: the previous check summed the
  archive's DECLARED file_size (attacker-controlled central-directory
  metadata, not enforced by zipfile's decompression loop). Extraction
  now streams each member through archive.open() with a running byte
  budget against MAX_BUNDLE_BYTES - the real last line of defense on
  the TLS-only-trust path (releases without a .sha256 sidecar).
- Re-prompt invariant fixed: a retried first run (marker unwritten
  because a LATER bootstrap step failed) re-prompted for a password the
  account already had and silently discarded the answer. desktop_
  bootstrap now records a secret-free .password-set marker whenever the
  account has a usable password; the early prompt gates on it.
- Interrupted-swap self-heal: a hard kill between the two swap renames
  left spa/ absent with good copies stranded at spa.new/spa.old;
  ensure_spa now promotes them (verified staging preferred).
- Install-lock age ceiling on POSIX (PID reuse after a crash no longer
  wedges the lock); _venv_is_current treats unreadable requirements
  files as stale instead of raising a raw traceback; _fetch_json read
  capped like every other network path.
- Docs: stale 'no cross-process lock yet' paragraph replaced (the lock
  exists and is tested); changelog fragment updated to the actual
  LOGIN_MIN_LENGTH constant name.

Tests for all of it (desktop modules at 98%, suite green); mypy clean.
@claude

claude Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review: Single-user desktop packaging (Phase 0)

This is a large PR (about 4.4k lines) that adds a one-command desktop launcher (python oc-desktop.py). I read through the diff plus the checked-out working tree for the security-sensitive modules (opencontractserver/desktop/*, config/settings/desktop.py, config/spa.py, the two edited migrations, desktop_bootstrap.py, warp_ingest_local_parser.py) and the roughly 1200-line test suite.

Overall impression: Code quality is high and clearly the product of multiple adversarial review passes already (the changelog fragments reference a clean-room usability test and a second round of adversarial PR reviews, and the code comments back that up). Highlights:

  • spa_dist.py::safe_extract_zip rejects path traversal (post-join is_relative_to check), symlink zip members, and guards decompression bombs by counting actual decompressed bytes streamed rather than the attacker-controlled file_size in the central directory. download_spa stages into a sibling dir and only swaps it in after checksum + extraction + index.html checks pass, with a .old backup restored on a failed rename, so a bad refresh cannot destroy a working cached copy; _recover_interrupted_swap self-heals a hard kill mid-swap.
  • Secret handling (launcher.py::_stable_secret_key, bootstrap.py): SECRET_KEY is persisted via the OS keyring with a hard timeout (a locked Linux keyring can block on an interactive unlock prompt rather than raising), correctly falling back to an ephemeral key rather than hanging the launcher. OC_DESKTOP_PASSWORD is scrubbed from every long-lived child env and from the launcher process own os.environ after first-run bootstrap (it would otherwise stay readable from the process environment for the whole session) - a genuinely careful detail.
  • paths.py::ensure_private_dir correctly recognizes that Path.mkdir(mode=..., exist_ok=True) silently skips the mode on pre-existing dirs/parents, so it explicitly chmods every level under the app-data root to 0o700 rather than trusting mkdir mode=.
  • bootstrap.py::_install_lock uses an atomic O_CREAT|O_EXCL cross-process lock for the first-run venv/pip install, with PID-liveness-based stale-lock reclamation on POSIX and an age ceiling to guard against PID reuse after a crash.
  • Migration 0074 (trigram index) probes pg_available_extensions and skips gracefully when pg_trgm is not installed (the embedded pgserver build has no contrib extensions) instead of hard-failing every desktop install mid-migrate; the reverse operation no longer does an unconditional DROP EXTENSION that could fail when other objects depend on it. Good fix.
  • Test coverage is unusually thorough for infra code: venv fingerprinting, install-lock stale/live-holder cases, zip-bomb/traversal/symlink rejection, checksum verification (including malformed responses), password floor enforcement on both the interactive and env-var paths, keyring timeout/failure fallback, swap-recovery after interrupted downloads.

Minor notes (nothing blocking):

  1. .github/workflows/docker-build-release.yml - the new build-frontend-dist job interpolates github.event.release.tag_name directly into a run: shell step (gh release upload "TAG" ...) rather than passing it through env: first. This is the classic GitHub Actions script-injection pattern (an untrusted expression is substituted into the script before the shell parses it). Risk is low here since the workflow only triggers on release: published, which requires push/maintainer access to create - but since the job already threads GH_TOKEN through env:, it could also pass the tag name through env: and reference it as a shell variable for defense-in-depth and consistency with the rest of the job.

  2. Migrations 0074 and 0028 are edits to already-existing migration files, not new migrations. This looks intentional and is well documented in the changelog fragments, and it is safe for already-applied databases: real deployments have pg_trgm available, so the old 0074 behavior and the new conditional behavior converge to the same DB state, and the 0028 change is a print-vs-warn cosmetic fix. Flagging only so it is a deliberate, discussed choice rather than an oversight, since editing an already-shipped migration is usually worth a second pair of eyes even when it is provably backward compatible.

  3. Known, explicitly documented limitation: real-time notification toasts do not fire under the desktop profile because InMemoryChannelLayer buffers are event-loop-bound and the notification signal / agent_tasks.py emit group_send from a different loop than Daphne uses. This is called out in the settings file, the docs, and the changelog as a Phase-1 follow-up (Postgres LISTEN/NOTIFY), so it is a known gap rather than a bug - confirming it is not silently swallowed anywhere in the diff.

  4. config/urls.py SPA catch-all regex (excluding api/graphql/admin/mcp/sse/ws/static/media) is registered last and only when OC_DESKTOP_SPA_ROOT is set, so it should not shadow existing routes - I checked the exclusion list against every route prefix actually mounted in that file and found no gap. No issue found; noting it because catch-all routes are an easy place to introduce a routing bypass.

  5. Nit: _free_port() in launcher.py has a documented TOCTOU window between releasing the probe socket and Daphne binding it. Already called out in the docstring as an accepted risk for a single-user loopback app, with a Phase-1 bind-retry-loop follow-up planned - reasonable to defer.

Security summary: No exploitable issues found in the security-sensitive surfaces (zip extraction, subprocess env handling, secret storage, file permissions, path traversal in config/spa.py via safe_join, the GitHub release fetch which is capped/timeout-bounded and github.com-only). Item 1 above is the only actual finding and it is low severity given the trust boundary.

Test coverage: comprehensive for the new pure-logic modules. The one deliberate coverage gap (opencontractserver/desktop/launcher.py, excluded via .codecov.yml and setup.cfg) is reasonable since it is pure process-orchestration/IO, and it is backed by the manual end-to-end procedure in docs/test_scripts/desktop_one_step_setup.md, which follows the CLAUDE.md manual-test-script convention.

Overall this is a thoughtfully hardened piece of infrastructure with its trade-offs called out inline rather than hidden.

Defense-in-depth against the Actions script-injection pattern; low risk
on a release-published trigger but free to avoid (reviewer nit).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants