Extract the generic process lifecycle layer (src/process_lifecycle.py)
shared by runtime-owned subprocesses: process identity (pid + boot-bound
start token), identity-bound observation, group and pidfd probes, the
TERM -> verify -> KILL -> verify escalation with re-gating before
escalation, identity-scoped sweeps, and the termination receipt.
Containment, the PTY shell, the Cookbook survivor sweep, the browser
lifecycle, web_tools browser cleanup, kill_process_tree and the startup
reaper consume it while keeping their own ownership semantics.
Safety corrections:
- browser membership and identity are bound in one snapshot; no identity
is recaptured after membership is decided
- web_tools legacy pid-file and profile-match kills signal only verified
identities; browser CLI groups only while their spawn identity verifies
- Cookbook and legacy-tmux descendant capture bind membership to identity
- PTY teardown never signals the server's own process group
- unverifiable processes are reported, never signalled
A batch whose open succeeded but whose later command failed was recorded
as a failed navigation, so a following observation was wrongly labelled
stale. Use the per-command rows; when the outcome cannot be determined,
treat the page as unknown instead of claiming either result.
Own each agent-browser session as a browser tree: the daemon's POSIX
session, its runtime files and its Chrome profile. Timeouts, launch
failures, bootstrap recovery, cancellation and shutdown clean that tree
and verify nothing survives, instead of killing only the daemon and
orphaning Chrome. Per-call cleanup no longer sweeps every Chrome under
the runtime TMPDIR.
Sessionless calls get an ephemeral browser closed before returning.
Actions on one session are serialized. Recovery is bounded by one
deadline with at most one retry for local HTML open, and the retry flag
is no longer model-visible. Observations after a failed navigation are
marked stale. read URL navigates and extracts in one batch because
agent-browser has no read command. Results carry a browser_lifecycle
receipt with stages, timings, ownership and cleanup evidence.
Playwright MCP tool calls are bounded by
ODYSSEUS_BROWSER_MCP_CALL_TIMEOUT_S and are not retried. research_navigator
now passes timeout_ms.
"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.
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`.
Snapshot current maintainer-preview application changes and regression fixtures for integration into lab. Excludes local runtime data, evaluation outputs and source backups. Focused Python regression selection: 140 passed; full suite not certified.
_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.
_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.
_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.
* refactor(model-routing): centralize explicit foreground fallback policy
Make foreground fallback an explicit per-user, availability-only policy shared by streaming Chat, non-stream Chat, and Agent runs.
Preserve strict defaults, owner/model and credential boundaries, pinned Agent routes, and truthful per-round provenance/accounting. Carry provider-reported model identifiers through native streaming adapters, non-stream responses, and caches, and keep legacy default_model_fallbacks as tombstoned raw storage that generic settings APIs and agent tools cannot expose or mutate.
* fix(agent-loop): restore rebase-dropped qwen routing, workspace prompt, and temperature clamp
* fix(model-routing): thread selected endpoint identity, fix cost classification and fallback eligibility
* fix(chat): restore stream helpers and harden run stop lifecycle
* fix(model-routing): let numeric provider codes win over symbolic rate-limit statuses
* fix(agent-loop): apply qwen temperature and notes-tool clamps per fallback candidate
* fix(chat): honor queued stop across resend and reload canonical terminal on EOF
* fix(chat): track stop queue and cleanup ownership by per-send generation
* fix(agent-loop): preserve requested temperature for non-qwen fallback candidates
* fix(chat): reserve send ownership before any await and scope stop to the current send
* fix(chat): clear the previous run identity at send reservation
---------
Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
Co-authored-by: StressTestor <212606152+StressTestor@users.noreply.github.com>
The grep tool's ripgrep fast-path excluded deny-listed key files with
`--glob "!*<pat>*"` for each entry in _SENSITIVE_FILE_PATTERNS. ripgrep's
--glob is case-sensitive, so on a case-insensitive filesystem (Windows,
default macOS) a key stored under a case variant of its name (ID_RSA,
Known_Hosts, Authorized_Keys) is the same file on disk but slips past the
lowercase exclusion, and ripgrep returns its contents. Those names are
non-dotfiles, so ripgrep's default hidden-file skipping does not cover them
either. The Python fallback already blocks them via the case-folded
_is_sensitive_path (#5097), so the two paths disagreed.
Switch the sensitive-pattern exclusions to --iglob so they match
case-insensitively, mirroring _is_sensitive_path. Add a regression test
that seeds ID_RSA and Known_Hosts and asserts grep returns ordinary
matches but not the key contents.
send_to_session let an authenticated caller reach a null-owner session.
The owner gate was `if owner and sess.owner and sess.owner != owner`, so a
target whose owner is None (legacy rows, or a session created while auth
was off) skipped the check and was read/written by any authenticated user.
list_sessions (get_sessions_for_user) and manage_session already exclude
null-owner sessions from an authenticated caller via an exact owner match,
so this path was the lone inconsistency — the same class of gap the
calendar owner=None fix closed.
Require an exact owner match: `if owner and sess.owner != owner`. Auth-off
(no owner) is unchanged, an exact-owner match still passes, and both
another user's session and a null-owner session are now not-found. Adds a
regression test that an authenticated caller cannot read the transcript of
or write into a null-owner session while single-user access still works.