Setup, configuration and deployment docs live in website/, which is
published as the docs site, but the map sent them to /docs and did not
list website/ at all. Add it, and drop the "when those paths are used"
hedge on /specs, which exists.
CODEOWNERS has no rules until #593 settles the per-area map; say so
instead of describing reviewers it does not list.
CONTRIBUTING.md still had a single maintainer curating main, which the
Release Maintainer section replaces. Name the Product Owner's GitHub
account and say where a community proposal starts, matching the
existing issue forms.
The #6503 merge added _control_plane_path() to _is_denied_tool_path(),
which grep, glob and ls call per enumerated entry. Without a snapshot
argument every call rebuilt _control_plane_snapshot() and stat'ed every
protected state file again. A 5,000-file grep went from 0.75 s to ~7 s,
and around 20k files it hits the grep timeout.
Thread an optional snapshot through _is_denied_tool_path() and
_can_traverse_tool_path(), and build it once per scan, the way
process_resources already does for its launch-boundary walk. Root
resolution and bound-resource resolution still observe fresh state.
The compact preview reduces every pre-execution tool error to a fixed
allowlist so exception text cannot reach the client. Messages our own
validators build per call are not on that list, so the model got "The
tool call could not be validated..." instead of the pdf_extract path
hint, the email uid provenance error, the artifact-Python target
message, or which schema argument was wrong. That is the default path
for the tool-profile models, Qwen3.5-9B included.
Each validator site now records its message in a per-call
validator_error before raising, and only that text is returned
verbatim. A schema failure gets a hint rebuilt from the offered schema
and the call's own arguments (missing property, expected type, allowed
values), never from the caught ValidationError. Everything else,
including all execution failures, still goes through
_public_preview_tool_error.
test_allows_user_content_the_app_hands_to_the_model is parametrized on
UPLOAD_DIR and friends, which sit under the per-worker data dir. pytest
used those paths as test ids, so every xdist worker collected different
names and `pytest -n 4` (documented in tests/README.md) aborted at
collection. CI shards with --shard and never saw it.
Each of the four pytest shards spends ~84 s on setup before pytest
starts, and repeats all of it: pip resolves and installs ~108 packages
(~24 s, even with the pip cache hit), Playwright downloads Chromium and
the headless shell with no cache (~11 s), and three separate
apt-get update calls run.
- Install requirements with uv (astral-sh/setup-uv, pinned) and cache
its downloads keyed on requirements.txt. uv resolves the same 108
pins as pip for this requirements.txt.
- Cache ~/.cache/ms-playwright keyed on package-lock.json so a hit
skips the browser downloads; --with-deps still installs system libs.
- Install FFmpeg and bubblewrap in one apt pass. The containment step
keeps its bwrap probes unchanged.
- Add --durations=25 so the slowest tests per shard are visible; the
shard planner only weights the slow marker, and shard times vary
from 154 s to 296 s within one run.
website/configuration-reference.md pinned every ODYSSEUS_* read to a
`path:line`, and test_env_reference fails when the committed page differs from
the generator output. So any change that adds or removes a line above an env
read in any scanned file has to regenerate the page, even when no variable
changed. Since the page landed on 2026-09-30, 24 of the 34 commits touching it
changed nothing but line numbers, and all three open PRs that touch it today do
the same - which also makes them conflict with each other on that file.
The Read in column now names the file, and "+N more" counts other files rather
than other read sites. The page still goes stale, and the test still fails,
when a variable is added, removed, moves to another file, or changes default.
check_notes still reports path:line on stderr, where it is useful and never
committed.
Request authority admits whole tool families from the user's request, so a
request to read email also admits send_email, delete_email and bulk_email,
and agent processes inherit the host network. With the gate defaulting to
off, an instruction injected through an email or a fetched page reaches those
tools with no other check; dev refuses them today.
Default the gate on, keep ODYSSEUS_TOOL_APPROVAL_GATE=0 as the opt-out, and
pin the production default with a test that imports the module in a fresh
interpreter. Four routing tests written for the opt-out posture now set it
explicitly.
The suite is 12.1k tests in a single CI job — nearly six minutes of pytest
that every push and every PR waits on in one block, on top of a setup step
that already installs npm, Playwright, FFmpeg and bubblewrap. Split it into
four sections the matrix runs in parallel: 352s becomes a 98s longest pole
locally.
Shards partition by test *file*, not by the area_* taxonomy markers. Those
markers do not partition the suite - a file can carry a hand-applied area_*
mark on top of the one conftest derives from its filename, so a marker-based
split would run those tests in more than one section. Assignment is a total
function of the file path instead, so every file lands in exactly one shard
and the four together run every test exactly once.
Sharding deselects rather than narrowing collection, so every test module is
still imported, in the same order, in every shard. The import-time stubbing
in conftest and the session-scoped static server behave identically whether
the suite runs whole or in sections - this suite has known collection-order
coupling and splitting by path would have walked into it.
Balance uses the existing `slow` marker as its weight signal rather than a
committed duration table that would go stale unnoticed. Files pack
heaviest-first into the lightest shard, which is deterministic for a given
file set, so every parallel job computes the same plan from the same commit.
Verified: the four shards together reproduce the full run exactly - 12141
tests selected across the four, and the same 59 failures, 78 skips and 2
xfails, by node ID and not merely by count.
"Is this path inside that root" is asked in twenty places in this tree and
answered twenty times by a locally written realpath/commonpath pair. Nine test
files exist because nine call sites each needed their own proof. Each one is
defensible alone; together they are the defect, because the boundary has no
single definition and a site that gets a detail wrong is wrong by itself.
src/path_confinement.py is that definition, and it settles the details the
copies disagreed on. Both sides get canonicalized: comparing a realpath-ed
candidate against a root that was only abspath-ed is the macOS /tmp ->
/private/tmp mismatch that has already produced a false failure here, and
canonicalizing one side is worse than canonicalizing neither. commonpath rather
than startswith, because /a/bc begins with /a/b and is not inside it. A relative
candidate joins the root rather than os.getcwd(), which is whatever directory
the server happens to be running in. NUL and newline are refused with a reason
instead of caught by a bare `except Exception` and reported as an ordinary
escape. Eighteen call sites go through it now. It deliberately does not decide
whether a path is sensitive -- that deny list answers "allowed" rather than
"inside", and it stays with src/tool_execution, which owns it. The one
commonpath left in the tree, in src/workspace_paths.py, stays: that function
translates a host path into a container path, so canonicalizing either side
would change the relative path it computes and break the mapping. It is not a
confinement check.
Two of those sites were weaker than the rest and are fixed rather than moved.
The email attachment check used abspath, which folds `..` but does not resolve
symlinks, so a symlink written into the extraction directory passed it and was
then read through. The skill-reference guard compared a realpath-ed target
against a raw dirname, so on a host where the skills tree is reached through a
symlink the two sides never matched and the guard could not fire.
The execution boundary had two separate holes.
The workspace namespace bound /home and /mnt read-write. On the one platform
where that namespace engages at all, a command inside it reaches outside the
workspace and writes to the user's home directory -- measured by running this
argv on a Linux host with working bubblewrap, not inferred from the source.
Binding the user's whole home directory into a workspace-confinement namespace
gives back most of what the namespace was for. Both are read-only now. The
workspace is also bound writable at its real host path, not only at /workspace:
BashTool's own /tmp redirect rewrites `/tmp/` to `<agent_cwd()>/.tmp/` before
the namespace is built, so the command bwrap receives already names the real
path, and those writes previously landed only because the workspace happened to
sit under the writable /home.
`namespaced or _replace_workspace_alias(...)` chose between a mount namespace
and a regex with nothing in the result saying which one ran. The fallback
rewrites the literal token /workspace in the command string, so a command that
never mentions /workspace is untouched by it and runs on the host unrestricted
-- which is every agent shell command on macOS. Both tools now ask
containment.probe() instead of each deciding for itself, and every bash and
python result carries a containment block naming the mechanism and stating
whether the filesystem dimension actually held. Under enforcing mode the
command is not run and the result says so.
That block reports the filesystem dimension only, and says so in a
reported_dimensions field. The probe knows this host could also give a process
group and a real wall clock, but these two tools still assemble their own
create_subprocess_* call and pass neither, so listing those dimensions would be
exactly the false claim src/containment.py calls worse than an honest absence.
probe() is new on src/containment.py: the same mechanism table and the same
arithmetic as acquire(), stopping before the side effects. acquire() is the
wrong shape for a decision -- it writes a durable grant record, and a record
whose pid is never filled in and whose release() never runs is an entry a
restart reaper keeps finding.
CONTAINMENT_MODE stays report_only. Flipping it refuses every agent shell
command on macOS and on any Linux host without bubblewrap, which is a product
decision rather than a code one.
Smaller things in the same area: the /tmp redirect's makedirs was unguarded, so
a read-only workspace turned a command that merely mentioned `/tmp/` into an
OSError traceback instead of a tool error; it degrades now. WORKSPACE_MOUNT
moved to src/constants.py so the namespace and the path resolvers read one
definition of the contract rather than two. The ".tmp" dirname got a constant,
since it appeared in both tool paths.
One generated artifact moved with it: website/configuration-reference.md pins
the source line where each ODYSSEUS_* variable is read, and three of those
shifted. Regenerated with scripts/generate_env_reference.py; the diff is line
numbers only.
Three existing tests changed. test_workspace_artifact_tool_floor asserted that
an unsafe interpreter prefix produces no `--ro-bind <prefix> <prefix>`, which
now fires on /home because /home is legitimately a read-only base mount.
Asserting the absence of a literal flag string cannot distinguish "the prefix
was rejected" from "the argv mounted that root itself", so it compares the argv
against the no-prefix baseline instead: an unsafe prefix must add nothing.
The Windows bash test asserted dict equality on the
whole result, which makes adding a field to every bash result impossible without
touching a test about tmux; it asserts the shape now. The personal-dir symlink
test grepped the resolver's source for the literal "os.path.realpath", which is
gone because the resolution moved into the shared boundary -- it keeps the
negative assertion that the closure must not grow its own abspath check again,
and the behavioural half now runs against the boundary, where it covers every
call site instead of one closure.
Not verified: the bubblewrap argv is asserted, not executed. There is no bwrap
on macOS, and in Docker it needs --privileged to work at all -- default and
seccomp=unconfined both fail with "Creating new namespace failed", and
--cap-add=SYS_ADMIN fails at pivot_root. The Python tool's
needs_virtual_namespace gate means ordinary Python code gets no namespace even
on a Linux host that could provide one; that is reported now but deliberately
not changed, because it alters the Linux Python path on every call and cannot be
checked from here.
A recorded pid is a claim, not a handle. The containment grant store, the
background-job store and the Cookbook task list all outlive the process that
wrote them — deliberately, so a restart keeps a job and its result — and the
kernel reuses pids. Any teardown driven off one of those records can therefore
land on a process we never started. ODY-86 was exactly this, and the Cookbook
survivor sweep still terminated any process whose full command line matched a
tracked one, which is the same mistake spelled differently.
Identity is (pid, start token). The token comes from /proc/<pid>/stat on Linux,
ps -o lstart= on macOS and the BSDs, and GetProcessTimes on Windows; the kernel
will not hand a pid to a process that started earlier, so comparing the token
recorded at launch against the token read now answers "is this still ours"
without a handle or a supervisor. verify() returns owned, gone, foreign or
unverifiable, and only owned permits a signal.
Keeping "unverifiable" out of the other two is the point. Process inspection
has broken off Linux four times here — ODY-70, -86, -94, -99 — every time
because an absent mechanism read as a successful answer. Folding it into "ours"
signals strangers; folding it into "gone" abandons live processes. It is a
containment failure and every caller treats it as one.
Wired into the three places that signal:
- containment.release() gates a grant recovered from the durable store, and
leaves an in-process teardown alone, where the caller holds the child and no
identity question arises. The verdict lands on the record, so "why is this
grant still here" is answerable afterwards.
- A startup reaper. Nothing read either store before, so a crashed run left
every grant permanently active and every job permanently running, and the
first thing to touch such a record was a teardown aimed at a reassigned pid.
The two stores get opposite treatment: an orphaned grant has no caller left
and is torn down, while a detached job is documented to survive a restart and
is only corrected, never killed.
- The Cookbook sweep takes its ownership from the tmux pane's process tree,
captured before the kill destroys the only link between a surviving model
server and the session that started it. A process that merely matches the
tracked command line is now reported rather than killed: the Cookbook
composed that command line, so an identical one is just as likely to be a
server the user started by hand. The sweep also runs on hosts with no procfs
instead of silently skipping, and says so when it could not look at all.
_session_alive collapsed every OSError from killpg(pgid, 0) into "the
group is gone". EPERM means the opposite — the group answered the probe
but holds a process we may not signal — so a session we could not touch
was reported as contained, and a timed-out command that left children
running said it had terminated cleanly.
Resolving PTY_KILL_ESCALATION also named signal.SIGKILL unconditionally,
which does not exist on native Windows. app.py imports this module at
start-up, so that turned a POSIX-only teardown detail into the whole app
failing to import there.
/api/shell/stream starts its PTY child under os.setsid, so the child
leads its own session and process group. The timeout, client-disconnect
and error paths all called proc.kill(), which signals only the group
leader. Creating a group and then signalling only its leader is strictly
worse than never creating one: the descendants are detached from the
server's group as well, so nothing else will ever reach them, while the
route reports "Command timed out after Ns" and exit_code -1 as if the
command were gone.
The kernel's controlling-terminal SIGHUP hid this for well-behaved
children, which is why it reads as working. Anything that ignores
SIGHUP — a nohup'ed job, a daemon, a process that means to outlive its
terminal — survives the kill indefinitely.
Signal the whole group instead, escalate to SIGKILL if it outlives the
grace period, and confirm it is actually gone. The timeout response now
says so when containment could not be established rather than claiming
a clean kill it did not get.
Agent-reachable execution has 25 independent spawn sites and no single
place deciding where a process runs or under what limits. All three
consequences are visible on this SHA. When bwrap is absent the workspace
namespace degrades to a regex that rewrites /workspace to the real path,
and nothing in the tool result says which one you got. No spawn site
passes start_new_session, so a wall-clock kill reaches the wrapper shell
and leaves its backgrounded grandchildren running while reporting the
process killed. Teardown stops at SIGTERM without ever checking death.
src/containment.py gives those paths one boundary. acquire() establishes
containment or refuses -- a string rewrite is not a mechanism it can
select -- and the grant states which dimensions actually hold, which were
best-effort and are missing, and which were required and are missing.
run() enforces the wall clock and the output cap. release() signals the
process group, escalates to SIGKILL, and reports dead only for a group it
observed go empty.
Containment never sees the command: acquire() takes a workspace and
limits, and the command text only reaches run(). Nothing in a request can
widen a boundary it is never shown.
CONTAINMENT_MODE chooses between refusing an unestablishable required
dimension and recording it. It ships report-only, so landing this changes
no behaviour on a host without bwrap -- which is every host today.
No call sites move here; they follow on this branch. The configuration
reference is regenerated because the page records src/constants.py line
numbers and the new path constant shifts two of them.
The Windows branch of `_create_bash_subprocess` spawned Git Bash with
neither pipes nor the env it was handed. `proc.stdout` and `proc.stderr`
came back `None`, so `_run_subprocess_streaming`'s reader returned
immediately and the Bash tool reported `"(no output)"` alongside the real
exit code — while the child inherited the server's own stdout/stderr and
wrote agent command output into the console and the launchd/Docker logs.
The `env` parameter was accepted and never used, so `PATH`, `VIRTUAL_ENV`,
`HOME`, `TMPDIR` and the configured import paths carried in
`ctx["subproc_env"]` never reached the child on Windows, even though every
POSIX path applies them.
Spawn it the way the POSIX path at `:688` already does: `stdin=DEVNULL`,
`stdout=PIPE`, `stderr=PIPE`, `env=env`.
`website/configuration-reference.md` is generated from source line numbers,
so the four added lines shift one entry; regenerated with
`scripts/generate_env_reference.py`.
Nothing in the UI said which build was loaded. /api/version has reported
version, build and source_commit since the harness started versioning itself
apart from the public semver, but the only way to read it was to curl the
endpoint — so "is the preview actually running the commit I just merged?" took
a terminal to answer.
Pins a footer under the settings sidebar nav showing the registered version
(plus the harness build when it differs) and the short source commit, with the
full hash on hover. It sits outside the nav's scroll container so it stays at
the bottom-left, and it is .admin-only, so syncAdminVisibility() hides it from
non-admins the same way it hides the Admin nav group.
The commit resolves at import via `git rev-parse HEAD` and is the string
"unknown" when that fails — a read-only Docker tree with no .git. The footer
treats "unknown" as absent and stays hidden when nothing is left to show,
rather than printing it. The collapsed rail and the two narrow tab-rail
layouts hide it too: neither has a bottom-left to write in.
Completes the lane alteixeira20 asked for. Test-only: tests/ and test helpers,
no production runtime code, no benchmark fixtures or allowlists.
Supplied workspace context must not produce a clarification. Pins
_looks_like_unattended_clarification on four shapes that hand the decision
back ("could you please share", "shall I", "which approach do you prefer")
and three ordinary answers that must not trip it.
Repeated update_plan is not the turn's work. ask_user and update_plan are
permitted on nearly every turn, so if they counted as execution a model could
loop on them and look busy. Pins that _tool_rejection_reason does not
advertise either as an available tool, and that update_plan is permitted
without ever being in required.
Request-scoped tool authority. _request_scoped_allowed_tool_names must not
make an undeclared tool executable; the native-terminal widening is pinned
separately so it stays opt-in rather than drifting into the default.
Foreign-process safety. The Chrome sweep matches this runtime's own profile
prefix, so a fake procfs with our pid, a user's ordinary Chrome and another
worktree's agent browser must leave exactly two of the three alone.
14 passed, 2 xfailed. The xfails are the negative-wording cases from the first
commit that do not hold yet.
Four more tests in the same family as the /tmp ones this change already
fixes, and they hide for the same reason: the failure depends on how
long $TMPDIR happens to be.
tests/test_shell_routes.py::TestHostDockerAccess (three) and
tests/test_cookbook_docker_access.py::test_container_opt_in_with_unix_
socket_is_allowed each bind an AF_UNIX socket at tmp_path/"docker.sock".
macOS gives sun_path 104 bytes including the terminator. pytest roots
tmp_path at $TMPDIR, which on a stock Mac is a 49-character
/var/folders/<2>/<30>/T/; add pytest-of-<user>/pytest-<n>/ and the
test's own name and the bind path is 115 bytes before the filename.
OSError: AF_UNIX path too long
Linux allows 108 and roots $TMPDIR at /tmp, so CI never sees it. Under a
shortened $TMPDIR the path lands at exactly 103 and passes — until
pytest's run counter reaches two digits and it becomes 104. That is why
the ledger's counts did not include these: they were measured somewhere
the path fit.
Adds tests/helpers/unix_sockets.bound_unix_socket, which binds under a
short directory and asserts the length before it tries, so the next
socket fixture fails with a sentence rather than an errno. Records the
trap in KNOWN_FAILURES.md along with the instruction to re-measure with
the default $TMPDIR.
Verified end to end against a local Qwen3.5-9B Q4_K_M that when the user
explicitly enables web for the turn, none of the three phrasings withholds
web_search, web_fetch or private_browser, including the one these tests record
as held. The held case holds on the inferred path only, where no toggle is set
and the runtime decides from intent.
That distinction was missing and the file read as a stronger claim than the
measurement supports. An explicit toggle beating an inferred negative may be
the intended semantics, so it is recorded rather than asserted.
The move left three documents naming `routes/email_routes.py`,
`routes/email_helpers.py` and `routes/email_pollers.py` as where the
code is. The shims keep those import paths working, so nothing breaks —
but each of those files is now seventeen lines that redirect, and a
reader sent there finds no email code at all.
Follows the phrasing specs/persistence.md already uses for the
contacts and vault subpackages: name the canonical path and note the
shims.
The route now answers 400 with a message naming the expected shape.
admin.js was taught to print `data.detail`; the Unified Integrations
form in settings.js still printed `Failed (400)` and dropped it.
That gap is exactly where the new validation bites. The client-side
JSON.parse guard added alongside it catches unparseable input, so the
only values that reach the route's 400 are ones that parse but are not
a list — `"npx"`, `{}`, `null` — and for those the status code alone
tells the user nothing about what is wrong with what they typed.
Adds source-level coverage for both forms; the PR changed two JS files
with no test on either.
Splitting style.css deleted it, and two files outside static/ still
named it:
- scripts/verify_background_research_cards.mjs injected
`<link rel="stylesheet" href="/static/style.css">` into the page it
builds. A stylesheet that 404s does not fail — the script kept
checking card layout against an unstyled page and kept reporting
pass. It now reads the shell's <link> tags out of index.html, the way
tests/css_snapshot/capture.mjs already does, so the set cannot drift
out from under it again.
- tests/css_snapshot/bench.html's hand-open fallback linked the same
deleted file. Replaced with the ordered set index.html ships.
test_every_stylesheet_referenced_by_shipped_html_exists only walked
static/*.html, which is why neither showed up. Extend it over the bench
page: the bench's whole output is computed styles, so a dead link there
is worth more than an unstyled page nobody looks at.
_process_is_alive used os.kill(pid, 0). That probe is POSIX-only:
CPython's Windows os.kill calls TerminateProcess(handle, sig) for any
signal other than CTRL_C/CTRL_BREAK, so it terminates the process it is
asked about. This function is only reached when there is no procfs to
read a command line from, which is exactly the macOS and Windows case
the rest of this change exists to handle.
core/platform_compat.py already owns that probe and documents the
hazard; its module docstring asks callers to import from there rather
than spell a POSIX-only call out locally. Delegate to it.
pid_alive answers False where os.kill raises PermissionError — a live
process owned by another user. Both call sites want that reading: the
sweep only unlinks a pid file it wrote itself, and a pid it cannot
confirm is not the daemon it is looking for.
First slice of the runtime regression lane. Drives stream_agent_loop with a
fake model and asserts on the tools the runtime offers, which is its decision
about what the turn may do. No production runtime code is touched and no
benchmark fixture or allowlist is imported.
The fake-model pattern is the one tests/test_tool_policy.py already uses:
patch stream_llm_with_fallback and inspect the tools kwarg.
Measured on lab@c499c01b, negative web wording is only partially detected:
"Answer from memory only, don't search online." web tools withheld
"Summarise what you already know. Do not search the web." web tools OFFERED
"No web search please, just tell me what you know..." web tools OFFERED
The case that holds is a plain regression guard. The two that do not are
xfail(strict=True): they run on every suite, document the target, and fail the
moment the behaviour lands so the marker gets removed rather than lingering.
A positive control keeps the guard from being satisfied by removing the web
tools altogether.
`_refresh_token_cache` rebuilt the bearer-token map in two steps, `clear()`
then `update()`. Between them the dict a concurrent reader was already holding
was empty, so a valid token landing in that window found zero candidates and
got a 401. The refresh runs on a worker thread via `to_thread`, so the window
is real rather than theoretical.
Build the new map and rebind the name. The reader dereferences the global once
and then holds a map that is complete — the previous one if it read early, the
new one if it read late, never a half-built one. `app.state._token_cache` is
rebound with it, because it was bound once at startup and would otherwise point
at the abandoned dict.
Ported from public `dev` (`984337b3`), with its test.
The upstream test does not pin the fix: both of its concurrency cases pass
against the pre-fix code, because landing a GIL switch inside a window a few
bytecodes wide does not happen across 100 refreshes. They are kept as written
and `TestRefreshLeavesTheReadersMapAlone` is added next to them, stating the
same invariant at object level — a map a reader already holds is not mutated
by a later refresh — which fails on the pre-fix code without depending on
thread scheduling.
`_cached` deduplicates the scheduler's outbound fetches — Miniflux unread
counts and MCP tool snapshots — by parking every concurrent caller on one
shared Future. Two cancellation paths left that Future stranded.
A waiter awaited the shared Future directly, so cancelling the waiter
cancelled the Future the owner and every other waiter were using. It now
awaits through `asyncio.shield`.
The owner removed its pending entry inside the success and `except Exception`
branches. `CancelledError` is a `BaseException`, so it took neither: the key
stayed in `_shared_cache_pending` pointing at a Future nobody would ever
resolve, and every later caller for that key waited forever. Cleanup moves to
a `finally` that is synchronous on purpose, and the owner cancels its own
Future so current waiters wake while a later caller can still retry.
Ported from public `dev` (`ce04dc1d`, #6174 upstream), with its test.
The configuration surface was undiscoverable. .env.example has three active
lines, and of the ODYSSEUS_* variables the code actually reads, most appear
nowhere in .env.example, docs/, website/ or README.md - including several that
change security-relevant behaviour (ODYSSEUS_BROWSER_NO_SANDBOX,
ODYSSEUS_ALLOW_PRIVATE_CALDAV, ODYSSEUS_ENABLE_HOST_DOCKER,
ODYSSEUS_MCP_ALLOWED_COMMANDS). Every question about one of them lands in the
issue tracker.
A hand-written page would drift within a month, so the page is generated:
- scripts/generate_env_reference.py walks the Python sources, collects each read
with the default it falls back to and the file it is read in, groups by area,
and marks the internal variables rather than omitting them.
- website/configuration-reference.md is the generated output, wired into the
Pages layout and linked from setup.md and .env.example. .env.example stays a
short deployment-level example and links onward rather than growing.
- tests/test_env_reference.py regenerates and compares, so adding a variable
without documenting it fails the suite. That is the point: the current state
happened because nothing objected.
Finding the reads needs more than one pattern. Two families - the upload caps in
src/upload_limits.py and the media-ingress overrides in src/media_ingress.py -
are read through helper functions, so the generator detects env-reader helpers
rather than hardcoding a list. Others span two lines, hold the variable name in
a module constant, or read through a mapping passed in as an argument. One lives
inside a string literal, in the Ollama probe script routes/cookbook_helpers.py
builds line by line. A line-based grep for os.environ.get("ODYSSEUS_ finds 70 of
the 98 the generator finds; the page reports that gap and recomputes it on every
run so the claim cannot go stale.
No application behaviour changes.
#25 fixed two sys.modules writes in test_auth_regressions.py that left empty
stub modules behind for the rest of the session, breaking 23 tests under one
collection order while the full suite stayed green. The class is wider than
that file, and an audit is the wrong answer to it: nothing stops the next one,
and the failure it causes lands on an unrelated test in a different file.
So this is a guard instead. An autouse fixture in the root conftest snapshots
which src.* / core.* names are bound to a bare ModuleType, and fails any test
that adds one. "Bare" is the same test the clear_fake_* helpers already use -
a plain types.ModuleType with no on-disk __file__. MagicMock stand-ins are out
of scope: they answer every attribute, so they fail at the point of use rather
than silently, and several files install them deliberately.
Three details that matter:
- It lives in the root conftest, so it is set up before any test-module
fixture and torn down after all of them. A stub a test's own teardown
removes is not reported.
- It drops the leaked entries as well as reporting them, so the failure stays
on the test that introduced it instead of cascading through the rest of the
run.
- It only reports stubs added during the test. Import state the session starts
with, including this conftest's own src.database stub, is left alone.
It found one beyond #25 on the first full run: _stub_heavy in
test_scheduler_restart_doublefire.py leaks the same five src.* modules as the
test #25 fixed, via sys.modules.setdefault. It already receives monkeypatch,
so the fix is to register through it. Fixed here because the guard has to land
green.
Full suite, macOS, default collection order:
this branch 10655 passed, 6 failed, 6 skipped 406s
lab 10655 passed, 6 failed, 6 skipped 371s
Same six either way, which is the point - none of this is visible in the
default order. Four are pre-existing macOS environment failures:
test_glob_confined_e2e and the two test_code_nav_tools document cases resolve
/tmp to /private/tmp, and
test_real_socket_falls_back_from_dead_first_to_live_second is connect-refused
timing on real sockets. The other two are the rich-colour and ffmpeg items
from the same ledger, fixed on their own branches.
Not verified: Linux, and any collection order other than the default. The
guard is order-independent by construction - it compares before and after
within a single test - but I have only run the default order.
test_inspect_media_exports_final_decodable_frame_at_exact_duration exported to
/workspace/final.webp and asserted exit_code == 0. WebP encoding is an ffmpeg
build option, not something this project requires - Homebrew's macOS ffmpeg is
built without it:
ffmpeg -encoders | grep -ic webp -> 0
so the tool returns "ffmpeg still extraction failed: ... Encoder not found"
and the test fails on the build rather than on the code under test.
The test's subject is the final frame being decodable at the exact duration,
which has nothing to do with the container. It now writes a PNG, and the two
things the WebP path was implicitly covering are split out and each guarded on
what is actually present:
- test_inspect_media_exports_a_webp_still - skipped unless ffmpeg reports a
webp encoder, and now asserts the file really is WebP rather than merely
non-empty.
- test_inspect_media_reports_a_missing_encoder_instead_of_crashing - runs only
where the encoder is absent, pinning the behaviour that surfaced this: the
tool reports ffmpeg's failure as a tool error and writes no partial file.
The product code has the same assumption and I left it alone. inspect_media
accepts any suffix in _IMAGE_SUFFIXES and hands the path to ffmpeg, so a .webp
request on a build without libwebp fails with ffmpeg's own message. That is a
poor message, not a crash or a corrupt file, and pre-validating the encoder
list is a separate change.
pytest tests/test_inspect_media_tool.py
68 passed, 2 skipped in 22.92s (1 failed, 66 passed, 1 skipped before)
Not verified: the WebP success path. This machine has no webp encoder, so
test_inspect_media_exports_a_webp_still skips here and has only been checked
for collection, not for a passing run.
test_rich_colors_follow_theme_and_undo_as_one_edit fails identically on every
macOS run, timing out after 30s waiting for a span that never appears. It was
written off as timing noise twice. It is not flaky - it is two Linux-only
input conventions, and the product code is fine.
Control+click: macOS delivers a Control-modified primary click as contextmenu,
not click. Instrumenting the Lemon swatch shows the button receiving
pointerdown, mousedown, contextmenu, pointerup, mouseup - and no click, so the
menu item's handler never runs and no highlight is applied. Control was never
meaningful here anyway; the palette item has no modifier behaviour. The two
calls now use a plain click.
Control+Z: the editor's undo accelerator is Cmd+Z on macOS. With the clicks
fixed, both undo assertions still failed until the presses became
ControlOrMeta+Z, which Playwright maps per platform.
Both fixes are portable - a plain click and ControlOrMeta are unchanged on
Linux, where this test already passes.
pytest tests/test_document_rich_color_reset_and_contrast.py
2 passed in 2.11s (1 failed, 1 passed before)
Not verified: Linux. I only have macOS here, so the claim that this stays
green on CI rests on the modifier being a no-op there, not on a run.
`add_server` wrapped `json.loads(args)` in a bare `except` that fell back to
`[]`, so an Args value that is not JSON — a bare path, which is what the form's
placeholder invites people to type — registered the server and spawned the
stdio subprocess with an empty argv. Nothing surfaced the loss: the POST
returned 200 and the row persisted with `"args": []`.
The route now returns 400 for an unparseable value, and also for valid JSON of
the wrong shape: `args=5` reached `StdioServerParameters(args=5)` and raised an
unhandled TypeError in the error formatter's `" ".join(...)`.
Both form clients mirror the guard instead of leaving the user to read a 400
they cannot see. `settings.js` stops silently defaulting a bad Args value, and
`admin.js` gains the same client-side parse check plus a `res.ok` branch so a
server-side rejection is not reported as a connection failure.
Ported from public `dev` (`9d5c0319`, #6215 upstream, fixing #6211), with its
test. The `admin.js` hunks are inert on `lab` — `initMcpForm` early-returns
because that form's markup is not in this build — and are carried anyway to
keep the two lines from diverging further.
The host cache was gated on the list being non-empty, so "queried fine, no
eligible peers" looked exactly like a cold cache and every caller paid for
another `tailscale status --json` — a subprocess with a 5s timeout.
Gate on the timestamp instead. Failures still leave the timestamp unset, so a
missing binary, a non-zero exit or unparseable output stays retryable rather
than being cached for the full TTL.
Ported from public `dev` (`affaee1e`, #6228 upstream), with its test.
`_cookbook_kill_session` kills the tmux session, then sweeps /proc for
model servers that survived the SIGHUP. The sweep had no guard, so on
macOS and Windows `os.listdir("/proc")` raised FileNotFoundError after
the kill had already succeeded. The function's outer except turned that
into `{"error": "...No such file or directory: '/proc'", "exit_code": 1}`
and skipped the state write that marks the session stopped — the agent
is told a stop failed that actually worked.
Guard the sweep with a procfs check, the way `_scan_running_model_processes`
already does a few hundred lines up. The root and the check now live in
`core/platform_compat`, which is where OS differences belong and which
makes both branches patchable from a test on either kind of host.
Adds a class-level guard test: the third instance of this defect, and two
of the three were found by reading source rather than by a test.
The decomposition lanes have two safety nets and neither covers the
product. The checkpoint benchmark measures the agent runtime; the
computed-style snapshot pins the CSS. Nothing checked that Notes,
Calendar, Documents, Email, Memory, Cookbook or Settings still worked
after a route package moved or a 17,000-line module was split - and the
unit suite does not, since a byte-identical file move can break tests
that pass on the base branch with CI green throughout. The 28 Playwright
specs we do have are all under tests/e2e/photo-editor/ and no workflow
runs them.
scripts/odysseus-smoke boots this worktree through `odysseus dev` and
runs tests/smoke/: one scenario per area, each asserting a user-visible
outcome rather than a status code. Models come from a deterministic
OpenAI-compatible stub on an ephemeral loopback port; email reuses the
existing ODYSSEUS_EMAIL_FIXTURE path rather than inventing a second
mechanism. No scenario touches a live endpoint or the network.
The report is a per-area table that prints the areas the suite does not
cover next to the ones it does, and builds its rows from the registry
rather than from what happened to run, so an area cannot go missing by
having its module deleted or renamed. Under a plain pytest with nothing
booted every scenario skips with the reason, so the full suite stays
green.
`lab` and the public `dev` line share only the repository's first commit as a
merge base, so `git log lab..dev` lists two thousand commits that are almost
all already present on both sides under different SHAs. Nothing in that output
says which public fixes never reached `lab`, which is the only question that
matters before `lab` becomes a release.
`scripts/ref_parity_audit.py` samples the most distinctive added lines from
each commit in the range and searches the other tree for them with
`git grep -F`, then reports the file-level presence diff. The two complement
each other: a commit whose probes are all found while one of the files it added
is missing from the target is a fix whose production change was reproduced
without its test, which the line sampling alone cannot see.
Probes are stripped of indentation and searched for anywhere in the tree, so a
port that moved or was re-indented still reads as present. Verdicts are
absent / partial / present / no-probe, and the report says outright that the
commit verdicts are a heuristic while the two file lists are exact.
Read-only by construction: `git log`, `show`, `diff`, `grep`, `ls-tree` and
`merge-base` only, no remote access, and it does not import the app package.
Three of the six recorded failures were the same test bug: an unresolved
/tmp path compared against a resolved /private/tmp one. macOS makes /tmp a
symlink, so a fixture built with tempfile.mkdtemp(dir="/tmp") and a code path
that resolves what it reports disagree about a file both found correctly.
test_code_nav_tools builds its fixture unresolved and compares it against the
reported path. One realpath fixes both of its failures.
test_glob_confined_e2e is the same cause through a longer route: it mixed
os.path.realpath(ws) with an unresolved secret directory, so relpath emitted
"../../../../tmp/<absolute path>" and the assertion that the absolute path was
absent from the output matched it as a substring. Resolving the secret
directory puts both sides in one tree and the relative path stays short.
macOS full suite goes from 6 failures to 3. The remaining three are an ffmpeg
build without a WebP encoder, a socket test that needs a fast connection
refusal, and the rich-text colour test that is still unexplained.
The ledger is updated in the same change so it does not describe failures that
no longer happen.
Running test_auth_regressions.py before the email modules failed 23 tests
that pass in isolation:
pytest -p no:randomly tests/test_auth_regressions.py \
tests/test_email_urgency_checkpoint.py
23 failed, 15 passed (38 passed in the reverse order)
Every failure was ImportError "cannot import name X (unknown location)"
against a module already in sys.modules, which is what an empty stub module
looks like to a later import.
Two writes leaked, both in this file:
- test_pop_notifications_owner_filtered inserted five empty stub modules with
a bare sys.modules[name] = mod and never removed them.
- _ensure_stub wrote its stub into sys.modules itself, so the autouse
fixture's monkeypatch.setitem three lines later captured that stub as the
value to restore. The fixture looked like it cleaned up and could not.
Both now go through monkeypatch, including the parent-package stub and the
attribute wiring, so everything is undone at teardown. The redundant setitem
calls in the fixture are gone: re-setting a key whose stub is already
installed is what made the leak invisible.
Pre-existing on lab, not introduced by any open PR. Verified against the
email subpackage branch too: identical numbers there before this fix.
Six of the seven modules call `spinnerModule.createWhirlpool(...)` and none
of them imported it. Everything still parsed, every module still loaded, and
the Email Settings page threw `ReferenceError: spinnerModule is not defined`
the moment `settings.js` mounted it — the panel rendered its loading spinner
and stopped there.
The cause is worth writing down because it is the same shape as the bug: the
import statements were collected with a pattern that was not anchored to the
start of a line, and the header comment says "the old import path keeps
resolving". That prose matched first and swallowed the real
`import spinnerModule from '../spinner.js';` that followed it.
`test_no_module_uses_a_package_name_it_never_bound` closes the class. Nothing
else here can: `node --check` parses without resolving scope, and loading a
module does not run the function body where the throw lives. It checks the
package's own vocabulary — every name any module in it binds — rather than
trying to model the browser's globals, so it has no false positives and still
catches the one mistake a split actually makes: the declaration stays behind
and the use moves.
settings.js is 5,721 lines and the registry/navigation/search/sidebar/
lifecycle primitives already live in static/js/settings/. What was left
behind in the coordinator was the layer above them: what happens when a
panel becomes active, where an admin-managed tab is handed to admin.js,
which elements are admin-only, the Appearance window fade, and the
public open/close. That layer reached module-global `modalEl` and
`initialized` directly, so none of it could be exercised without
booting every panel in the file — and every panel I eventually move out
would have to route back through it.
Three modules, no behavior change:
shell.js panel-activation side effects, the admin handoff,
.admin-only visibility, open()/close(). Takes what it
needs from settings.js as injected callbacks, the same
shape bindSettingsNavigation() already uses, so it
holds no panel state.
peek.js the Appearance window fade and its toggle. It is window
chrome rather than Appearance panel data, and it has to
be cleared when the user leaves that panel.
oauthReturn.js the once-per-load return path from the Google OAuth
redirect. It was an IIFE running at module evaluation
in the middle of a 5,700-line file.
settings.js keeps open/close/syncAdminVisibility as exports, so every
caller (app.js, calendar.js, chatStream.js, gallery.js, admin.js,
modelPicker.js, slashCommands.js, chatRenderer.js, emailLibrary.js) is
untouched. 5,721 -> 5,583 lines; the settings/ modules go 887 -> 1,107.
The real-ESM coordinator smoke now links the three new files and asserts
what moved: admin-only elements hidden for a non-admin and shown for an
admin, an admin-managed tab click handed to admin.js without a second
local activation, and the Peek fade applying on Appearance and clearing
when the user navigates away. The OAuth test follows its handler to the
new file and additionally pins the coordinator wiring, since "uses the
module-local open()" is now a property of the seam rather than of one
source slice.
No new module needs a cache-busting query or an sw.js precache entry:
the existing settings/ submodules have neither, they load transitively
from settings.js's versioned URL, and sw.js serves JS network-first.
admin.js stays where it is. It has no shell to extract — open() and
close() already delegate to settingsModule, and its 4,122 lines are all
panel code. That is a panel split, not this one.
static/js/document.js is 17,579 lines and is about to be decomposed behind a
re-export wrapper. 51 test files read it off disk and grep it as text, and 28
of those slice it with `src.split("function a", 1)[1].split("function b", 1)[0]`
-- "the region between a and b", which only means what the test intends while a
and b are neighbours in one file. Several also hard-code the file's two-space
indentation, which no extracted module reproduces. Left alone, the first
extraction makes those assertions cover the wrong region, and an `x in region`
check passes while covering more than it was written for.
tests/helpers/document_source is the one place that names the file now:
- document_source() is the entry plus everything under static/js/document/,
so a membership assertion keeps finding its subject wherever it lands;
- function_body()/declaration() locate a construct by name in whichever
module defines it and end at its real closing brace, so neither moving it
nor moving its neighbour changes the region.
The rewrite only collapses a slice when the old terminator sat at the
construct's end. 28 slices deliberately span a whole family of functions --
everything from _docxHexColor to exportAsDocx -- and collapsing one to its
first member drops what the assertions look for, so those stay as they are and
are listed in KNOWN_ADJACENCY_SLICES, to be converted as each family becomes a
module. That list may only shrink.
Two guards come with it:
- test_document_source_test_hygiene fails on a direct read of the entry file
and on any new adjacency slice;
- test_frontend_module_graph resolves every relative import under static/
(718 of them, none broken today) and requires the document module set to
stay in the sw.js precache, since the worker fetches the URLs it lists and
not what they import.
test_document_module_api pins the 38 default-export keys and 29 named exports
by loading the module in a browser and reading what it actually exports, rather
than grepping for the literal object -- after extraction that object may be
assembled from imports, and a source-shape check would pass while the export
was broken.
No JavaScript moves here. static/ is untouched.
`emailLibrary/index.js` goes from 11,375 lines to 6,187. What came out:
- `settingsPage.js` (879) — the Email Settings view, its form and controls,
away/auto-reply including the calendar-event sync, and the display
preferences. The inline-image preference lives here rather than with the
renderer because the settings form owns writing it.
- `unsubscribe.js` (1,224) — the bulk-unsubscribe review flow. One exported
entry point, its own localStorage keys, its own agent-tool-output listeners.
- `reader.js` (676) — opening an email as a docked tab or a floating window,
plus the AI summary panel both of them share.
- `menus.js` (855) — the reader More menu, the card kebab menu, the bulk
Actions menu and `_bulkAction`.
- `bodyRender.js` (871) — plain and threaded body rendering, inline MIME
images, quote folding.
- `attachments.js` (491) — attachment chips and the deferred load for messages
whose attachment list was not in the list response.
- `aiReply.js` (449) — AI-reply entry points, the per-message context draft,
the translate and remind submenus.
The extracted modules import back from `index.js`, so the graph has cycles.
That is safe for hoisted function declarations and unsafe for a value read
during evaluation, so nothing crosses a module boundary except functions:
`API_BASE` is re-declared per module, the way `emailInbox.js` and
`emailShared.js` already do it, and `_autoReplyRefreshSeq` moves into
`state.js` because the settings page and the unread-badge refresh both write
it and an imported binding is read-only. The module-graph test enters the
package at each module in turn, which is the order that would expose a
dead-zone read.
Two test helpers grew while doing this. `js_function_source` replaces three
marker-pair slices ("from this signature down to that comment") whose end
marker had moved into another module — the slice ran past the function and
kept passing against the wrong text. Its first implementation balanced braces
by walking characters and ended `_toggleCardPreview` 18 lines early, because
the apostrophe in `// that's a scroll, not a nav` opened a string that ate the
braces after it. It now keys on the invariant the file actually holds: a
top-level declaration starts at column 0, so its closing brace is the next
lone `}` at column 0.
The email library was 11,375 lines in one file, the second-largest JS
module in the repo. `static/js/emailLibrary/` already held four extracted
helpers, so the package existed; the bulk of the code just was not in it.
The implementation moves to `emailLibrary/index.js` and the old path
becomes a re-export wrapper. Five call sites import that path, four of
them dynamically with a `?v=` string, and `sw.js` caches URLs verbatim,
so a wrapper is what makes the move need no coordinated edit to any of
them.
The test side is the part worth reviewing. 58 tests read
`static/js/emailLibrary.js` as text. Pointing them at
`emailLibrary/index.js` would buy one move and break again on the next
one, which is exactly what happened to the stylesheet tests (they now go
through `tests/helpers/stylesheets.py`). So the same shape:
`tests/helpers/js_modules.py` reads the whole package, and assertions
stop caring which module a function sits in.
`tests/test_email_library_module_graph_js.py` is new. This frontend has
no module-graph validation, and a package fails in ways a single file
cannot: a wrapper that drops an export is `undefined` at call time rather
than an error at load time, and a module that reads a `const` across an
import cycle throws only when that module is entered first. It pins the
wrapper's surface against the entry module's, evaluates every module on
its own in a browser, and requires both import paths to hand out one
instance.
It also caught a live gap while being written:
`emailLibrary/replyRecipients.js` is imported by `emailInbox.js`, an app
shell module, and was never in the `sw.js` precache.
static/js/settings.js was 5,721 lines behind a four-name public surface.
static/js/settings/ already existed with dom, registry, search, sidebar,
navigation and lifecycle, so this continues that package rather than
inventing a layout.
Moved verbatim, 407 lines:
settings/speech.js initTtsSettings, initSttSettings
settings/writingStyle.js initDocumentWritingStyle
settings/imageModels.js initImageSettings
settings/agent.js initAgentSettings
settings/api.js postSettings, lifted from _postSettings
These five were chosen because their only dependencies outside themselves
were el/byId, _postSettings and sortModelIds. Panels with wider reach stay
put: initEmailAccountsSettings, for instance, pulls a 64-declaration closure
covering most of the file, and splitting that is a design change rather than
a move.
settings.js keeps its public surface exactly: open, close,
refreshAiModelEndpoints and the default export, verified in a browser.
Three test updates the move required:
- tests/helpers/test_settings_shell_coordinator.mjs allowlists the real
modules the coordinator may import, and rejected the new ones. That guard
working is the reason to trust the rest of this diff.
- two source-introspection tests read settings.js for behaviour that now
lives in a panel module. They read the whole settings surface now, so the
next extraction does not break them again.
routes/email_routes.py (7,194 lines), routes/email_helpers.py (2,025) and
routes/email_pollers.py (1,764) were the largest flat email area left in
routes/. They now live in routes/email/, following the pattern fifteen route
areas already use: the canonical module under the package, a shim at the old
path that replaces itself in sys.modules with the canonical object.
The shim matters more here than the move. Dozens of email tests monkeypatch
module attributes, and several pop the module from sys.modules and re-import
it. Without identity between routes.email_routes and routes.email.email_routes
those patches would apply to a different object than the app uses.
Intra-package imports keep the flat path, matching routes/document/ and
routes/gallery/: the shim resolves them and the diff stays a move.
Eight path references in five test modules read these files as source rather
than importing them, so they now read the canonical location. That is the same
adjustment every previous conversion made, and it is why the note/document
shims mention source-introspection tests explicitly.
No behaviour change. Verified with the full suite and under several explicit
collection orders, since a byte-identical routes move has broken tests under
one ordering before.
specs/testing-devops.md lists "no canonical full-suite known-failing/flaky
ledger" as a gap. Without one a first local run is uninterpretable: you cannot
tell a regression from a platform artifact, so you either chase a non-bug or
ignore a real one.
Six failures on macOS against lab@c499c01b, each with its cause and a verdict
rather than a blanket "environmental":
- three compare an unresolved /tmp path against a resolved /private/tmp one.
Those are test bugs and the file says so.
- one asserts ffmpeg exit 0 for a .webp still, which is a build option Homebrew
does not always carry. Needs a skip or a PNG fallback.
- one opens real sockets and needs a fast connection refusal. Environmental.
- one Playwright colour-contrast test had been written off as a flake. It is
not: three consecutive runs failed identically at ~31s. Recorded as
unexplained and possibly a real defect, because calling it noise is what
stopped anyone looking.
Also documents the prerequisites, since most surprise failures are a missing
npm ci rather than anything here, and the CHROMADB_PORT precaution: the client
reaches Chroma over HTTP regardless of the data directory, so a test run can
attach to a store holding real data.
style-part-01..08 were arbitrary 6,000-line cuts, so the names said nothing
and a reader had no way to guess which file held a rule.
The original stylesheet turns out to be roughly area-grouped already, so
cutting where the content changes rather than every 6,000 lines produces
boundaries worth naming. Classifying every top-level rule by selector prefix
and smoothing over a 40-rule window gives 17 stable runs: agent-chat, compare,
memory, documents, admin-settings, skills, gallery, cookbook, tasks,
image-editor, email, notes, calendar, research.
The numeric prefix stays because load order is load-bearing, and it also
disambiguates the areas that appear twice: the original interleaves, and
merging non-adjacent blocks of the same area would reorder the cascade.
The names describe where a file sits, not a claim that it holds every rule for
that area or only rules for it. The header in each file says so, because that
is the misreading this naming invites.
Reconstruction was checked against lab's style.css before rewriting: identical
after whitespace normalisation, all 47,528 lines accounted for. The computed
style baseline still reproduces exactly.
static/style.css was 47,530 lines. It is now nine files under static/css/:
tokens.css holds the :root custom properties, light theme and density classes,
and style-part-01..08 carry the rest in their original order. Nothing was
rewritten; every line moved verbatim and the <link> order in index.html
reproduces the original file byte for byte.
Cut points are brace-depth zero and outside block comments, so no rule or
comment is split. All 47,531 source lines are accounted for across the
fragments.
The computed-style baseline recorded before the split is reproduced exactly,
which is the evidence that the cascade is unchanged rather than an argument
that it should be.
Three things the split broke and this fixes:
- The harness self-test swapped two conflicting .attach-strip blocks inside
style.css to prove the digest is order-sensitive. It rewrote one hardcoded
URL, so with the file gone it silently measured an unmodified page. It now
searches every stylesheet, rewrites whichever holds the pair, and fails
loudly if none does.
- Four tests asserted the cache-bust token by matching /static/style.css?v=.
They check the real invariant now, that every app stylesheet shares one
token with app.js, through stylesheet_cache_version().
- The three panel stylesheets carried their own tokens, so a browser could
hold half an old cascade and half a new one. All app stylesheets now bust
together.
static/style.css no longer holds every rule. 79 rules for the document,
gallery and editor panels now live in static/css/, yet 27 browser tests still
built their synthetic page with a single <link> to style.css, and 51 more read
that one file as though it were the whole cascade. Those tests kept passing
while covering less: a rule that moved became invisible to the assertion that
was meant to pin it.
Python readers now call tests.helpers.stylesheets.app_css(), and synthetic
pages are built from stylesheet_link_tags() so they load exactly what
index.html loads, in the same order. The helper already existed; this moves
the remaining callers onto it.
test_portal_dropdown_z_js parametrised over a file list including style.css to
assert an absence. Checking a negative against one file of a split stylesheet
is how a moved rule escapes, so the CSS case now checks the concatenation.
Two guards keep it from coming back: one fails on any test reading
static/style.css directly, the other on any synthetic page linking it alone.
Both name the helper to use.
No production code changes. Suite is unchanged at 6 pre-existing failures.
Duplicate @keyframes names resolve last-wins across the whole cascade, so
every definition but the last was dead code that still read as live at its
call site. Two of the five duplicated names differed from the winner:
fadeIn style.css had an opacity-only variant before the one that
adds translateY, so every consumer was already sliding
research-pulse a background-colour pulse sat before the opacity/scale one
that actually runs
The other three (spin, loading-bounce, pulse) were byte-identical repeats.
Removing the losing definitions changes nothing rendered, which the computed
style snapshot confirms: the committed baseline is reproduced exactly across
all three pages and 24 variants. Deciding that a consumer wanted the plain
fade, or the background pulse, would be a visual change and belongs in its own
PR with screenshots.
This also unblocks the mechanical stylesheet split. While two definitions of a
name differed, neither could be moved: relocating either changes which one is
last, and therefore changes behaviour. A regression test now fails on any
duplicate name so the trap cannot come back.
_terminate_owned_daemon() read /proc/<pid>/cmdline and, on FileNotFoundError,
unlinked the pid file on the stated assumption that "the daemon may have
exited". Off Linux that file is always missing, so the branch always fired:
the pid file of a live daemon was deleted and the daemon itself never killed.
_owned_daemon_exists() swallowed the same error and therefore always returned
False, which is precisely the state its own docstring warns about, since a
close against an unrecognised session can bootstrap a fresh daemon and wait on
its browser indefinitely.
Demonstrated on macOS before the change: a pid file holding a live pid is
removed by _terminate_owned_daemon() and _owned_daemon_exists() reports False.
After it, the file survives and the daemon is reported present.
_process_command_line() now returns None for "this host cannot tell" and
_process_is_alive() answers the separate question of whether the pid exists.
Without procfs we decline to kill a process we cannot confirm is ours, and we
only forget a pid file once the pid is genuinely gone. Linux behaviour is
unchanged: the command-line identity check still gates both paths.
The snapshot harness read ODYSSEUS_TEST_STATIC_ORIGIN into a module constant.
That env var is published by the session static-server fixture, which runs
after collection has already imported the module, so the constant always held
the 7011 fallback and the capture connected to a port nothing was listening on:
route.fetch: connect ECONNREFUSED 127.0.0.1:7011
Neither branch is wrong on its own. The harness was written while the fixture
still bound a fixed 7011, and the ephemeral-port change removed that port. The
two only disagree once they are in the same tree, which is what this integration
branch is for.
With this, the baseline recorded before the stylesheet split is reproduced
exactly after it, so the split is confirmed to preserve computed styles rather
than only argued to.
start-macos.sh is the single-instance launcher and adopts whatever is
already listening: an open ChromaDB port is a resource it reuses. With
one checkout that is right. With several, it means a scratch worktree
silently attaching to another checkout's vector store, and the script
reports it as a success.
odysseus-dev is the sibling that owns isolation instead. Ports are
derived from the worktree path, so two checkouts never collide and one
checkout always gets the same URL. A ChromaDB this worktree did not
start is refused, never adopted — we start our own or fall closed to
keyword mode and say which. The data dir, database and browser-MCP
cache live under .odysseus-dev/, leaving data/ to a normal launch. A
checkout wired into launchd or systemd will not boot at all, and the
ports the project already means something by (7000, 7011, 7860, 8100)
are refused even when asked for explicitly.
Readiness is /api/ready rather than a TCP accept: the port accepting
connections says nothing about the database or a writable data dir.
That endpoint is not auth-exempt, so the tool owns a dev admin account,
hands it to setup.py and prints it.
--from-pr N fetches pull/N/head into its own worktree and boots it,
borrowing a venv so a PR is a few seconds rather than a pip install.
start-macos.sh is untouched: it is what the LaunchAgent runs.
static/style.css is 51,425 lines in one file. Hundreds of selectors are
declared more than once and !important is used throughout, so the rendered
result is a function of source order. Extracting a block into its own file
changes that order, and nothing in the suite would notice - which makes a
51k-line split unfalsifiable and "looks fine to me" the only available
evidence.
This moves no CSS. It captures getComputedStyle over a fixed inventory of
676 elements across three pages, four viewports, both themes and the three
density modes - 16,224 element snapshots - hashes them, and compares against
tests/css_snapshot/baseline.json. A capture takes about 21 seconds.
The bench page synthesises one element per selector from an evidence-driven
list: every selector declared more than once in style.css that can be
expressed as a static compound chain, plus a curated set per feature area.
Redeclared selectors are the ones a reorder can flip. The bench loads
whatever stylesheets index.html ships, so it keeps measuring the real set
once the file is split. tests/test_css_computed_style_snapshot.py also carries
a self-test that swaps two conflicting .attach-strip declarations and asserts
the digest moves, so the harness cannot silently stop watching.
The second half is the asset-manifest check specs/frontend.md asks for,
scoped to stylesheets: every stylesheet referenced by shipped HTML and by the
sw.js precache exists, and index.html and sw.js agree on the ?v= string. They
hardcode it independently today, so a split that updates one and not the
other ships an offline cache nobody notices until a plane.
The session-scoped autouse fixture bound 127.0.0.1:7011 and raised when the
port was taken. Because it is autouse, that raise errored every collected
test rather than the browser ones: a second worktree running its own suite
produced 10,612 errors, none of them about the code under test. 7011 is also
the application's own default port, so the suite could not run while a local
instance was up.
Bind port 0 instead and publish the resulting origin as
ODYSSEUS_TEST_STATIC_ORIGIN. The browser tests shell out to node, which
inherits the environment, so the snippets read process.env rather than
hardcoding a port. ODYSSEUS_TEST_STATIC_PORT still pins one when something
outside pytest has to reach the server; that is the only path that can now
fail to bind, and it fails with a message that says so.
Two concurrent full runs from one checkout now both pass. Only the docx export
snippet is an rf-string, so it is the only one whose JS braces needed doubling.
_terminate_owned_chrome() walked Path("/proc") unconditionally, so on macOS
and Windows iterdir() raised FileNotFoundError out of private-browser session
shutdown. src/tools/cookbook.py already guards the same kind of scan with
os.path.isdir("/proc").
The sweep only reclaims Chrome trees that agent-browser reparented, so it is
an optimisation rather than a correctness requirement: degrade to a no-op
rather than failing the whole shutdown path.
_PROC_ROOT is a module attribute so both branches are testable on either kind
of host. The procfs-present path had no coverage at all before this.
Last step of splitting static/style.css by panel. This moves the cookbook,
deep research, memory and settings rules that can move into
static/css/cookbook-research-memory-settings.css.
After the three steps style.css still holds the shell and every rule whose
move would change the cascade, which on this branch is most of the file.
Eager ordered links, verbatim moves, no rendering change.
Second step of splitting static/style.css by panel. This moves the email,
calendar, notes and tasks rules that can move into
static/css/email-calendar-notes-tasks.css.
Same shape as the first step: eager ordered links, verbatim moves, no
rendering change. The header comment in each split file lists the load order
and the earlier file's header is updated so all of them agree.
static/style.css is 51,425 lines and holds every panel's styles, so two
people working on unrelated panels still edit the same file. This is the
first of three steps that give each panel group its own stylesheet. It moves
the document library, image gallery and image editor rules that can move
into static/css/documents-gallery-editor.css.
The new file is loaded eagerly from index.html immediately after style.css,
so the cascade is the concatenation of the two in that order, which is the
order those rules already had. Nothing is deferred and nothing renders
differently.
Rules moved verbatim. A rule stays in style.css when its selector group
covers more than one panel, when the class is shared design language used
from modules outside the panel, or when moving it would flip which of two
equally specific rules wins on an element the markup puts both classes on.
That last rule is what keeps this change small: most panel-prefixed rules on
this branch compete with a shared component rule somewhere later in the file.
Nineteen tests read static/style.css directly and go red the moment a rule
they assert on moves, which says nothing about the page. They now go through
tests/helpers/stylesheets.app_css(), which concatenates the stylesheets in
the order index.html loads them. The helper also exposes the <link> markup
for tests that build a synthetic page through Playwright.
Covers both behaviours end to end: a refused outbound URL and an exhausted
budget must skip the request entirely rather than fall through to httpx, the
remaining budget caps each hop's timeout, and an over-ceiling Content-Length
returns 413 without the body being parsed.
Verified against the pre-fix tree: the User-Agent was hardcoded, the endpoints
were literals, check_outbound_url was absent, both hops carried independent
12.0s timeouts, and an oversized declared body returned 422 after parsing
rather than 413 before it.
The post-external-context gate is off unless ODYSSEUS_TOOL_APPROVAL_GATE is
set, but THREAT_MODEL.md described only the untrusted-context wrapper. A reader
auditing the prompt-injection posture would reasonably assume the gate was
active.
State the default, what it blocks when enabled, the two deliberate exemptions,
and note in Known Gaps that the compensating control for the missing shell
sandbox is off by default.
The 256 MiB ceiling was only enforced inside _dump_payload, which runs after
FastAPI has parsed the request and after json.dumps has re-serialised it. By
that point the payload has been materialised several times over, so the limit
rejected an allocation it had already paid for.
Check Content-Length first, on both write routes. A request that omits or lies
about the header still reaches the exact byte count, which remains
authoritative.
The arXiv and OpenAlex title resolvers called two hardcoded endpoints with a
hand-written "Odysseus/0.20" User-Agent, no outbound-URL policy, and a full
12s timeout each. APP_VERSION was already 1.0.3, so the agent string was wrong
the moment it was written, and a scholarly query could hold a user-facing
search open for the sum of all three hops.
Move the endpoints to named constants, build the User-Agent from APP_VERSION,
run both calls through check_outbound_url (they follow redirects, so the final
host is not the one in the constant), and give the whole SearXNG -> OpenAlex ->
arXiv chain one shared wall-clock budget.
The budget is a ContextVar rather than a parameter so the existing test doubles
for _openalex_title_results and _arxiv_title_results keep working unchanged.
* fix(auth): derive the session cookie Secure flag from the request scheme
SECURE_COOKIES only marked the login cookie Secure when it was explicitly
set to true, so an HTTPS login on an install that never set it handed out a
session cookie the browser is happy to send back in cleartext.
Unset now derives the flag from the request: the connection scheme, which
uvicorn's proxy-headers middleware rewrites for the proxies it trusts, or
X-Forwarded-Proto for a terminator that is not on a trusted address. That
is the same test core/middleware.py already applies before sending HSTS, so
the two stop disagreeing about whether a request arrived over TLS. An
explicit true still forces the flag on and an explicit false turns it off
for an install still answering on both HTTP and HTTPS. Strictly more Secure
flags than before and never fewer.
Empty counts as unset, because docker-compose pinned SECURE_COOKIES=false
for every container; the compose files now pass the variable through
unset, the way FASTEMBED_CACHE_PATH already does.
The helper and its decision order come from #3799, which was closed for
being too large to review and whose six replacement PRs dropped this fix.
Part of #3803.
* docs(setup): flag the leftover SECURE_COOKIES=false on upgrades
The old default was false, so an install set up before scheme derivation
can still carry an explicit SECURE_COOKIES=false in its own .env. That
value stays authoritative, so HTTPS logins keep getting a non-Secure
session cookie even after the tracked compose defaults are updated by a
pull. Say so where people look: the security notes and the variable's
own comment in .env.example.
* docs(setup): align TLS guidance with scheme-derived cookies
---------
Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
* fix(static): vendor KaTeX and Mermaid instead of loading them from a CDN
index.html pulled katex.min.{js,css} and mermaid.min.js from cdn.jsdelivr.net on
every page load. For self-hosted software that is three problems at once: an
air-gapped or offline install renders no math and no diagrams at all, every
session announces its IP, User-Agent and Referer to a third party, and the "runs
on your own hardware" promise quietly isn't true.
static/lib/ already vendors highlight.js, docx, xlsx, mammoth, html2pdf and
qrcode, so the CDN usage was an inconsistency rather than a policy. Vendoring
also pins Mermaid, which was floating on the `11` tag, to 11.16.1.
Behaviour is unchanged: both libraries still load eagerly from <head>, just from
this machine.
- KaTeX goes in its own directory because its stylesheet resolves fonts with a
relative url(fonts/...), so the vendored CSS needs no rewrite. Only the .woff2
variants ship, matching static/fonts/, since a browser that supports woff2
never requests the .woff/.ttf alternatives the stylesheet also lists.
- The service worker precaches KaTeX and its fonts so offline math is typeset
rather than falling back to system glyphs, and CACHE_NAME is bumped. Mermaid
is left to the existing cache-first rule: at 3.5 MB, precaching it would mean
re-downloading it on every cache bump for a library most sessions never touch.
- Licence texts travel with the bundles in licenses/, following the convention
the repo already uses for OpenDyslexic and DeepResearch.
- .gitattributes turns the whitespace check off for static/lib/ so `git diff
--check` passes without stripping bytes from the published npm artifacts,
which would desync them from upstream.
* perf(markdown): load KaTeX and Mermaid on first use, not on every page load
Both libraries loaded eagerly from <head>, costing every session ~985 KB on the
wire (929 KB of that Mermaid) even though most chats contain neither a formula
nor a diagram. Measured on a cold profile via the Resource Timing API: JS bytes
per page load drop from 3,102,141 to 2,098,634, a saving of 1,003,507 bytes, and
third-party requests per load go from 3 to 0.
markdown.js now fetches each library the first time one is actually needed:
- renderMermaid() checks for an unprocessed mermaid fence before touching the
network, and re-queries the DOM after the load so a diagram replaced mid-stream
still renders.
- mdToHtml() is synchronous, so when KaTeX is not in yet it banks the math source
in an inert placeholder and schedules a flush that loads the library and swaps
the placeholders in. Once KaTeX is loaded it typesets inline exactly as before,
so callers that never call a render helper still get their math.
Both loaders memoise the promise rather than the module, so concurrent callers
share one fetch and a double trigger cannot start two loads; a failed load clears
the memo so the next formula retries instead of being poisoned for the session.
The flush is scheduled with setTimeout rather than requestAnimationFrame, which
is throttled to a stop in a background tab and never fires at all in a headless
browser, so math would have sat as plain source text until the tab was focused.
If neither library ever loads, math degrades to readable source text and diagrams
to their fence contents, rather than to nothing.
* fix(markdown): unescape & last so math entities survive intact
The math pass unescaped & before < and >. mdToHtml escapes the source
first, so a literal "<" typed inside a formula arrives here as "&lt;",
turns back into "<" on the ampersand pass, and is then eaten by the very next
one. Typing $a < b$ rendered as "a < b" instead of the literal text.
The code-block pass in the same function already unescapes & last; only the
math paths were the outlier, in all four of the copies this branch consolidated
into pushMath(). Reordering to match makes them consistent and clears the
js/double-escaping alert CodeQL raised on this PR.
Math containing a genuinely typed "<" is unaffected, which is why this went
unnoticed for so long. Covered by a regression test asserting both cases.
* fix(markdown): decode entity-spelled math in one pass
mdToHtml escapes the source before the math pass, so a typed "<" reaches
the delimiters as "<" and a typed "<" reaches them as "&lt;".
KaTeX has no entity syntax and reads the leftover "&" as an alignment
marker, so "$a < b$" rendered as a red .katex-error instead of a
formula, on both the inline and the deferred path.
Chained replaces cannot fix it in either order: unescaping "&" first
lets the next pass eat the "<" it just wrote, and unescaping it last
leaves the entity spelling for KaTeX to choke on. One alternation,
longest form first, decodes every spelling and never rescans its own
output.
The tests now drive the vendored KaTeX build rather than a renderer that
echoes its input, which is why the old assertion looked correct.
* fix(document): typeset deferred math before the PDF export
exportAsPdf() renders the document into a detached container and hands
it straight to html2pdf. On a page where KaTeX has not loaded yet,
mdToHtml() returns pending placeholders and schedules a flush scoped to
document, which never reaches a node that was never attached, so the
PDF printed raw formula source.
Render the container's own math first. renderMath() returns immediately
without fetching anything when there is nothing pending, so a document
with no formulas still exports without pulling KaTeX.
* perf(frontend): share one cached fetch for settings and tools
/api/auth/settings was fetched independently by eight modules and /api/tools by
three on a single load — 4 and 3 requests measured — and any two of those
callers could observe a different snapshot of the same object. chatRenderer.js
is imported under three different ?v= query strings, so it is three separate
module instances each issuing its own /api/tools request.
appConfig.js holds one promise per endpoint, so concurrent and later callers
share it. Every writer invalidates: the settings panel routes its 16 saves
through a single helper, and the admin tools save drops both snapshots because
that route persists disabled_tools into the same settings store. A rejected
fetch clears its slot rather than being memoised, so one blip at boot cannot
leave keybinds, TTS and the search provider on defaults for the session.
The settings panel keeps reading directly: it is the writer and edits what it
reads, so it must see authoritative state.
Cold load, Resource Timing: /api/auth/settings 4 -> 1, /api/tools 3 -> 1, and
0 settings requests on the first load after a login, because the cache now
consumes the sessionStorage prefetch that login.html writes.
Fixes#5996
* fix(admin): refetch tool state when the Agent Tools panel opens
The shared cache made Admin > Tools render the boot snapshot on every
reopen. Its save posts the whole disabled list rebuilt from the checkboxes,
so a tool disabled out of band (the manage_settings tool, another tab) came
back enabled on the next unrelated toggle. Reproduced against the running
app: with api_call disabled by a separate client, toggling app_api off
posted ['app_api'] and silently re-enabled api_call.
The panel now drops the shared entry before reading it, which restores what
dev does today and keeps the startup read that chatRenderer.js shares. Cold
load is still 1 request each for /api/auth/settings and /api/tools, and the
panel costs the same 2 requests per open as dev.
* fix(static): preserve concurrent tool setting changes
---------
Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
galleryEditor.js and its js/editor/ graph are 54 modules / 576 KB, and
gallery.js imported them statically. Every page load paid for the whole
image editor even though most sessions never open the Edit tab: 54 of the
173 JS files on a cold load, and 576 KB of the decoded JS, were for a
panel that was never displayed.
Add a small panel-loader registry (static/js/panels.js) that imports a
panel's module on first use and memoises the promise, so a double-click
cannot start two loads and a failed load can still be retried. Convert
the image editor to it, and route the two existing dynamic imports in
chat.js and chatRenderer.js through the same entry so all three call
sites share one module instance instead of two.
closeEditor() and isEditorOpen() stay synchronous: if the module was
never loaded there is no edit session to close and none can be open.
The service worker keeps precaching the editor, in a separate
PANEL_PRECACHE list, so the panel stays available offline even though
index.html no longer loads it. The two lists now serve different
purposes and the header comment says so.
The PR-description check folded the template's asterisks into the pattern,
so a ticked box written without them read as unchecked while rendering
identically on the PR page. `ready for review` was silently withheld and
the bot reported missing visual evidence even with screenshots attached,
with no way to tell from the rendered PR what was wrong.
The two attestations directly above it already anchor on the wording
alone. This one now does the same, accepting `**bold**`, `*italic*`,
`__underscores__` and plain text.
Fixes#6071
* fix(mcp): stop assuming http://localhost:7000 for the OAuth callback
The MCP OAuth callback origin is wrong on any install not reached at
http://localhost:7000, and on Docker it cannot be corrected at all.
Three sites, one assumption:
- The redirect base fell back to a fixed port 7000. The app binds APP_PORT
natively (app.py, launcher.py) and the macOS launcher defaults to 7860,
where 7000 is AirPlay Receiver, so the callback lands on another service
entirely. The fallback now follows APP_PORT. The hostname stays localhost
rather than internal_api_base()'s 127.0.0.1: this URI is registered with
the authorization server, so changing the host would invalidate the
registrations that already exist.
- The paste-back form hardcoded an http:// action. Serving the page over
HTTPS, Chrome raises its insecure-form interstitial, and overriding that
posts plain HTTP at a TLS port, which fails too. Either way the
authorization code never reaches Odysseus. The action now carries the
scheme the request arrived on.
- OAUTH_REDIRECT_BASE_URL is the only fix available to a Docker install,
because the container listens on 7000 and cannot see the host port map,
but compose never forwarded it and nothing documented it. Both fixed.
* fix(mcp): make the paste-back form action relative and export APP_PORT
Answers the review on #6032. Three of the fixes did not survive contact with
the deployments they targeted.
- The form action derived its scheme from request.url.scheme. uvicorn only
honours X-Forwarded-Proto from a peer inside --forwarded-allow-ips, which
defaults to 127.0.0.1; the Dockerfile CMD sets no override, so a proxy
arriving over the Docker bridge is untrusted and the scheme stays http.
That is mixed content on exactly the HTTPS installs paste-back exists for.
A relative action is resolved by the browser against the origin the page
came from, which is right under every proxy setup, and it drops the Host
header from the page entirely.
- The APP_PORT fallback never fired for the shipped launchers. start-macos.sh,
the generated .app launcher and launch-windows.ps1 all pass --port to
uvicorn without putting the value in the environment, so the motivating
case, macOS on 7860, still registered localhost:7000. Each now exports it.
internal_api_base() and companion pairing read APP_PORT too and were wrong
in the same way.
- .env.example pointed Google MCP servers at OAUTH_REDIRECT_BASE_URL.
add_server writes Desktop App credentials, and Google only accepts loopback
redirects for that client type, so a public origin comes back as
redirect_uri_mismatch. The variable is for the DCR flow; Google stays on the
loopback default and finishes remotely through paste-back.
The Host header is no longer reflected into the page, so the escaping
regression test asserts its absence instead of its escaping.
The timeline's terminating dot used a single left offset (-17px) at both
breakpoints, but the thread's padding-left differs (22px desktop, 18px
mobile) and the step dots already carry a per-breakpoint offset. The 6px
dot therefore landed 2px right of the 2px rail on desktop and 2px left of
it on mobile, which is the visible kink under an expanded last step.
Derive each offset from the rail's centre instead: the rail sits at
left:5px and is 2px wide, so the dot's left edge belongs at 3px, giving
3px - padding-left per breakpoint.
* docs(setup): document the HTTP/2 reverse-proxy setup
The "private or proxied deployments" section named Caddy, nginx and Traefik
but gave no runnable config, and never mentioned the main reason to bother:
the frontend is unbundled ES modules, so a page load is a few hundred small
same-origin requests. Over HTTP/1.1 the 6-connection cap serialises those
into dozens of round trips, which is invisible on localhost and dominates
load time over a LAN or VPN.
Adds a five-step setup you can paste: a Caddyfile for each of the three ways
people reach these boxes (public domain, Tailscale, own certificate), how to
run the proxy in the foreground and then as a service, the .env keys that
have to follow the origin, and a curl one-liner to confirm HTTP/2 actually
negotiated.
Also covers what bites when moving an existing install behind TLS:
SECURE_COOKIES applying regardless of the scheme the request arrived on,
OAUTH_REDIRECT_BASE_URL still defaulting to localhost because the MCP
redirect is registered up front rather than derived per request, and HSTS
being host-wide and port-agnostic. Notes that a custom HTTPS port does not
stop Caddy binding port 80 for the redirect, which is the failure I hit
first.
Docs only — no code change is needed to run behind HTTP/2 today.
* docs(setup): clarify HTTP/2 and origin migration
---------
Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
The app font faces are declared in static/style.css, so the browser only
discovers FiraCode-Regular.woff2 and FiraCode-SemiBold.woff2 once the
stylesheet has parsed. On a cold load they start about 145 ms in, behind
the module graph. font-display: swap keeps that from blocking render, so
the cost is a visible swap rather than a stall, but the fetch can start
immediately instead.
Two preload hints move the request into the head. Measured cold on a
scratch instance with an empty cache, three runs per arm: request start
142-203 ms becomes 15-19 ms, response end 174-248 ms becomes 46-63 ms.
The total request count is unchanged and each face is still fetched
exactly once.
crossorigin is required even though these are same-origin: fonts are
always fetched in CORS mode, and without it the preload is discarded and
the font fetched again. Dropping the attribute produces four font entries
in the Resource Timing list instead of two.
Only Fira Code 400 and 600 are preloaded. They are the only faces first
paint uses. Inter, OpenDyslexic and Fira Code 300 stay unloaded on both
desktop and mobile, with or without a saved font preference.
_drawWhirlpool re-armed requestAnimationFrame forever whenever its element
had never been connected to the document. The grace period is there so a
spinner can keep drawing between start() and the caller appending the
element, but it had no deadline: while the element has never been connected
_wpWasConnected stays false, so the guard stays true and the else branch is
unreachable. Any caller that starts a spinner and then takes an early return,
such as an aborted request or a panel that resolved from cache, leaves a loop
redrawing an 84-segment spiral into a detached canvas at one frame per
displayed frame until the tab closes.
Put a 2 second deadline on the grace period. Callers append in the same task
as start(), so that is far more slack than any of them need. A spinner that
is actually in the document is unaffected.
Two supporting changes in the same file:
- Both self-terminate paths now call stop() instead of setting isRunning
directly, so termination always runs one cancelAnimationFrame and never
depends solely on inferring DOM connectivity. Both draw functions bail at
the top when they are no longer running, and _requestFrame() clears rafId
as the callback enters so it is a truthful "a frame is pending" flag.
- start() arms a visibilitychange listener and stop() removes it. A hidden
tab cancels the pending frame, a re-shown tab re-arms it. Chrome throttles
background rAF but does not reliably stop the canvas work, and owning the
listener from start/stop means a dead spinner never leaves one behind.
Adds tests/test_spinner_stops_when_never_attached_js.py, which drives the
real module under node with a fake clock and a manual frame pump. It covers
all four exits and, importantly, the converse: a spinner that is attached
keeps running well past the grace window.
Both the authorize and callback routes built the redirect URI with a
hardcoded `http://` and the Host header. Behind any TLS terminator that
produces `http://host:443/api/email/oauth/google/callback` — the wrong
scheme and, on a split-port setup, a dead port. Google then refuses the
authorize request or the token exchange, so OAuth email is unusable on
every HTTPS deployment unless GOOGLE_OAUTH_REDIRECT_URI is pinned by hand.
uvicorn's proxy-headers middleware already rewrites the scheme from
X-Forwarded-Proto for trusted proxies (on by default, trusting 127.0.0.1),
so request.url.scheme is correct both directly and behind a proxy.
Google requires the callback's redirect_uri to match the authorize one
exactly, so both sites change together. An explicit
GOOGLE_OAUTH_REDIRECT_URI still wins, unchanged.
* fix(notes): fail closed when an unauthenticated request reaches owner-scoped routes
The notes CRUD routes resolved the acting user with bare get_current_user().
A request that reached them with no identity (auth-middleware regression,
SSRF from a sibling service) came through as user=None — which every query
treats as the single-user mode: list all accounts' notes, read/update/
delete/pin/archive any row, reorder globally.
Resolve the owner through require_user() instead, which already encodes the
right policy: 401 when auth is configured, while the documented anonymous
modes (AUTH_ENABLED=false, LOCALHOST_BYPASS on loopback, unconfigured
first-run) still resolve to the single-user path. fire-reminder in the same
file already gated this way; the CRUD routes now match, and the inline
require_user import there is folded into the module import.
Extracted from #2940 (stabilization slice).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(notes): drive fail-closed test via ASGITransport, not sync TestClient
The focused fail-closed test hung at `TestClient(app).get(...)` on some
environments. Starlette's sync TestClient runs the app in a background
event-loop thread (anyio blocking portal) and then dispatches each sync
endpoint onto a second worker thread; that handshake deadlocks on certain
anyio/httpx/platform combos. The identity injection also used
BaseHTTPMiddleware (@app.middleware("http")), the other known TestClient
deadlock source.
Switch to the repo's existing httpx.ASGITransport + AsyncClient idiom so the
whole request runs on the test's own event loop (no portal thread, no
BaseHTTPMiddleware). Identity now comes from a pure-ASGI shim that writes the
same request.state fields the real auth middleware sets, and a non-loopback
client peer keeps require_user's loopback fall-throughs out of the picture.
Same assertions and coverage; production code unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
_getPlatform('local') fell back to navigator.userAgent to decide the
*server's* platform. On a Mac/Linux homeserver opened from a Windows
browser this returned 'windows', so the GGUF serve builder emitted the
Windows python-only shape (`python -m llama_cpp.server`, no
`llama-server ||` fallback). That command fails on the Unix host with
"No module named llama_cpp" even though native llama-server is installed,
and the diagnosis then misleadingly tells the user to pip-install
llama-cpp-python.
Trust the server-side hardware probe over the user-agent: a non-empty
probe backend (metal/cuda/rocm/cpu_*) means a Unix server; local Windows
instead carries platform:"windows" which already sets _envState.platform
and short-circuits. Only fall back to the browser hint when there is no
server-side signal at all. Keeps #1389/#2961's local-Windows path intact.
Fixes#3221
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Load .env in start-macos.sh for APP_PORT and APP_BIND
Parses .env at startup (consistent with how app.py reads it via
python-dotenv) so APP_PORT and APP_BIND are honoured without having
to retype them on the command line every run.
Resolution order: shell env (ODYSSEUS_PORT / ODYSSEUS_HOST) → .env
(APP_PORT / APP_BIND) → built-in defaults. Existing ODYSSEUS_* shell
overrides are fully preserved.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Document .env support for APP_PORT and APP_BIND in macOS section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>