Skip to content

fix(unifia): harden security and restore E2E coverage - #29

Merged
Rwanbt merged 11 commits into
feat/unifia-rebrand-completefrom
fix/unifia-security-and-e2e
Aug 11, 2026
Merged

Rwanbt merged 11 commits into
feat/unifia-rebrand-completefrom
fix/unifia-security-and-e2e

Conversation

@Rwanbt

@Rwanbt Rwanbt commented Aug 11, 2026 •

Copy link
Copy Markdown
Owner

Issue for this PR

Closes #30

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Closes the security and E2E production-readiness gates for the Unifia rebrand branch.

  • Replaces quadratic trailing-slash regex handling on user-controlled URLs with a shared linear implementation.
  • Removes the worktree marker TOCTOU by using exclusive file creation.
  • Persists GitHub auth through a unique, exclusively-created temporary file with restrictive permissions.
  • Keeps LSP enabled by default in E2E and retains the explicit opt-out flag.
  • Aligns two E2E assertions with the current UI and persisted theme key.
  • Disposes LSP protocol handlers before stopping the child process and stress-tests shutdown with in-flight requests.

How did you verify your code works?

  • packages/app: typecheck and unit tests passed.
  • packages/unifia: typecheck and unit tests passed.
  • packages/mobile: typecheck passed.
  • Targeted E2E with LSP enabled: 2 passed.
  • LSP shutdown stress test: 40 passed across 10 reruns.
  • Biome and git diff --check passed.
  • GitHub CI, CodeQL, and E2E are the final remote gates.

Screenshots / recordings

Not a visual UI change.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

MM2-B02-WORKER added 3 commits August 11, 2026 16:03
CodeQL js/polynomial-redos (#278) reports `replace(/\/+$/, "")` in
remote-connect.tsx. The anchored `+` makes the engine retry from every
offset when the slash run never reaches `$`, so the cost is quadratic in
a length the user chooses by pasting.

Measured on Bun 1.3.14 with `"https://h/" + "/".repeat(n) + "x"`:

  n         regex       backward walk
  10 000    222 ms      0.004 ms
  50 000    2 914 ms    0.004 ms
  100 000   8 001 ms    0.004 ms

8 s on the main thread is a frozen WebView, so this is a real defect and
not only a rule firing.

The alert named one site; the pattern is the class. Every place that
normalizes a URL the user typed carries it:

  - app  context/server.tsx    normalizeServerUrl, serverName
  - app  connect/remote-connect.tsx
  - mobile entry.tsx           a verbatim copy of normalizeServerUrl
  - mobile notifications.ts    NotificationBridge's constructor

`trimTrailingSlashes` now lives in packages/app/src/utils/url.ts and is
exported from @unifia/app, and mobile's inline duplicate of
normalizeServerUrl calls the shared function instead of repeating its two
lines — the duplicate is how the fix would have had to be found twice.

The remaining `replace(/\/+$/, "")` occurrences normalize filesystem
paths and config keys (file/index.ts, worker-runtime.ts, autocomplete,
dialog-select-directory). They are not reachable from a pasted string of
attacker-chosen length and are left alone.

Test: packages/app/src/utils/url.test.ts asserts equivalence with the
regex it replaces and bounds the 100 000-slash case to 100 ms.
Two CodeQL findings, one root shape: a write that assumes it is creating
the file it is about to harden, without saying so to the kernel.

js/file-system-race (#334) — team/worktree-manager.ts
`existsSync(marker)` then `writeFileSync(marker)` is a TOCTOU, and this
module exists precisely so several workers can act on worktrees at once:
two of them both observe "absent" and both write, and the second silently
overwrites a marker the first is already being read for. Replaced by the
`wx` flag (O_CREAT|O_EXCL) so the kernel picks the winner in one call.
EEXIST is the idempotent path and the only error swallowed.

js/http-to-file-access (#333) — github/auth.ts
The taint the rule follows — a GitHub HTTP response reaching a file write
— is not exploitable: the destination is the module constant on line 22,
never the network data, and the content round-trips through JSON.parse.
Reported as such rather than closed; that call is Erwan's.

But the flagged line held a real defect. `mode` on writeFile only applies
when writeFile creates the file, and the temp path was the fixed
`github-auth.json.tmp`. Left behind by a crash — or planted by anything
that can write the data dir — it was reused as-is and the OAuth access
token was persisted with whatever permissions it already had. The write
now uses a unique name and `wx`, so `mode: 0o600` is always the mode of
the file that receives the token.

Tests
- worktree-manager.test.ts: creation, no-clobber, and a POSIX-only case
  proving `wx` refuses to write through a planted symlink. That last one
  is skipped on Windows on measurement, not on principle — CreateFile
  with CREATE_NEW follows the reparse point and creates the target there,
  so the flag buys nothing and the assertion would fail for an unrelated
  reason.
- github/auth.test.ts: a stale 0644 `.tmp` is left untouched and the
  persisted session is 0600. Verified decisive — reverting only auth.ts
  fails it with ENOENT on the stale file (the old code truncated and
  renamed it away).
…justified it

The e2e runner defaulted UNIFIA_DISABLE_LSP to "true" on the stated
grounds that language servers were "the mechanism behind the suite's
flakiness". That claim was never measured, and measurement refutes it:
three runs of the same two tests give LSP on -> 2 passed (43.7 s), LSP
off -> 2 failed, LSP off -> 2 passed (37.3 s). The suite flakes either
way.

What the flag does buy is real — four 45 s `initialize` timeouts and
every `spawned lsp server` line disappear — but that is a cost removed,
not a cause. Left on by default it would have hidden any LSP regression
from the suite permanently, in exchange for a shorter run.

The flag itself stays: it is a legitimate opt-in, it has four tests in
test/flag/disable-lsp.test.ts, and its gate sits at the same point as
`cfg.lsp === false` (lsp/index.ts:303). It is simply no longer defaulted
— `serverEnv` already spreads process.env, so setting it in the
environment still works.

The same false claim was duplicated in the flag's own doc comment; it is
corrected there too, with an explicit "do not reach for this to stabilise
a test".

Nothing in .github/workflows sets UNIFIA_DISABLE_LSP, so CI picks the
language servers back up from this change alone.
@github-actions

Copy link
Copy Markdown

Hey! Your PR title fix(security,e2e): close the three CodeQL findings and stop hiding the LSP from e2e doesn't follow conventional commit format.

Please update it to start with one of:

  • feat: or feat(scope): new feature
  • fix: or fix(scope): bug fix
  • docs: or docs(scope): documentation changes
  • chore: or chore(scope): maintenance tasks
  • refactor: or refactor(scope): code refactoring
  • test: or test(scope): adding or updating tests

Where scope is the package name (e.g., app, desktop, opencode).

See CONTRIBUTING.md for details.

MM2-B02-WORKER added 6 commits August 11, 2026 16:21
Root cause of the prompt-family failures, read off the Playwright call log of
run 31480610511 rather than inferred:

    - waiting for getByRole('button', { name: 'Send' }).first()
    - attempting click action
      - <div data-slot="toast-description">Choose an agent and model before
        sending a prompt.</div> ... intercepts pointer events
    208 x retrying click action

`submit.ts:306` refuses the prompt outright when either the agent or the model
is missing — it shows that toast and returns, so nothing is sent and no request
ever reaches the mock LLM. The helper in fixtures.ts typed and pressed Enter as
soon as the prompt input was *visible*, but agent and model are resolved from
the provider list the backend serves, which lands after the input renders.
Typing before then is the race. That is the exact trap e2e/AGENTS.md names:
"Do not treat a visible element as proof that the app will route the next
action to it."

Everything else in those failures is cascade. `started` never moves, so the
helper falls back to clicking Send; the click is intercepted; the test burns
its 120 s timeout; the queued mock response is never consumed and the `llm`
fixture teardown reports "TestLLMServer still has 1 queued response(s)". Four
of the twelve common failures were that teardown message — a symptom reported
as if it were the defect.

`waitPromptReady` polls the committed `__opencode_e2e.model.current` probe for
both `agent` and `model`, which is the same state submit.ts checks. Semantic
app state, no wall-clock wait, no new probe: the one it needs already existed
and was already used by the noReply branch two functions above.

Second defect, independent and also from the same call log: the debug bar
`<aside aria-label="Development performance diagnostics">` is `fixed bottom-3
right-3 z-50 pointer-events-auto` and 324 px wide, which parks it on top of the
composer's Send button. That is what the 208 retries were hitting once the
toast faded. It is dev-only telemetry, never shipped, and nothing in the suite
exercises it, so layout.tsx no longer renders it when the e2e harness is
present. Without this the fallback click can never land, and a fast, legible
failure becomes a timeout.

`e2eActive()` reads the `__opencode_e2e` marker the harness installs from an
init script. Nothing else writes it, so a normal page always reads false and
the bar is unchanged for developers.
This is the first causal defect behind the prompt-family failures, and it is
the repo's signature shape: a producer whose consumer was never wired to it.

`fixtures.ts` derived the model it seeds into the browser from
OPENCODE_E2E_MODEL, defaulting to `opencode/gpt-5-nano`. That variable is set
by `script/e2e-local.ts` for `seed-e2e.ts`, which writes a message record on
the *shared* backend. The browser does not talk to that backend — it talks to
the isolated per-worker backend started by `e2e/backend.ts`. Asked what it
serves:

    HTTP 200
    provider=local-llm models=1 gemma-4-E4B-it

One provider. No `opencode`, no `gpt-5-nano`: the Zen provider is dropped for
having zero models without credentials, which is the
"provider removed: zero models after filtering" warning printed on every test.

So the composer was seeded with a model that does not exist in the list it
reads, `local.model.current()` stayed undefined, and `submit.ts:306` refused
every prompt before sending it. Which model it is does not matter for routing —
with OPENCODE_E2E_LLM_URL set, `provider.ts:797` sends every model to the mock
regardless of provider. What matters is only that the id is in the list.

The seed is now resolved from `/config/providers` on the backend the browser
will use, once per worker. One authoritative source instead of a constant that
had drifted away from the thing it was meant to describe. OPENCODE_E2E_MODEL is
still honoured when the backend actually serves it, so pointing the suite at a
specific model keeps working; `seedStorage` now requires a model rather than
defaulting to one, so this cannot silently drift again.

Measured before this branch, on the base commit 8c3e08f (run 31495152621):
13 failures / 119, after 3 attempts each.
`unit (windows)` went red on run 31501895233 with one failure:
`sanitizer-fuzz.test.ts:51`, the per-iteration wall-clock assertion. The same
file passes in isolation on the same machine — twice, 45.4 s cold and 4.3 s
warm, 6 pass — and the base commit's own `unit (windows)` was green. Nothing in
this branch touches the sanitizer.

That is contention, and the test was built to catch it: 120 iterations each
asserted against a 200 ms ceiling gives 120 chances per run for a GC pause or a
preemption on a shared runner to fail a check that proves nothing about the
code under test.

The budget exists to catch catastrophic backtracking, which blows up by orders
of magnitude — seconds on a 4 KB input. So the threshold only has to sit
clearly above scheduler noise and clearly below a blow-up, and 200 ms sat in
the noise. The per-iteration ceiling moves to ReDoS scale (2 s) and a total
budget across the 120 iterations is added next to it. One stalled iteration now
trips neither; a single pathological input still trips the ceiling, and a
sanitizer that got slow across the board still trips the total. Strictly more
sensitive to the thing being tested, and no longer sensitive to the runner.

Propagation grep: the sibling budgets in `sanitizer.test.ts` (50 ms / 200 ms)
are single-shot, not loops, so they carry 1/120th of the exposure and have not
been observed failing. Left alone rather than churned without evidence, and
reported.
…ile diffs

A literal NUL in CONTROL_CHARS made git classify this file as binary. Every diff on it showed as `0 insertions, 0 deletions` and could not be reviewed — including the budget change in the previous commit. Same six code points, written as \u escapes: identical values, identical behaviour, reviewable diff.
…rdicts

G3: the LSP hypothesis is refuted and the real cause named — the browser was seeded with a model the isolated e2e backend does not serve, so submit.ts:306 refused every prompt. Common-failure count corrected from 11 to 12.

G4: the four remaining alerts are classified with cited evidence, two fixed at source, one already fixed on the branch, and js/http-to-file-access left open for a human decision. None closed by the agent.

G7: the build chain is proven — unsigned APK produced and apksigner confirms no key was used — so only the keystore blocks it.

G1: figures re-measured on this branch, including the packages/mobile trap where bun test and bun run test disagree by 31 failures.
@github-actions

Copy link
Copy Markdown

This pull request has been automatically closed because it was not updated to meet our contributing guidelines within the 2-hour window.

Feel free to open a new pull request that follows our guidelines.

@Rwanbt Rwanbt reopened this Aug 11, 2026
@Rwanbt Rwanbt changed the title fix(security,e2e): close the three CodeQL findings and stop hiding the LSP from e2e fix(unifia): harden security and restore E2E coverage Aug 11, 2026
@github-actions

Copy link
Copy Markdown

Thanks for your contribution!

This PR doesn't have a linked issue. All PRs must reference an existing issue.

Please:

  1. Open an issue describing the bug/feature (if one doesn't exist)
  2. Add Fixes #<number> or Closes #<number> to this PR description

See CONTRIBUTING.md for details.

1 similar comment
@github-actions

Copy link
Copy Markdown

Thanks for your contribution!

This PR doesn't have a linked issue. All PRs must reference an existing issue.

Please:

  1. Open an issue describing the bug/feature (if one doesn't exist)
  2. Add Fixes #<number> or Closes #<number> to this PR description

See CONTRIBUTING.md for details.

@github-actions

Copy link
Copy Markdown

Thanks for updating your PR! It now meets our contributing guidelines. 👍

@Rwanbt
Rwanbt merged commit 868e121 into feat/unifia-rebrand-complete Aug 11, 2026
19 checks passed
@Rwanbt
Rwanbt deleted the fix/unifia-security-and-e2e branch August 11, 2026 20:11
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