Skip to content

fix: follow-up documents, launcher hardening, dependency advisories - #331

Open
dovvnloading wants to merge 7 commits into
mainfrom
fix/combined-bug-fixes
Open

dovvnloading wants to merge 7 commits into
mainfrom
fix/combined-bug-fixes

Conversation

@dovvnloading

Copy link
Copy Markdown
Owner

Three groups of verified fixes in one pull request, at the owner's request: one wait and one merge, instead of one pull request per group. It supersedes #329 and #330 and adds the dependency advisory fixes. Each group was first pushed on its own branch, and each passed the full pre-push hook there (all 11 checks); the combined branch runs it again.

Group Commits Plan items
A. Follow-up questions keep the previous message's documents 2 BE-34, BE-02
B. Launcher hardening 3 LP-12, LP-10, LP-02
C. Dependency advisories 2 Dependabot: urllib3 (high), undici (1 high, 2 moderate, 3 low)

A. Follow-up documents (from #329)

Problem

  • The follow-up bug (BE-34). Ask "summarise this" with a document attached, then "now list its section headings". The follow-up reached the model with no document text at all, so it made up the answer. Only attachment metadata is stored with a message, and fix(chat): budget context from measured token counts, keep history contiguous, replace streamed answers with their translation #306 only names earlier attachments in the history.
  • Wasted reads (BE-02 remainder). A new turn read every column of every message, and JSON-decoded each reply's reasoning, sources and stats, only to drop them. A fork read and decoded the whole source chat to check one message id.

Root cause

  • Nothing re-resolved the previous turn's documents for a turn that attaches none.
  • The repository had no partial read, only get_chat (everything) and get_chat_overview (no messages).

Change

Two commits.

  1. perf(persistence): read only the message fields a caller uses.
    • DatabaseManager.load_messages(thread_id, columns=...) selects only the named fields from a fixed whitelist; a name outside it is a ValueError and never reaches SQL. It decodes them as load_chat does, in one read transaction, and returns None for an unknown chat.
    • chat_exists is a SELECT 1.
    • Both repositories expose get_messages and chat_exists, covered by the shared contract suite (fix(persistence): harden chat and memory storage #319).
    • A new turn now reads five fields (HISTORY_MESSAGE_FIELDS); the fork check reads ids; rename asks chat_exists.
    • load_chat shares the row decoding, so the two reads cannot drift.
  2. fix(chat): give a follow-up the documents of the message before it.
    • When a turn attaches nothing, the documents on the user's most recent message are resolved again through the same owner and integrity checks, and sent under their own heading, marked as from the previous message.
    • They go through the attachment budget that scales with the context window.
    • Images stay with their own turn. Only the immediately previous message lends documents, and a turn with its own attachments gets only those.
    • A document deleted or expired since is left out; only a count is logged, never a name or text. The turn proceeds.
    • Nothing is persisted on the follow-up, and regenerating its reply brings the same documents back.
    • The new turn's one transcript read moves from prepare() to admission, so the previous turn's attachments are known before resolution. The user turn is still appended with expected_revision set to the admitted revision. Since fix(persistence): harden chat and memory storage #319 any write moves the revision, so a write landing after the read fails the append, as before.

Not included: the third commit of the original branch (a sticky per-thread code contract, BE-27). It touches the code-execution prompt and needs its own review.

Compatibility and rollback

  • No schema or API change.
  • ChatRepository gains two methods. The only implementations are the SQLite adapter and the in-memory double, both updated.
  • Each commit reverts on its own; reverting the second alone restores the old follow-up behaviour.

Checks

Windows, Python 3.14 unless stated.

  • Focused files: python -m pytest -q tests/test_chat_attachments.py tests/test_prompt_shape.py tests/test_event_loop_hygiene.py tests/test_chat_repository_contract.py tests/test_chat_correctness.py tests/test_generation_harness_controls.py gives 284 passed, 19 subtests.
  • Python 3.12 (py -3.12) on the four changed test files: 195 passed.
  • Against the old code:
    • test_chat_attachments.py and test_prompt_shape.py copied onto an export of origin/main (2972c7a) give 10 failed, 78 passed. The 10 are the new follow-up tests, including deleted/expired documents, images not resent, only the previous message, and the budget at two window sizes.
    • test_event_loop_hygiene.py and test_chat_repository_contract.py cannot be collected there, because they use the new repository methods.
  • Pre-push hook (scripts/check.ps1 quick tier): all 11 checks passed in 416.5 s (backend 255.7 s, vitest 91.8 s).

Security, concurrency, data loss

  • Earlier documents are resolved with the requester's owner and the attachment service's integrity checks, exactly like new ones.
  • Partial-read column names come from a fixed whitelist, never from input.
  • Logging: prompt and document text never reach logs.
  • Revision guard: the append is guarded by the admitted revision. A chat deleted between admission and the read is refused as stale_revision.

Limits

  • Not run against a live model; the tests use the fake runtime and real attachment storage.
  • A document comes back only for the turn right after it. A question two turns later does not get it back automatically.

B. Launcher hardening (from #330)

Problem

Three launcher defects:

  • LP-12.
    • What: the WebView2 signature check (the gate before the bundled installer runs), the private-folder ACL step and the dev-server tree kill started powershell.exe, icacls and taskkill by bare name.
    • Effect: whichever copy came first on a user-modified PATH ran instead of Windows' own.
  • LP-10.
    • What: InstanceLock.acquire() returned None for every failure, exactly as for "another Cortex holds the lock". That included an icacls error, a lock file that could not be opened, and a secret or record that could not be written.
    • Effect: the launch then looked for a running instance that did not exist and exited 2. Several failing exits also printed to a console a windowed build does not have and wrote no startup diagnostic, while the dialog said the log "could not be written".
  • LP-02 (remainder).
    • What: readLauncherCredentials() still honoured ?bootstrap= and ?handoff= in the query string.
    • Context: the launcher has only ever sent them in the fragment (main.py _desktop_url), which never reaches a server, request line or log. Only Playwright specs and screenshot scripts used the query form.

Change

One commit each.

  1. core/win_paths.system32(name) resolves under the directory GetSystemDirectoryW reports, with %SystemRoot% / %windir% only as a fallback. PowerShell is looked up in WindowsPowerShell\v1.0. It falls back to the bare name only when that absolute path does not exist. It is used at all three call sites.
  2. acquire() returns None only for a contended lock. That means EACCES/EAGAIN/EWOULDBLOCK/EDEADLK from the lock call or from writing the locked byte.
    • Anything else raises InstanceLockError(reason). Its message is fixed text plus the OS error's class name, never a path or secret.
    • _run_web records it under the stage "instance lock", and the dialog names the data-folder remedy.
    • The lock-file size is read with fstat (reading a locked byte fails), and normalising writes go straight to the descriptor.
    • Every non-zero exit goes through _fail(stage, message, code), which writes the diagnostic. The dialog mentions an unwritable log only when a write was attempted.
  3. The frontend reads both credentials from the fragment only. The query is still scrubbed from the address bar. The e2e specs and screenshot scripts open /#bootstrap=... as the launcher does.

Not included from the original branch, because they add features rather than fix defects: remembering the window's size and position, and asking before closing during a reply.

Compatibility and rollback

  • No API or data change.
  • A URL with credentials in the query string no longer signs in. Only test tooling ever built one, and it is updated here.
  • Each commit reverts on its own.

Checks

Windows, Python 3.14 unless stated.

  • Focused tests: python -m pytest -q tests/test_launcher.py tests/test_win_paths.py gives 222 passed. Python 3.12 (py -3.12): 222 passed.
  • Frontend: npx vitest run src/app/App.test.tsx gives 39 passed.
  • Against the old code: the new tests import the new modules, so they cannot be collected on origin/main. Instead, the old behaviour was put back inside the new code: _contended() made to always return True (every failure treated as contention), and system32() made to skip the system directory. Result: 6 failed, 216 passed, including test_instance_lock_distinguishes_contention_from_setup_failure, the PowerShell, icacls and taskkill path tests, and the WebView2 check.
  • Pre-push hook (scripts/check.ps1 quick tier): all 11 checks passed in 637 s (backend 450.8 s, vitest 99 s).
  • Playwright specs with the changed URLs: not run locally; the CI "Browser tests" job runs them.

Security

  • LP-12 removes a PATH dependency from the installer's signature gate.
  • LP-02 removes the one way a credential could appear in a URL that is sent to the server.
  • The lock error messages carry no paths or secrets.

Limits

  • Not exercised in the real packaged WebView2 window.
  • The contention errno set covers msvcrt locking and flock as documented; an unusual filesystem could report contention differently and would now show as a setup failure, not as "already running".

C. Dependency advisories

Problem

Dependabot reports open advisories on two packages:

  • urllib3 2.7.0, high severity, in both Python locks. First patched in 2.8.0.
  • undici 7.29.0, pulled in by jsdom (test tooling only): one high, two moderate and three low. First patched in 7.29.1.

Change

  • urllib3: both locks regenerated with the command in each lock's header plus --upgrade-package urllib3. Only urllib3 moves, to 2.8.0, with its new hashes.
  • undici: npm update undici takes it to 7.30.1, inside jsdom's range.
  • Restored lock entry: npm 11 also dropped the optional @emnapi/runtime entry that CI's npm 10 needs. It is restored unchanged, so undici is the only package that moves in package-lock.json.

Checks

  • The lock diffs are 6 lines per Python lock and 6 lines in package-lock.json, version and integrity only.
  • npm ci with the new lock, then npx vitest run src/api: 48 passed.
  • Pre-push hook on the separate branch: all 11 checks passed in 597.6 s.
  • The local interpreter still has urllib3 2.7.0 installed, so local backend runs did not exercise 2.8.0. CI installs from the lock and does.

Combined branch checks

  • Pre-push hook (scripts/check.ps1 quick tier) on the combined branch: all 11 checks passed in 504.4 s.
  • The seven commits cherry-picked onto origin/main (1ae4975) without conflicts.

Plan item: BE-34, BE-02, LP-12, LP-10, LP-02 (remainder), plus the urllib3 and undici advisories

🤖 Generated with Claude Code

dovvnloading and others added 7 commits October 17, 2026 09:59
A new turn read the whole transcript to build the model's history, which
copied and JSON-decoded every reply's reasoning, sources and stats only to
drop them; a fork read and decoded the whole source chat just to check
that one message id was in it.

DatabaseManager.load_messages(thread_id, columns=...) selects only the
named fields (a fixed whitelist, never passed to SQL as given), decodes
them the way load_chat does, and returns None for an unknown chat.
chat_exists is a SELECT 1. Both repositories expose them as get_messages
and chat_exists, covered by the shared contract tests. The new-turn
history read now asks for the five fields history is built from, the
fork check reads only ids, and rename asks chat_exists. load_chat shares
the row decoding so the two reads cannot drift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… PATH

The WebView2 signature check (the gate before the bundled installer runs),
the private-folder ACL and the dev-server tree kill started their tools by
bare name, so whichever powershell.exe, icacls or taskkill came first on a
user-modified PATH ran instead. core/win_paths.system32() resolves them under
the directory GetSystemDirectoryW reports (%SystemRoot% / %windir% only as a
fallback), with PowerShell's WindowsPowerShell\v1.0 subfolder, and falls back
to the bare name only when that absolute path does not exist.

Plan item: LP-12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…log every failed exit

InstanceLock.acquire() returned None for every failure -- an icacls error,
a lock file that could not be opened, a secret or record that could not be
written -- exactly as it did for "another Cortex holds the lock". The launch
then went looking for a running instance that did not exist and exited 2.
acquire() now returns None only for a contended lock (EACCES/EAGAIN/
EWOULDBLOCK/EDEADLK from the lock call, or from writing the locked byte) and
raises InstanceLockError(reason) for anything else; _run_web records it under
the stage "instance lock" and the dialog names the --data-dir remedy. The
lock-file size is read with fstat instead of reading the byte, which fails
under a held lock, and normalising writes go straight to the descriptor.

Several failing exits (no record behind a held lock, a dead holder's lock that
never frees, an unreadable handoff secret, a headless backend that stopped on
its own) printed to a console a windowed build does not have and wrote no
startup diagnostic, while the dialog said the log "could not be written".
Every non-zero exit now goes through _fail(stage, message, code), which writes
the diagnostic and prints; the dialog only mentions an unwritable log when a
write was actually attempted.

Plan item: LP-10

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dependabot reports a high-severity advisory against urllib3 2.7.0 in
both locks, first patched in 2.8.0. Regenerated with the command in each
lock's header plus --upgrade-package urllib3; nothing else moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dependabot reports one high, two moderate and three low advisories
against undici 7.29.0 (pulled in by jsdom, test tooling only), first
patched in 7.29.1. `npm update undici` takes it to 7.30.1, inside
jsdom's range. npm 11 also dropped the optional @emnapi/runtime entry
that CI's npm 10 needs; it is restored unchanged, so undici is the only
package that moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
readLauncherCredentials() still honoured ?bootstrap= and ?handoff= in the
query string, although the launcher has only ever sent them in the fragment
(main.py _desktop_url), which never reaches a server, a request line or a
log. Nothing in production used the query branches -- only the Playwright
specs and the screenshot scripts did. The query is no longer read (it is
still scrubbed from the address bar), and those specs and scripts now open
/#bootstrap=... like the launcher does.

Plan item: LP-02 (remainder)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Summarise this" with a document, then "now list its section headings":
only attachment metadata is stored with a message, so the follow-up
reached the model with no document text at all, and it invented the
answer.

When a turn attaches nothing, the documents attached to the user's most
recent message are resolved again, through the same owner and integrity
checks, and sent marked as coming from the previous message, under their
own heading. They go through the attachment budget that scales with the
context window like any document. Images stay with their own turn, only
the immediately previous message lends documents, and a turn that
attaches its own files gets only those. A document deleted or expired
since is left out (a count is logged, never a name or text) and the turn
proceeds. Nothing is persisted on the follow-up; regenerating its reply
brings the same documents back.

To have the previous turn's attachments before resolution, the new
turn's one partial transcript read moves from prepare() to admission.
The user turn is still appended guarded by the admitted revision, so a
write landing after that read fails the append as before. A brand-new
chat no longer makes a read at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

1 participant