Skip to content

feat(auth): mount the device grant under OIDC, and keep its re-auth gate honest - #15

Closed
appletechie wants to merge 108 commits into
mainfrom
feat/device-grant-oidc
Closed

feat(auth): mount the device grant under OIDC, and keep its re-auth gate honest#15
appletechie wants to merge 108 commits into
mainfrom
feat/device-grant-oidc

Conversation

@appletechie

Copy link
Copy Markdown
Owner

The device-authorization grant (RFC 8628) refuses to mount unless auth mode is accounts. OIDC deployments are excluded on the reasoning that they delegate login to the IdP via the cli-ticket flow and so never need it.

That reasoning covers a CLI logging a human in. It does not cover a third-party application asking to act as a user — which is what the grant is for. The cli-ticket flow hands back the server's own session JWT: full account authority, no scope claim, no grant_id, no revocation handle, no act provenance. Everything that makes a delegated token safe to hand to an application is exactly what it lacks.

So an OIDC deployment has no way to authorize an application at all. And the grant needs nothing from accounts that oidc does not also provide: both mint the same HS256 session cookie, and _check_cookie already picks between the two configs the same way this now does.

Header mode stays excluded for a real reason rather than symmetry — identity there is asserted by an upstream proxy, so there is no session to delegate from and no login to bounce a consenting browser through.

The part that isn't a gate flip

Consent requires a login performed after the grant began (session iat ≥ grant created_at); a stale session bounces to the login page with reauth=1. That gate is the anti-phishing control — a victim handed a one-click link with the code prefilled must deliberately re-enter their credentials against a screen naming the identity and the client, instead of approving by reflex.

reauth=1 was implemented entirely in the accounts SPA login form, which holds back its auto-redirect and demands a password. /auth/login never read the parameter.

So flipping the mount gate and stopping there would have produced:

consent → stale iat → bounce /auth/login?reauth=1
        → IdP recognises its own session
        → silent redirect back
        → callback mints session with fresh iat
        → gate satisfied

The user proves nothing. Nothing errors, no test fails, and the control is gone while still appearing to be there. That is the failure mode worth flagging: it would have shipped green.

/auth/login now forwards reauth=1 to the IdP as prompt=login (OIDC Core 3.1.2.1) — the standard way to ask an IdP to re-prompt a user it has already authenticated.

Two deliberate narrownesses:

  • Only on that path. Unconditional prompt=login would cost a password on every ordinary sign-in, which is how a control like this ends up switched off by whoever finds it irritating.
  • Only exact "1". The consent page is the only caller and sends exactly that; accepting loose truthy spellings would just widen the surface for an unrelated query param to trigger a re-prompt.

Changes

File
app.py mount gate accepts oidc
device_auth.py factory accepts oidc, selects _oidc_config
routes/auth.py reauth=1prompt=login
designs/DEVICE_AUTH.md mode claims + the cross-mode re-auth note
test_device_auth.py mode test split: header rejected, oidc builds
test_oidc_reauth_prompt.py new — pins the parameter

Verified

  • 220 auth-suite tests pass — device_auth, oidc, callback, invites, open-redirect, accounts, plus the new file
  • Removing the prompt=login block fails the new suite — the tests aren't vacuous
  • return_to still round-trips the bounce, so a re-auth doesn't abandon the pending grant
  • ruff, ruff format, mypy clean on every touched file

The e2e browser proof (tests/e2e_ui/auth/test_device_grant_reauth.py) stays accounts-only — it drives a real password form, which OIDC doesn't have. The OIDC half is covered at the route level.

Why now

Unblocks polly-webhook connecting to a self-hosted Omnigent as a delegated client. It needs /v1/sessions, /v1/agents, /v1/hosts — already exactly the _DELEGATED_ALLOWED_PREFIXES allowlist — and the deployment it must reach first runs OIDC.

dbczumar and others added 30 commits August 4, 2026 18:13
)

A dropped host rendered two different indicators depending on incidental
state. The badge read the host tunnel directly (name + red dot), while
ChatPage passed a separate `hostOffline` prop derived from
`liveness.kind === "host_offline"` that replaced the name with generic
"Host is offline — click to reconnect" copy.

`host_offline` is far narrower than "the host tunnel is down": it also
requires the runner to be down (a live runner short-circuits to `online`),
the startup grace to have lapsed, and the host to be non-resumable. So the
same event — the host dropping — showed a passive, unclickable name when
the runner outlived the host, and a nameless reconnect prompt when it
didn't. The name is what tells the user which machine to go restart.

The badge now owns the decision: one shape (name + status dot) that turns
into a button opening the reconnect instructions whenever its bound host is
offline and reconnectable. A dormant resumable managed host stays passive —
the next message wakes it, so `omnigent host` would be wrong advice.

The reconnect dialog's state now comes from the session's host binding
rather than liveness, so a session whose runner outlived its host gets the
`omnigent host` command instead of the local `omnigent run --resume` one.

Signed-off-by: dbczumar <corey.zumar@databricks.com>
…ent-ai#4091)

* fix(server): file forked sessions into the source's project

Forking a session filed in a first-class project left the fork unfiled:
fork_conversation built the fork's metadata row without project_id. The
fork route now carries the source's project onto the fork, gated on the
forker owning that project (projects are owner-private, so a fork of a
shared session filed in someone else's project stays unfiled).

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(web): refresh the project folder when a session is forked

A fork inherits the source's project, but the dialog only invalidated the
flat session list — each folder renders its own ["project-sessions", name]
query, which has no poll and converges only on an explicit invalidation.
The push stream can't cover it either: it skips the active session, and the
fork becomes active on navigate. So the clone stayed missing from its folder
until a reload or a re-navigation.

Adds an e2e regression test. It seeds the committed turn the fork action
anchors on straight into the store (new seed_committed_turn helper) instead
of driving a model turn, so it neither waits on nor inherits the flakiness
of the mock-LLM harness.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(web): stabilize reasoning indicators during active turns

Signed-off-by: dbczumar <corey.zumar@databricks.com>

---------

Signed-off-by: dbczumar <corey.zumar@databricks.com>
…nigent-ai#4093)

Switching from a Codex session to a Claude Code session briefly painted
the Codex model (e.g. gpt-5.5) in the Claude session's composer before
correcting itself.

`switchTo` clears the session-scoped model fields but deliberately keeps
`selectedModel`, the cross-session sticky pick, so a CLI-created new chat
inherits the user's last choice. The native picker kind flips to Claude
immediately (the session query and sidebar row are already cached), so
for the whole snapshot round trip the composer resolved the sticky and
read the outgoing session's model.

Only surface the sticky once the session's own catalog vouches for it.
Pre-bind the catalog is empty, so the label waits instead of advertising
a model this session would reject; post-bind it is a no-op, since the
store only ever leaves a catalog-compatible sticky (or the override) in
`selectedModel`.

Co-authored-by: Isaac

Signed-off-by: dbczumar <corey.zumar@databricks.com>
Remove the cron schedule triggers from both discord-watch-rotation
workflows so they no longer fire automatically. workflow_dispatch is
kept for manual runs, and the original crons are left commented out so
the schedules can be restored later.

Co-authored-by: Isaac
* fix(omnidev): pin backend Python 3.12

Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com>

* fix(omnidev): reuse Python version pin

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

---------

Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com>
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
Co-authored-by: Pat Sukprasert <pat.sukprasert@databricks.com>
…-ai#3578) (omnigent-ai#3597)

PR omnigent-ai#3105 removed the `server start` subcommand in favor of
`server --background` and updated the Electron shell-out in the same
commit. The desktop app ships on its own electron-updater channel, so a
client built before v0.7.0 is a normal steady state against a v0.7.0
CLI — and it still runs `omni server start`, which now dies with
"No such command 'start'". "Start locally" is broken for those users.

Restore the subcommand as a hidden alias that routes to the same helper
as the flag, so the two spellings cannot drift. The deprecation notice
goes to stderr; the desktop parses the URL off stdout, which is
unchanged.

Signed-off-by: Yi Lyu <isabellalyu1130@gmail.com>
Co-authored-by: Serena Ruan <82044803+serena-ruan@users.noreply.github.com>
…se policy (omnigent-ai#1222)

* feat(qwen,goose): record delegated fs I/O and gate it with result-phase policy

Omnigent's OSEnvironment but left two layers as documented follow-ups: the
delegated I/O was invisible in history and no content policy ran on it.

Wire both onto the existing _handle_fs_read / _handle_fs_write handlers:
- emit a paired ToolCallRequest + ToolCallComplete per op so the I/O shows in
  history (the adapter renders them as observed function_call items)
- run PHASE_TOOL_RESULT content policy on the bytes; an explicit deny refuses
  the op (a write is gated before it happens), failing open otherwise

Content-only: the harness policy round-trip carries no request_data, so the
payload is {"result": content}. Closes the file-I/O recording / content policy
item in docs/QWEN_FOLLOWUPS.md.

Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com>
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

* fix(qwen,goose): gate delegated fs at the call phase and audit stale ops

Addresses the review on the delegated-fs recording/policy work.

1. Phase semantics. A delegated write was gated by a result-phase policy eval
   before the write, which is content-only and fails open, so a policy timeout
   would let the write through. Gate writes (and reads) at PHASE_TOOL_CALL with
   the tool name, path, and content, failing closed on an eval error or an ASK
   verdict (delegated fs has no elicitation path). Reads keep the result-phase
   content check that decides whether the read bytes reach the model.

2. Audit records. Stale prior-turn server fs requests were answered at turn
   start, running real I/O, and then had their ToolCall events cleared before
   they reached history. Drain those events into history instead of dropping
   them, so the I/O they performed is recorded.

Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com>
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

* fix(qwen,goose): evaluate result-phase policy after a delegated write

The write handlers gated at PHASE_TOOL_CALL and then wrote, but never ran a
result-phase evaluation, so the value env.write() returned was never policy
checked and the audit record dropped it. Reads already did both phases.

Run PHASE_TOOL_RESULT after the write carrying the actual result. A denial
records BLOCKED and refuses the response; it cannot undo the write, since it
runs after the operation. The success record now carries the real result too,
matching the read path.

_fs_content_policy_denies was read-specific, so it is now
_fs_result_policy_denies and takes any result. Read behavior is unchanged.

Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com>
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

---------

Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com>
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* Add deep-research example (single agent over an MCP search server)

A single-agent example that answers a question with a cited, cross-checked
report: it plans sub-queries, searches the live web and reads full pages
through an MCP search server, and verifies claims across independent sources.

It is the repo's first example that wires an MCP server via tools/mcp/*.yaml
(auto-discovered), so it also documents the MCP extension path. One agent plus
one MCP server, no sub-agents — the simplest example to copy from. Runs
zero-config against a public, keyless endpoint.

Signed-off-by: Ilya Bogin <ilya.bogin@keenable.ai>

* test: add e2e coverage for the deep-research example agent

The examples-coverage-sync drift guard (test_every_agent_has_a_dedicated_test_file)
requires every example agent to have a dedicated e2e test. The deep-research
example shipped without one, failing E2E Tests (shard 0/4).

Add a structural test via validate_agent_def_structure (infra-free: the agent's
tools come from the hosted Keenable MCP server and it runs on the claude-sdk
harness, so it can't run end-to-end in CI). Because the agent name 'deep-research'
has a hyphen (not a valid Python test-module name), the test lives in
test_deep_research_example.py and the guard is told via a 'deep-research' entry
in _ALT_COVERED, mirroring the existing 'openai-coder' handling.

Signed-off-by: Ilya Bogin <ilya.bogin@keenable.ai>

* docs: show deep research search provider options

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

---------

Signed-off-by: Ilya Bogin <ilya.bogin@keenable.ai>
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
Co-authored-by: Pat Sukprasert <pat.sukprasert@databricks.com>
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
…mnigent-ai#4097)

The "new session in project" pencil navigates to /?project=<name> while
the landing screen stays mounted. The project-prefill state machine only
restarted when the ?project= param changed, so re-clicking the SAME
project's pencil after editing its default settings kept the stale seeds
— the fix only showed up after clicking another project (or Home) and
back, which flipped the param away and back.

Track a signature of the config the machine last settled from and restart
the prefill when that content changes for the same project, mirroring the
project-switch reset. The saved config is already fresh in the react-query
cache; this makes the machine re-read it.

Co-authored-by: Isaac

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
…gent-ai#4099)

* dev/repro-agent: pin the verdict handoff to a single JSON block

The output contract only said "a single structured verdict block" without
pinning a format, so the agent rendered YAML on some runs and JSON on others,
and the shape drifted (missing facets, prose bullets instead of objects). That
makes the `verdict` field — which the caller parses to label the issue —
unreliable to extract.

Pin it: exactly one fenced ```json block as the final message, JSON only, every
key always present, and `verdict` restricted to the four lowercase literals so
it matches verbatim. `facets` becomes an array of {symptom, verdict, evidence}
objects instead of free-form bullets. README step 4 updated to match.

Co-authored-by: Isaac

* dev/repro-agent: require the JSON block be the last chunk, allow prose above

Some runs split the artifacts into separate markdown sections (a small
"Reproduction Verdict" block, then prose "Journey"/"Facets" headers) with no
single consolidated handoff, so there was no reliable last block to parse.

Clarify the contract: comprehensive prose above the block is fine, but the
```json block must be the LAST chunk of the final message (nothing after its
closing fence) and must carry the complete self-contained handoff. Explicitly
forbid splitting the artifacts across separate sections/headers. There is no
output-schema enforcement for the claude-sdk agentic loop (AgentSpec.output_type
is inert), so this is enforced by instruction plus last-```json-fence parsing on
the caller side.

Co-authored-by: Isaac
* refactor(web): decouple typography from interface geometry

Make the desktop font preference drive semantic text tokens while keeping icons, controls, and spacing fixed.

Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>

* refactor(web): migrate interface body text to text-ui

Use the settings-controlled semantic body token across shared components and application pages for consistent sizing.

Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>

* style(web): refine sidebar typography and empty states

Align sidebar hierarchy with settings-controlled tokens and make empty projects easier to scan.

Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>

* style(web): tighten sidebar density and theme polish

Unify sidebar row geometry, refine theme-specific colors and canvas treatments, and standardize compact controls.

Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>

* test(e2e-ui): align font size checks with typography tokens

Update browser assertions for the discrete desktop font token and its current bounds.

Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>

* test(ui-snapshot): update typography visual baselines

Adopt the CI-rendered snapshots for the intentional settings-driven typography changes.

Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>

* fix(web): preserve dark active sidebar hover

Keep selected row colors stable when hovering in dark mode across both sidebars.

Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>

* style(web): polish sidebar actions and overlays

Align sidebar controls, dropdowns, and tooltips with shared density, typography, and interaction tokens for a more consistent visual hierarchy.

* style(web): normalize mobile sidebar scale

Keep mobile sidebar typography and icon geometry predictable without changing the desktop presentation.

* style(web): refine responsive sidebar and chat density

Use responsive sidebar spacing and settings-driven chat typography so mobile and desktop retain clear, consistent reading rhythm.

* test(web): align CI expectations with sidebar polish

Update E2E assertions and reviewed visual baselines to reflect the intentional typography, navigation, and density changes.

Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>

---------

Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
* fix(sdk): validate response model scalars

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

* fix(sdk): narrow session stream events (omnigent-ai#4101)

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

---------

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
…t-ai#4103)

test_scheduled_task_create_edit_modal_and_time_picker flaked ~30% of runs,
always timing out on `_pick_minute`'s `name_input.click()` with
"dialog-overlay intercepts pointer events". While the time-picker Popover is
open, the Radix Dialog owns pointer hit-testing over the modal, so a normal
actionability-gated click at the input's coordinates resolves to the overlay
and blocks the full 30s under load.

Force every dismiss click on the name input (`click(force=True)`) — the same
technique the picker's open click already uses. A forced click still
dispatches a real pointerdown on the input, which Radix registers as the
interaction-outside that closes the popover, without waiting on overlay
actionability. Covers all three dismiss sites: the retry path and final
dismiss in `_pick_minute`, plus the two post-typed-time blurs in the test body
(focusing the time input reopens the picker via onFocus).

Verified: reproduced the flake (multiple failures across batches of 5-8 runs),
then 12/12 green after the fix; the full file's 9 tests pass.

Co-authored-by: Isaac

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
…nd wrappers (omnigent-ai#3559)

* Root cause fix — omnigent/policies/builtins/_shell.py

sudo/env/command/time/exec moved out of CMD_WRAPPERS (skip-one-word) into _FLAG_WRAPPERS with their value-consuming flags. CMD_WRAPPERS is now just {"nohup"}, which genuinely takes no options. -- needs no entry — it's consumed as a valueless flag.

While verifying, I found the same hole one level down, which also affects the original GHSA-fixed wrappers: _skip_flag_wrapper_args matched value flags by whole-token equality, so bundled short options bypassed too — sudo -nu root git push, env -iu FOO git push, and (pre-existing) nice -qn 10 git push. It now scans the bundle's characters and consumes a separate value only when the value-taking option is the bundle's last character, so -n 10/-o L still consume while -n10/-oL stay attached. This mirrors orchestration.py:236-245, which already got this right for blast_radius.

Fail-safe backstop — new is_unresolved_invocation(), wired into both consumers

The wrapper tables are an enumeration, so I didn't want the next unmodelled wrapper to be another silent ALLOW. A head still starting with - now routes through each policy's existing "can't parse this" path rather than abstaining — ASK in github.py, the configured action in working_dir.py. Reachable today via nohup -- git push …. Detection is shared; the response stays per-policy, per the module's stated contract.

* 1. env -S / --split-string (the blocker). Reviewer was right: modelling -S as a value flag swallowed the command into the flag's value, leaving zero tokens — which is_unresolved_invocation([]) can't see. Fix takes the reviewer's option (b): env -S is a command interpreter like sh -c, so it's unwrapped and re-parsed on the path that already exists for bash -c / eval.

- _skip_flag_wrapper_args gained a capture_flags set and now returns (index, captured) — reusing the existing flag walk (which already handles --flag=v, -S v, -Sv, bundles like -iS v) instead of writing a second scanner.
- real_invocation_tokens stops at env when a split-string is captured; unwrap_shell_command returns it → recursion gates the inner command.

env -S 'git push <evil> main' → DENY. env -S 'npm test' → still abstains.

2. /usr/bin/sudo -u root git push — same fail-open, not flagged in either review. Wrapper lookup matched the bare word only, so a path token became the apparent command and the segment abstained → ALLOW. Wrappers now match on basename (unwrap_shell_command already did).

* fix(policies): add BSD sudo -a/--auth-type and -c/--login-class to value-flag set

These two options were missing from _FLAG_WRAPPERS["sudo"], leaving a
residual silent-ALLOW bypass: sudo -a foo git push ... left "foo" as
the apparent command head, which does not start with "-" so is_unresolved_invocation
could not catch it. Add both flags and tests for each form.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
Co-authored-by: Tomu Hirata <tomu.hirata@gmail.com>
…d skip model override on cli-config path (omnigent-ai#4105)

- PiExecutor._resolve_model: strip trailing [1m]-style bracket suffixes before
  passing model IDs to the Databricks AI Gateway. The direct Anthropic API
  accepts e.g. system.ai.claude-opus-5[1m] but the gateway endpoint does not
  (returns 404).
- CodexExecutor.run_turn: when model_provider_override is set (cli-config path)
  pass model=None to thread/create so the codex binary uses its own configured
  model rather than forwarding an unresolvable alias (e.g. gpt-5.6) to the UC
  API.
- credential_label: cli-config providers now label from the entry name
  (provider_display_name) rather than the display_name field, for consistency
  with other provider kinds.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
…omnigent-ai#4106)

* fix(web): keep chat content clear of the TurnRail as the area narrows

PR omnigent-ai#4085 replaced the transcript's md:pl-12 left inset with a symmetric
px-4 gutter, dropping the clearance that kept the centered chat column off
the left-edge TurnRail (the tick minimap). On a narrow conversation area
the prose crowded the ticks.

Restore the clearance as a continuous, width-driven clamp keyed on the
conversation area (@container/chat) rather than the viewport: the column
slides left with the area until its edge nears the rail, then the left
inset ramps up to hold a minimum gap and caps at 3rem so it stops moving
instead of snapping. Because it reads the area width, opening the sidebar
feeds it too.

Add a multi-turn visual-snapshot test that mounts the rail (it only renders
for >= 2 turns, so the one-turn baseline never covered it), rendered at a
narrower viewport so the inset is actually engaged in the capture.

Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>

* test(e2e-ui): regenerate visual baselines

* fix(web): shrink rail gap to 24px and stop the pill leaking into snapshots

Reduce the restored TurnRail clearance cap from 3rem to 1.5rem (24px) so the
column sits closer to the ticks while still clearing them.

Park the pointer out of the transcript's top hover band before capture in both
chat snapshot tests. Playwright's virtual mouse starts at (0,0), inside the band
that reveals the "Jump to top" pill (and, on the rail test, over a tick), so a
load-timing race could flash that transient chrome into the resting-state
baseline. Moving the pointer low pins it hidden.

Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>

* test(e2e-ui): regenerate visual baselines

* test(e2e-ui): hide the Jump-to-top pill from chat snapshots deterministically

The pill is transient chrome: the initial layout settle (LatestTurnSpacer +
StickToBottom pinning to the bottom) fires a scroll that reveals it for ~2s, so
whether it lands in a capture is a race — which is why a regenerated baseline
picked it up. Force it hidden via an injected style, the same way the shared
settle kills the blinking caret, so the resting-state baseline is deterministic
regardless of when the scroll settles.

Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>

* test(e2e-ui): regenerate visual baselines

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
…-ai#4109)

* feat(webui): wire SessionRail into AppShell behind ?debug=1

SessionRail and ExecutionLogsPanel were implemented but never rendered.
Add SessionRail as a desktop-only column between the chat and workspace
panel, gated on debugMode so it only appears with ?debug=1. The column
hides automatically when a push panel (terminals or execution logs) is
open.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(webui): remove TerminalsCard from SessionRail debug rail

Terminals are already shown in WorkspacePanel. The debug rail should
only show the Execution logs card. Also removes the onExpandTerminals
prop and all terminal-related dead code from SessionRail.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(webui): fix execution logs card title overflow in debug rail

Widen the debug column from w-48 to w-56 and add truncate/min-w-0 to
the CardTitle so the text doesn't overflow into the action buttons.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(webui): add top padding to debug rail column

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
…d harnesses (omnigent-ai#4107)

* fix(web): split the harness picker by support level

The landing composer's harness picker split its primary list and "More"
group by host readiness, so any configured harness led: Claude Code,
Codex, Cursor, and Pi all competed for the few primary slots, while "More"
held only harnesses that happened to need setup. Support level — what
actually distinguishes these integrations — wasn't represented at all.

Add a `fullySupported` flag to `NativeCodingAgentSpec` and set it on
Claude Code and Codex, the integrations we maintain and test end to end.
Only those lead; every other harness folds into "More" whether or not it
is configured on the host. The flag is opt-in, so the supported set is two
lines in one file rather than a marker on each of the nine others, and a
test asserts the set is exactly claude + codex so it can't drift silently.

Two behaviors are preserved: selecting a harness pins it inline via the
existing `effectiveAgentId` rule, so the active pick is never buried; and
the hide-unconfigured preference still outranks support level, dropping
harnesses that can't launch here (and the "More" trigger with them when
that empties the group).

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* feat(web): promote previously-launched harnesses in the picker

Splitting the picker by support level left Pi and Cursor users a hover
away from their harness on every new session, even though the split is
right for a first-time user. Nothing recorded which harnesses someone
actually launches.

Add a localStorage-backed `useRecentHarnesses` (modeled on
`useRecentWorkspaces`, but not host-scoped — a preference for Pi follows
the person across machines) and record the canonical harness id on a
successful create. The picker then promotes any recorded harness into the
primary list alongside the fully supported ones, so a regular Pi user
gets one click instead of one hover, while a fresh install still leads
with Claude Code and Codex only.

Recording happens only after the create succeeds, so a harness the user
merely browsed past never earns a slot, and the hide-unconfigured
preference still outranks recency: promotion applies within what can
launch on the host, never resurrecting a harness that can't run there.
Stored ids fold through the reversed-alias map, so `native-pi` matches
the canonical `pi-native` spec.

Also fixes the two CI failures from the support-level split: the flow
test's `selectAgent` helper now drills into "More" only when the row
isn't already inline, and the harness-install e2e no longer drills for
Codex (fully supported, so it leads inline even while needing setup).

Adds tests/e2e_ui coverage for both behaviors, stubbing every harness as
configured so the split is provably driven by support level rather than
host readiness.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
…mnigent-ai#4111)

* feat(webui): capture raw SSE events and show in execution logs panel

- sseEventLog.ts: module-level ring buffer (max 500 events/session)
  with subscribe/snapshot API for useSyncExternalStore
- useSseEventLog.ts: React hook that subscribes to the ring buffer
- chatStore.ts: tap tapSessionEvents to push each StreamEvent into the
  ring buffer; clear on fresh stream bind (not reconnect)
- ExecutionLogsPanel.tsx: add Items/SSE toggle — SSE tab shows
  timestamped raw events with expand-to-pretty-print, auto-scrolls
  to bottom as events arrive

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* perf(webui): skip SSE ring buffer when debug mode is off

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* perf(webui): cache isDebugMode as module-level boolean

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(webui): return new array ref on push so useSyncExternalStore re-renders

Object.is on the same mutated array always returns true, causing React
to skip re-renders. Produce a fresh array on every push/trim so the
snapshot reference changes and the SSE list updates in real time.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* feat(webui): support localStorage debug flag in addition to ?debug=1

Both useDebugMode and the SSE ring buffer guard now check
localStorage.getItem("debug") === "1" as a fallback, so debug mode
can be toggled once in the console without keeping ?debug=1 in every URL:
  localStorage.setItem("debug", "1")   // enable
  localStorage.removeItem("debug")      // disable

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(webui): stable snapshot ref and correct debug flag detection

- snapshotSseLog: return shared EMPTY constant instead of allocating a
  new [] on every call; prevents useSyncExternalStore render-loop from
  the unstable reference on sessions with no log yet
- isDebugMode: re-read window.location.search + localStorage on every
  call instead of caching against popstate; React Router uses pushState/
  replaceState which never fires popstate, so the cached value stayed
  stale when navigating to ?debug=1 in-app

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
…4108)

* ci(triage): re-triage issues when needs-info is cleared

Add a hybrid needs-info lifecycle. When the issue author comments on an
issue that still carries needs-info, needs-info-response.yml removes the
label using the omnigent-ci App token (the default GITHUB_TOKEN would not
re-trigger downstream workflows). That removal fires issue-triage.yml's
new `unlabeled` trigger, which reads the reporter's follow-up comments,
reclassifies, and assigns an owner — re-adding needs-info only if the
issue is still too vague. Issues the reporter never clarifies are closed
by the existing stale.yml.

issue-triage.yml changes:
- trigger on issues [opened, unlabeled]; the unlabeled path fires only
  for needs-info on an open issue, and allows a bot actor (the App)
- feed the author's follow-up comments into the triage prompt
- remove needs-info on re-triage when the LLM no longer flags it
- suppress the duplicate-of comment on the re-triage path
- add a per-issue concurrency group

Co-authored-by: Isaac

* ci(triage): address review — idempotent label removal, dormant-App notice

- needs-info-response.yml: re-check live labels before `gh --remove-label`
  so a stale event payload / race can't fail the step (gh errors on a
  missing label); emit a ::notice:: when the omnigent-ci App is
  unconfigured so a dormant feature is distinguishable from a broken one.
- issue-triage.yml: also suppress the `duplicate` label on the re-triage
  path (not just the comment), keeping the label and its explanation
  consistent; hoist `import os` to the top of the block.

Co-authored-by: Isaac
…ent-ai#4033)

* perf(host): cache auth headers and parallelize status payloads

Two follow-on speedups for omni host status:

1. Cache _remote_headers() per base_url within a process.
   Databricks SDK credential resolution (~3s) ran on every
   _host_http_json call. Since tokens are valid for the lifetime
   of a CLI invocation, resolving once and reusing is safe.
   A threading.Lock serialises concurrent first-time resolution
   for the same URL.

2. Build daemon status payloads in parallel with ThreadPoolExecutor.
   With the dead-process skip from the previous commit, only live
   daemons make HTTP calls. Parallelising them lets independent
   servers be queried concurrently instead of sequentially.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* chore: restore uv.lock to main

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix: move header cache resolution inside try/except in _host_http_json

_remote_headers() does file I/O and Databricks SDK calls that can raise
OSError. The cache-populating call was outside the try block, so such a
failure propagated unhandled. Under ThreadPoolExecutor (added in this
PR) that aborted the entire omni host status listing.

Move the resolution inside the existing try/except so auth/file errors
remain recoverable and produce a status_code=0 result per daemon,
matching the pre-change behaviour.

Also adds test_host_http_json_handles_remote_headers_oserror to pin
this contract.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* chore: fix import order (ruff)

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
…-ai#4116)

Rename the `enhancement` label to `Feature` and `documentation` to `Docs`
across the issue-triage system. The triage agent's `type` value is applied
verbatim as an issue label, so update the validator allow-list, the agent
schema and classification rule, the feature-request template's auto-label,
and the design proposal doc to keep them coherent.

Co-authored-by: Isaac

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
…mnigent-ai#4055)

* Sidebar ownership/archived filters

Signed-off-by: Hubert Zub <hubert.zub@databricks.com>

* test(e2e-ui): regenerate visual baselines

* dropdown visibility

Signed-off-by: Hubert Zub <hubert.zub@databricks.com>

---------

Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
* Fix reference font sizes

Signed-off-by: Hubert Zub <hubert.zub@databricks.com>

* test(e2e-ui): regenerate visual baselines

---------

Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
* ci: mirror linked issue priority onto closing PRs

Add a workflow that copies an issue's priority label (P0-P3) onto the
PR that closes it. Only closing links (closes/fixes/resolves #n) count;
a plain "related to #n" mention is ignored. When a PR closes several
issues the highest priority wins, and stale priority labels are dropped.

Runs on PR events and re-syncs when an issue's priority label changes;
the issue-label trigger is gated to priority labels only so other label
edits don't spin up the job.

Co-authored-by: Isaac

* ci: address review feedback on priority sync

- Tolerate null GraphQL nodes (unknown PR number, data: null) instead of
  crashing on AttributeError; cover the parsing with tests.
- Add a 30s urlopen timeout so a stalled connection fails fast.
- Validate PR_NUMBER is an integer with a clear message.
- Surface a warning when the issue->PR GraphQL lookup fails rather than
  silently succeeding.
- Pass the resolved PR list through an env var instead of interpolating
  it into the run block.

Co-authored-by: Isaac
…#3932)" (omnigent-ai#4124)

This reverts commit 617293d.

Painting a cached transcript before revalidation meant the contents
moved under the reader: the window appeared instantly, then shifted as
newer commits were gap-bridged onto it. A hydrate spinner that resolves
into a settled transcript reads better than a fast paint that jumps, so
go back to the cold-load spinner on every conversation switch.

Co-authored-by: Isaac

Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
…-ai#4132)

Claude Code >= v2.1.197 writes `status: "shell"` to its per-session status
file when a turn ends but a background shell is still alive. The status-file
poller's map didn't know that literal, so `read_session_status` returned
`None`, the poller fired no edge and stayed stuck on its last `running` (while
also suppressing the PTY watcher's `idle`). The session never reported idle
while a background shell ran, so `sessionStatus` stayed `running`,
`shouldQueueSend` returned true, and every new message queued client-side —
regressing the "don't queue while only background work runs" behavior.

Map `shell` to `idle`: the agent loop is idle, and the Stop hook separately
relabels its own `idle` to `waiting` with the shell tally, which is what keeps
the "N background tasks still running" spinner lit.

Co-authored-by: Isaac

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
…i#4123)

* [OMNI-2351] Hide message actions when not hovered/focused

Signed-off-by: Hubert Zub <hubert.zub@databricks.com>

* test(e2e-ui): regenerate visual baselines

---------

Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
* fix(web): persist open shell tabs per session

Shell tabs lived only in transient component state and the
conversation-switch effect cleared them on every navigation, so opening
a shell, switching sessions, and returning lost the tab. The PTYs
themselves live on the server and are re-fetched by useTerminals — only
the tab strip was being discarded.

Persist openTerminals/selectedTerminalKey per session in
sessionWorkspaceState (mirroring the open file tabs), seed and restore
them on mount/switch, and gate the dead-tab prune effect on the
terminals list's loading state so a restored tab isn't wiped by the
transient empty list before the session's terminals load.

Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>

* test(e2e): cover shell-tab persistence; skip prune on errored terminal fetch

Add an e2e_ui test that opens a real shell in one session, switches to
another via the sidebar (client-side nav), and returns — asserting the
shell tab and its live PTY are restored. This exercises the
conversation-switch effect that regressed, which a full page reload
wouldn't.

Also address review feedback: the dead-tab prune effect ran whenever the
terminals query wasn't loading, but an errored fetch also yields an empty
list — a non-authoritative one. Pruning against it would wipe restored
tabs whose PTYs we simply couldn't reach. Gate the effect on
terminalsError as well, with a component test for the errored-read case.

Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
@appletechie

Copy link
Copy Markdown
Owner Author

All eight findings addressed across two commits (204f54f, ca94fad). Both reviewers landed on the same two P1s independently, and both were right — they're the same mistake this PR was written to fix, reached by paths I didn't check.

P1 — unforced first bounce

device_consent_page bounced an unauthenticated caller with reauth=False, forcing re-auth only when an existing cookie was stale.

Under accounts that's sound: no session means no credential, so the SPA shows the form regardless. Under OIDC it isn't — "no Omnigent session" says nothing about the IdP's, which is separate and may be live. Silent bounce, fresh iat, gate passes.

Every bounce is forced now. The parameter is removed rather than defaulted, since no caller wants an unforced bounce.

P1 — GitHub OAuth can't honour the gate

Confirmed: provider_type="github" points at https://github.com/login/oauth/authorize — plain OAuth 2.0, no prompt parameter, no id_token, no auth_time. The gate degrades to "redirect out and back" with no way to detect it.

Refused outright via a new unsupported_reason predicate, which also absorbs the header-mode check so there's one answer to "can this provider carry a grant" instead of two that can drift. app.py consults the same predicate and logs the refusal — an operator who set the flag learns why /oauth/* is missing rather than concluding it didn't take — and the server still boots.

Medium — requested but never verified

This one I'd have missed, and it's the sharpest of the eight. prompt=login is a request; nothing checked it was honoured, and auth_time appeared nowhere in the tree.

Now: max_age=0 rides alongside (obliging a conforming IdP to report auth_time), /auth/login signs the bounce time into the state cookie as reauth_at so it can't be stripped via the URL, and /auth/callback 403s without minting a session unless auth_time postdates it.

Missing auth_time is refused too — silence is indistinguishable from a reused session. Ordinary logins carry no reauth_at and are untouched, since most IdPs omit the claim and requiring it everywhere would break every sign-in.

Verification meant decoding the id_token twice, so the validated decode is now _verified_id_token_claims, shared with the email resolver — one validated path, so no caller can read a claim from an unverified token.

Medium — mount never exercised through create_app

Correct: the factory tests proved the router builds, never that the app calls it. create_app is now driven with a real OIDC provider (constructed in-process, so no discovery request), asserting the grant is reachable — and absent for GitHub.

The GitHub assertion is against the route table, not a status code: the SPA catch-all answers unmounted paths with 405, so "not 200" would also pass with the routes mounted and merely erroring. Found that while writing it.

Medium — SimpleNamespace proved nothing about the real cookie

Also correct, and the failure mode is nasty: if OIDCConfig.session_cookie_name/cookie_secret diverged from what the callback sets, _session_iat returns None forever and consent becomes an infinite bounce — no error, no failing test. Added a test that mints through the real mint_session_cookie from a real OIDCConfig and asserts the consent page renders, naming the identity and the client.

P2 — comment length

Ten inline lines against AGENTS.md's three. Condensed; the reasoning already lives in the docstring and DEVICE_AUTH.md.

Verified

  • 280 tests pass — auth suite, integration, e2e
  • Mutation-checked every fix: unforced bounce fails 1, allowing GitHub fails 2, dropping the callback verification fails 2, treating missing auth_time as a pass fails 1
  • ruff, ruff format, mypy clean
  • DEVICE_AUTH.md updated for all three: the always-forced bounce, the GitHub exclusion, and requested-then-verified

One thing I did not do: verify auth_time for the accounts path. It doesn't need it — the server checks the password itself, so the re-authentication is a local fact rather than a claim from a third party.

@github-actions github-actions Bot added size/XL Pull request size: XL and removed size/L Pull request size: L labels Aug 6, 2026
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>

@polly-review polly-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Polly Review

Approach: Mostly sound

Both prior High findings are fixed. What remains is fail-open classification, an unverified re-auth, and test gaps.

Findings

  • Mediumdevice_auth.py:290: unsupported_reason denies only provider_type == "github". from_env yields exactly github or oidc today, so nothing is broken now — but any future plain-OAuth provider, or a directly-constructed config, defaults to allowed and silently reopens the bypass this PR closes. Invert to an allowlist: permit "oidc", refuse anything else until audited.
  • Mediumroutes/auth.py:203 (prior d7286f4818d1, still open): prompt=login is requested, never verified. grep -rn 'auth_time' omnigent/ returns zero hits. Send max_age=0, record the re-auth request time in the signed state, and reject a missing or stale auth_time in the callback before minting the session.
  • Mediumapp.py:2422: the refusal warning sits inside the login_url truthiness gate at app.py:2344, and UnifiedAuthProvider.login_url is None for header mode (auth.py:443). So the header-mode refusal — one of the two cases unsupported_reason exists to explain — is never logged, and the custom-provider reason string at :2417 is likewise mostly unreachable. Hoist the warning above that gate. No caplog assertion anywhere covers it.
  • Mediumtests/server/test_device_auth.py:458 (prior 1e6d9245dae6, still open): the app fixture is _build_accounts_app (:273) — the one mode where always-on reauth provably changes nothing. No test in tests/ exercises the consent gate under OIDC, and all three bounce assertions are "/login" in location, a substring /auth/login also satisfies, so login_url is untested in both directions.
  • Mediumtests/server/test_device_auth.py:94 (prior 60f80474d531, still open): still a SimpleNamespace _oidc_config. Nothing proves the real OIDCConfig cookie name/secret match what /auth/callback writes; a mismatch is an infinite consent bounce with a green suite.
  • Lowdevice_auth.py:449: return_to is html.escaped, not URL-encoded, and &reauth=1 is appended after it. A crafted user_code=X%26reauth=0 yields &reauth=0&reauth=1, and URLSearchParams.get returns the first. Not exploitable (that path has no session to auto-bounce), but the new always-on invariant rests on param ordering. Use quote(return_to, safe="/").
  • Lowdevice_auth.py:288: _source == "oidc" with _oidc_config is None classifies as supported, then trips an assert. Classify it as unsupported and raise deliberately.

Checked

GitHub refused on both the factory and create_app paths; every _bounce_to_login caller now forced; _logger binding and the custom-provider type guard; accounts SPA + e2e behaviour under always-on reauth (inert, cannot loop); ordinary OIDC login unchanged unless reauth is exactly 1; no existing test breaks or becomes vacuous.

✅ Previously reported, now fixed: 2
⚠️ Previously reported, still open: 3

5 finding(s) without a valid diff anchor
  • Medium · Provider classification fails openomnigent/server/routes/device_auth.py:290 (diff unavailable for validation)
    This denies one known-bad value and admits every other string. OIDCConfig.from_env produces exactly github or oidc today, so the gate holds now — but the next plain-OAuth provider added to from_env, or any directly-constructed config, is admitted by default and silently reopens the bypass this PR was written to close. The safe shape for a security predicate is an allowlist: return None only for provider_type == "oidc" and refuse anything else with a reason naming the unaudited value.
  • Medium · prompt=login is requested but never verifiedomnigent/server/routes/auth.py:203 (diff unavailable for validation)
    Carried over from the previous review and unchanged at this head: grep -rn 'auth_time' omnigent/ returns zero hits, and no max_age is sent on the authorization request. A misconfigured or non-conforming IdP satisfies the bounce from its own session, and the callback mints a fresh iat that clears the gate with no error anywhere. Send max_age=0 alongside prompt=login (OIDC Core then requires the IdP to return auth_time), persist the re-auth request time in the signed state cookie, and reject a missing or older auth_time in the callback before minting the session.
  • Medium · Refusal warning never fires for header modeomnigent/server/app.py:2422 (diff unavailable for validation)
    This whole block is nested under the auth_provider is not None and getattr(auth_provider, "login_url", None) gate at app.py:2344, and UnifiedAuthProvider.login_url returns None for header mode (auth.py:443). Header mode is one of the two cases unsupported_reason exists to explain, so an operator who sets OMNIGENT_DEVICE_GRANT_ENABLED there gets exactly the silent absence of /oauth/* this warning was added to eliminate — as does a custom provider, whose dedicated reason string just above is likewise unreachable. Hoist the wanted/blocked computation and the warning above the login_url gate, and add a caplog test (there is none).
  • Medium · The forced bounce is only tested where it does nothingtests/server/test_device_auth.py:458 (diff unavailable for validation)
    The app fixture is _build_accounts_app (:273), and under accounts reauth=1 on a no-session bounce is inert — LoginPage.tsx:90 only suppresses a getMe() probe that would not have redirected anyway. So this test cannot fail if _bounce_to_login regressed to reauth=False; the OIDC case that motivates the change is untested. Also, this and the two sibling assertions check "/login" in location, which /auth/login also satisfies, leaving auth_provider.login_url unverified in both directions. Add an OIDC-mode app fixture and assert the location starts with /auth/login and carries reauth=1, for both the no-session and stale-session bounces.
  • Medium · SimpleNamespace config still proves nothing about the real cookietests/server/test_device_auth.py:94 (diff unavailable for validation)
    Carried over from the previous review. This builds the router from a hand-written _oidc_config, so the one thing that can actually break — whether the real OIDCConfig.session_cookie_name and cookie_secret match what /auth/callback writes — is asserted nowhere. tests/server/test_oidc_reauth_prompt.py has a real OIDCConfig but never mints a session cookie or touches the device router. If they diverge, _session_iat returns None on every request and the OIDC consent page becomes an infinite bounce with a green suite. Mint via mint_session_cookie from a real OIDCConfig, set it under cfg.session_cookie_name, and assert the consent page renders.

Reviewed at 204f54f.

* Match the chat header design

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

* test(e2e-ui): regenerate visual baselines

---------

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>

@polly-review polly-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Polly Review

Approach: Needs changes

Three prior findings remain open, and the new verification machinery is bypassable.

Findings

  • Highroutes/auth.py:332: freshness is enforced on the login request, not the consent decision, and the marker is opt-in on a public GET. Attacker starts a grant, then sends the victim /auth/login?return_to=%2Foauth%2Fdevice%3Fuser_code%3DXXXX with no reauth=1 — no prompt, no max_age, no reauth_at, so the callback skips the check, mints a session with iat = now, and the consent gate (iat >= created_at) passes. The victim approves having re-authenticated nothing. _sanitize_return_to permits that path (auth.py:681), and mint_session_token emits only sub/iat/exp/provider (oidc.py:53-102), so nothing downstream can distinguish a forced login from a silent SSO. Fix: carry the verified auth_time into the session (and set an equivalent only after accounts password verification), and have both consent gates compare that against grant.created_at, failing closed when absent — or issue a short-lived server-signed marker bound to grant.id and require it on the consent GET and POST.
  • Mediumdevice_auth.py:290 (4c77469dc408, unchanged): still a denylist of "github" only; _oidc_config is None also classifies as supported.
  • Mediumapp.py:2422 (ac6cbdc72665, unchanged): still nested under the login_url truthiness gate at app.py:2337, so header mode never logs the refusal.
  • Mediumroutes/auth.py:212: max_age=0 and reauth_at are the two lines the whole gate rests on, and neither is asserted anywhere. They appear in tests/ only in the callback tests that inject reauth_at by hand. Delete either line and the suite stays green.
  • Mediumroutes/auth.py:50: auth_time >= not_before - 60 contradicts the doc's "postdates it" — a session authenticated 59s before the bounce passes. Compare auth_time against the id_token's own iat (same clock) with a small processing allowance.
  • Mediumtests/server/test_device_auth.py:565 (13c9947e2476, unchanged): still no OIDC consent-bounce test; all three assertions are "/login" in location, which /auth/login also satisfies, so login_url stays untested in both directions.
  • Low — two JWKS fetches per re-auth callback (auth.py:866 and :920 each build a fresh PyJWKClient); isinstance(auth_time, int) 403s a spec-conforming float; the 403 is raw JSON with no retry path after the code is already exchanged; _build_oidc_app never clears OMNIGENT_DEVICE_CLIENT_SECRET, so an ambient value makes the mount test 401 instead of 200; the time.sleep(1) at test_oidc_reauth_prompt.py:178 is not load-bearing.

Checked

The _resolve_oidc_email refactor is behaviour-identical line for line; the reauth_at check sits after the github/oidc email branch so both converge through it before the sole session mint; marked flows fail closed on missing or stale auth_time; both create_app tests will really exercise the mount decision; the real-OIDCConfig consent test genuinely exercises session_cookie_name and cookie_secret against _session_iat.

✅ Previously reported, now fixed: 5
⚠️ Previously reported, still open: 3


Reviewed at ca94fad.

Comment thread omnigent/server/routes/auth.py Outdated

# This login was demanded by a device-grant consent bounce, so a
# session the IdP simply reused is not good enough.
reauth_at = state_payload.get("reauth_at")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High · Re-auth gate is bypassed by an unmarked login link

Only runs when the state carries reauth_at, which only /auth/login?reauth=1 writes — and /auth/login is a public GET accepting any same-origin return_to. An attacker starts a grant, then sends the victim /auth/login?return_to=%2Foauth%2Fdevice%3Fuser_code%3DXXXX with no reauth=1: no reauth_at is signed, so this is skipped, the callback mints a session with iat = now, and the consent gate at device_auth.py:517 passes on iat alone. Fix: persist the verified auth_time into the session (mint_session_token carries only sub/iat/exp/provider) and gate consent on that, or bind a signed re-auth marker to grant.id.

Comment thread omnigent/server/routes/device_auth.py Outdated
return f"{source!r} auth has no server-minted session to delegate from"
if source == "oidc":
config = auth_provider._oidc_config
if config is not None and config.provider_type == "github":

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium · Provider classification still fails open

Unchanged since the last review. This denies one known-bad value and admits every other string, and _oidc_config is None also returns supported. from_env yields only github or oidc today, so nothing is broken now — but the next plain-OAuth provider, or a directly-constructed config, is admitted by default. Require a present config and allow only provider_type == "oidc", refusing anything else with a reason naming the unaudited value.

Comment thread omnigent/server/app.py Outdated
if _device_grant_wanted and _device_grant_blocked is not None:
# Asked for and refused: say so, or the operator sees only the
# absence of /oauth/* and assumes the flag did not take.
_logger.warning(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium · Refusal warning still never fires for header mode

Unchanged since the last review. This block is nested under the login_url truthiness gate at app.py:2337, and UnifiedAuthProvider.login_url returns None for header mode (auth.py:443). Header mode is one of the two cases unsupported_reason exists to explain, so an operator who sets OMNIGENT_DEVICE_GRANT_ENABLED there still gets only the silent absence of /oauth/*. Hoist the wanted/blocked computation and the warning above that gate, and add a caplog assertion.

# `max_age=0` makes it enforceable — it obliges a conforming IdP
# to return `auth_time`, which the callback then verifies.
params["prompt"] = "login"
params["max_age"] = "0"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium · max_age=0 and reauth_at are untested

These two lines are what makes the gate enforceable, and neither is asserted anywhere. test_reauth_forwards_prompt_login_to_the_idp checks prompt and PKCE but not max_age; the callback tests inject reauth_at by hand rather than letting /auth/login write it. The chain is tested at each end and joined nowhere — delete either line and the suite stays green while the gate switches off. Assert params["max_age"] == ["0"] in the authorize-params tests, and decode ap_auth_state to assert reauth_at is present with reauth=1 and absent without it.

Comment thread omnigent/server/routes/auth.py Outdated
_AUTH_STATE_TTL_SECONDS = 300 # 5 minutes
_CLI_TICKET_TTL_SECONDS = 300 # 5 minutes
# Tolerance when comparing the IdP's `auth_time` against our own clock.
_REAUTH_CLOCK_SKEW_SECONDS = 60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium · 60s skew window admits a pre-bounce authentication

_reauthenticated_after compares auth_time >= not_before - 60, where not_before is our own clock at the bounce. An authentication performed up to 59 seconds before the consent bounce therefore satisfies a gate the design doc describes as requiring the re-auth to postdate it, so a non-conforming IdP reusing a very recent session passes. Since auth_time and the id_token's iat both come from the IdP's clock, compare those two directly with a small processing allowance, and keep the server-clock value only for binding the marker to this request.

serena-ruan and others added 6 commits August 6, 2026 18:11
… it (omnigent-ai#4127)

* dev/resolve-agent: resolve a reproduced bug (review-or-fix) and prove it

Adds the step after repro-agent: given a pointer to a completed repro run — a
local session link or a CI run URL (--ci-link) — resolve-agent recovers the
reproduction (verdict, per-facet breakdown, journey, the authored e2e test) and
drives the bug to resolution.

Two paths, decided by whether an open PR already fixes the bug:
- Review path: check out the existing PR, run the repro test against it
  (pass = it fixes the bug; fail = it doesn't), review the diff, and comment
  findings on that PR — no competing PR opened.
- Author path: audit the repro test against the unfixed tree so it fails on real
  buggy behavior, root-cause, fix, add targeted tests at the changed layer, and
  prove every live facet goes fail->pass.

Robustness on the author path: hostile-env rerun of env-default tests; an
independent cross-vendor review (a codex-native reviewer child on its own diff,
fed a recurring-pitfalls checklist) before opening the PR, reusing the server +
runner it already runs on. Opens a ready-for-review PR; does not merge.
--skip-push commits locally without pushing.

dev/resolve.py mirrors dev/repro.py; tests/dev/test_resolve.py unit-tests the
driver helpers.

Co-authored-by: Isaac

* dev/resolve-agent: address PR review — base off origin/main, stricter ci-link parse, honest guard comment

Review feedback on omnigent-ai#4127:

- Base the fix worktree on the latest origin/main, not this checkout's HEAD.
  Running the driver from a feature branch would otherwise drag unrelated
  commits into the fix worktree and contaminate the PR/review. Adds
  _resolve_base_ref() (fetch origin/main, fall back to local main, then HEAD).

- Confirm before creating the worktree, so answering "no" no longer leaves an
  orphaned fix/<slug> worktree + branch on disk.

- Parse the --ci-link URL structurally (scheme + github.com host + anchored
  path) instead of an unanchored substring regex, so a string that merely
  contains the run path (or a different host) is rejected. Adds rejection tests.

- Soften the headless_subagent_purpose_guard comment in config.yaml: it only
  inspects sys_session_send, not the sys_session_create that launches the
  reviewer child, so it does not itself constrain that child — spawn_bounds caps
  the fan-out and the reviewer's read-only behavior rests on its prompt + the
  codex bundle's guardrails.

- Fix two inaccurate inline comments (worktree base, absolute-agent-path
  rationale) to match the actual flow.

Co-authored-by: Isaac

* dev/resolve-agent: recover the pasted test from CI logs (repro-agent omnigent-ai#4207)

repro-agent now pastes the complete verbatim e2e test source into its final
message before the JSON handoff. The CI job log echoes that message untruncated,
so on the --ci-link path the log itself now carries the full test body — prefer
reading it from the inline block there, with gh run download as the fallback.
(A live --session transcript is still truncated, so the disk read off the repro
session's workspace stays the robust path locally.)

Co-authored-by: Isaac
…mnigent-ai#4223)

The non-closing duplicate comment ended with "Leaving it open for a
maintainer to confirm", which parks the issue in a queue nobody is
watching. The reporter is the one person who can settle it immediately:
they know whether the linked issue covers their case.

Both the `duplicate` (closure disabled) and `similar` comments now ask
the reporter to take a look and close their own issue if it matches,
with an explicit path for when it doesn't. The `similar` copy stays
softer — a loose match is a weaker basis for that ask.

Rendering the new copy surfaced a pre-existing grammar bug: the plural
branch produced "these already covers this". Replaced with a phrase that
agrees in number, plus a regression test.

Co-authored-by: Isaac

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* Sidebar paddings and gaps

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

* test(e2e-ui): regenerate visual baselines

* test fixes

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

---------

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
* Match composer footer design, remove chevrons

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

* test(e2e-ui): regenerate visual baselines

---------

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
…en type: null (omnigent-ai#3339)

* feat(sandbox): support explicit auto sandbox type

Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>

* docs(sandbox): clarify auto sandbox selection

Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>

---------

Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>
Co-authored-by: Anthony Ivan <anthony.ivan@databricks.com>
* Unify dropdown styling

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

* minmax

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

---------

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
@appletechie

Copy link
Copy Markdown
Owner Author

Second round addressed — b96ae1a. All five hold up; the High is the sharpest finding on this PR so far.

High — the gate could be skipped rather than beaten

Correct, and it makes the previous two rounds partly beside the point. Hardening the forced path doesn't matter if the forced path can simply not be entered.

/auth/login is a public GET accepting any same-origin return_to. An attacker who starts a grant sends the victim:

/auth/login?return_to=%2Foauth%2Fdevice%3Fuser_code%3DXXXX

No reauth=1 → no reauth_at → nothing demanded → IdP satisfies it from its own session → cookie with iat of now → consent renders. One click.

The root cause is that iat cannot carry the property: every completed callback has a fresh one, including the ones with zero user involvement. So the proof now lives on the session as an auth_time claim, written only where a credential was actually presented — an accounts password submit, or an IdP-attested re-authentication — and consent requires auth_time ≥ grant.created_at. A login that skipped the bounce carries no claim, bounces, and is made to prove itself. The demand no longer depends on the attacker's link having asked for it.

Both the consent GET and the approve POST read the new claim, so the render check can't be sidestepped by posting directly.

Took your first suggested fix (persist into the session) over binding to grant.id — it generalises to any future consumer needing "did this human actually authenticate", rather than solving it once for device grants.

One consequence worth stating: an IdP that never emits auth_time can no longer carry a grant — one bounce, then a 403 with a clear error. That terminates rather than looping, and it's the right answer for a provider that can't support the control.

Medium — 60s skew admitted a pre-bounce authentication

Right, and the diagnosis was exact: I was comparing the IdP's clock against ours. Now compares auth_time against the id_token's own iat — both the IdP's — so skew between the two servers cancels and a reused session fails regardless of whose clock is ahead. The remaining allowance covers the IdP's processing between authenticating and signing, which is what it should have been measuring.

Medium — provider classification failed open

Fixed as suggested: allowlist provider_type == "oidc", require a present config, and name the refused value in the reason. Agreed on the reasoning — from_env yields only two values today, so this is about the next one, not a live bug.

Medium — refusal warning never fired for header mode

Verified: the block sat under the login_url truthiness gate at app.py:2344, and header mode's login_url is None. So the operator least able to work out why /oauth/* was missing was the only one who got no explanation. Hoisted above that gate, with caplog-style assertions for header mode, GitHub, and a negative case asserting a supported deployment logs nothing.

Medium — max_age=0 and reauth_at untested

Also correct — tested at each end, joined nowhere. Both pinned now, and the state cookie is decoded to assert the marker is present with reauth=1 and absent without it.

Verified

  • 290 tests pass — auth suite, integration, e2e
  • Mutation-checked each fix: gating on iat fails 1, denylisting instead of allowlisting fails 1, dropping max_age=0 fails 1, renaming the reauth_at marker fails 1, stamping auth_time without the IdP's attestation fails 4
  • ruff, ruff format, mypy clean
  • DEVICE_AUTH.md rewritten for the session-carried proof, the same-clock comparison, and the allowlist

Note for anyone reading the diff: mint_session_token/mint_session_cookie gained an optional auth_time, and the four accounts login paths pass it since the server verifies the credential itself. mint_runner_token deliberately does not — a runner token should never satisfy a consent gate.

@polly-review polly-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Polly Review

Approach: Mostly sound

All eight prior findings are fixed. Moving the proof onto the session as auth_time is the right design — but the stamp is unconditional, which lets the bypass back in through a different door.

Findings

  • Highroutes/auth.py:341: _idp_reauthenticated runs on every login, so proof is stamped whenever the id_token reports auth_time >= iat - 120, including on a login the attacker's unmarked link triggered. Two ways through: an IdP that sets auth_time to token-issuance time on silent SSO (a widespread misimplementation) always passes; and even a conforming IdP passes inside the 120s window after a genuine sign-in. Fix: stamp proven_at only when the signed state carries reauth_at.
  • Mediumaccounts_auth.py:645: magic_redeem stamps auth_time on the strength of a token /auth/magic minted for whatever session asked, so a session whose proof predates the grant can self-upgrade to "freshly proven" with no password entry. Carried forward rather than introduced here, and not cross-origin drivable — but it contradicts the invariant oidc.py:78-81 and the design doc both rest on.
  • Mediumdevice_auth.py:536: the gate proves an authentication after the grant began, not one for this grant. Attacker authorizes at T0, victim signs in at T1 for unrelated reasons, click at T2 renders consent with no friction. Not a regression, but the docstring's "always costs a deliberate, fresh credential entry" overstates it.
  • Mediumroutes/auth.py:341: _resolve_oidc_email and _idp_reauthenticated each build a fresh PyJWKClient, so its instance cache can't be reused and every standard OIDC callback now makes two sequential JWKS fetches — doubled latency and rate-limit exposure, synchronously, inside an async handler. Verify once, pass the claims to both.
  • Lowroutes/auth.py:889: isinstance(auth_time, int) rejects a fractional NumericDate, which RFC 7519 permits, and accepts True. Same at :897.
  • Lowroutes/auth.py:346: the 403 fires after the code is exchanged, so it can't be retried, and returns raw JSON with no route back to the pending grant.
  • Low — doc/comment drift: the retired iat gate and _session_iat (which no longer exists) survive in device_auth.py:280,502, three spots in test_device_auth.py, two in test_oidc_reauth_prompt.py, and six in DEVICE_AUTH.md — where :194-201 now contradicts :217-233.
  • Low — test hygiene: neither _build_oidc_app nor test_consent_page_renders_for_a_real_oidc_session_cookie clears OMNIGENT_DEVICE_CLIENT_SECRET, so an ambient value turns a mount test into a 401 that reads like a mount failure; three bounce assertions still use "/login" in location.
  • Note (pre-existing, untouched by this diff) — anyone can create a CLI ticket, get a victim to open /auth/login?ticket=…, and poll the victim's session JWT out unauthenticated. This PR doesn't cause it, but auth_time now travels on that JWT, so the exfiltrated session also carries device-approval power. Worth its own issue.

Checked

import time is present in accounts_auth.py (four new callers, no NameError); every mint_session_* call site audited — no refresh/sliding-renewal mint exists, and the bootstrap and runner tokens correctly omit the claim; pre-existing sessions fail closed and self-heal in one login; an IdP that never emits auth_time terminates in one bounce plus one 403 on both the OIDC and accounts SPA paths; nothing else decodes the session claim set; the new tests are collectable, tear down cleanly, and fail on inversion.

✅ Previously reported, now fixed: 8


Reviewed at b96ae1a.

# login, not only the forced ones: the device-grant consent gate
# reads this, and a login that skipped the bounce would otherwise
# look identical to one that honoured it.
if _idp_reauthenticated(token_json, config):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High · Proof is stamped on every login, not only forced ones

_idp_reauthenticated runs unconditionally, so any login whose id_token reports auth_time >= iat - 120 gets proof stamped — including one the attacker's unmarked /auth/login?return_to=/oauth/device?user_code=… link triggered. Two ways through: an IdP that sets auth_time to token-issuance time on silent SSO (a common misimplementation) always passes; and even a conforming IdP passes if the victim authenticated within the last 120s. Either reopens the bypass this redesign closed. Fix: stamp proven_at only when the signed state carries reauth_at. Ordinary logins then get an unproven session and are bounced at consent — which the page already handles.

provider="accounts",
# The server verified the credential itself just now, so this is
# a local fact rather than a third party's claim about one.
auth_time=int(time.time()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium · magic_redeem lets an unproven session mint its own proof

The comment says the server verified a credential just now, but /auth/magic mints this token for whatever session called it, carrying no evidence that session ever presented one. So a session whose auth_time predates the grant can call /auth/magic then /auth/magic/redeem and come back stamped fresh, clearing the approve gate at device_auth.py:574 with no password entry. Not cross-origin drivable (no CORS middleware, the redeem_url sits in a JSON body), and carried forward rather than introduced here — but it violates the invariant oidc.py:78-81 and DEVICE_AUTH.md both state. Fix: propagate the caller's auth_time through the magic token instead of restamping.

# reauth=1 so the login page re-prompts rather than auto-returning
# the existing session (which would loop).
proven_at = _session_auth_time(request)
if proven_at is None or proven_at < grant.created_at:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium · Proof is temporal, not bound to this grant

proven_at >= grant.created_at accepts any credential entry inside the grant's pending window, whatever it was for. Attacker authorizes at T0 and mails the link; the victim signs in at T1 for an unrelated reason; clicking at T2 renders consent and one click binds the grant — no bounce, no grant-specific friction. Not a regression (the old iat check behaved the same), but the docstring at :505 and DEVICE_AUTH.md claim approval "always costs a deliberate, fresh credential entry", and it costs a credential entry. Fix: carry the grant id on the proof and compare it on both the consent GET and the approve POST, or soften both claims.

if claims is None:
return False

auth_time = claims.get("auth_time")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low · auth_time type check accepts bool, rejects float

isinstance(auth_time, int) is True for True, so an IdP sending "auth_time": true reads as 1 — harmless here, but it passes the type gate for the wrong reason. The real problem is the other direction: a fractional NumericDate is valid per RFC 7519 and is rejected outright, so a conforming IdP emitting auth_time: 1767225600.5 can never carry a device grant and every forced login 403s. Same for the issued_at check at :897. Fix: accept finite int | float and exclude bool explicitly.

@@ -435,15 +502,20 @@ async def device_consent_page(request: Request) -> Response:
device flow began (session ``iat`` ≥ the grant's ``created_at``). A

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low · Docstring still describes the retired iat gate

This paragraph describes the gate as session iat >= the grant's created_at, but the code below reads auth_time (:535). The same stale iat / _session_iat wording survives at :280, in test_device_auth.py:72,577,589, test_oidc_reauth_prompt.py:16,155, and across DEVICE_AUTH.md:147,195,200,349,372,377_session_iat no longer exists under that name. The module docstring at :31 is inverted too: it says dropping prompt=login makes the gate "pass without the user having proven anything", when at this head it makes the gate bounce and then 403. Worth fixing together, since the doc is the design record.

@appletechie

Copy link
Copy Markdown
Owner Author

Went back through the review bodies — I had only been reading the inline comments, and four items never got a diff anchor, so they were never addressed. Fixed in 6e19b47. None was exploitable; all four are places where something read as stronger than it was.

Low — the bounce built a URL with an HTML escaper

_bounce_to_login ran the consent URL through html.escape before appending &reauth=1.

The described attack doesn't reproduce. html.escape turns & into &amp;, so a crafted user_code=X%26reauth=0 arrives as amp;reauth=0 and reauth=1 remains the only reauth:

today : return_to=/oauth/device?user_code=K7M2-QP9X&amp;reauth=0&reauth=1
parsed: {'return_to': [...], 'amp;reauth': ['0'], 'reauth': ['1']}

But the conclusion is right, for a better reason. html.escape is an HTML escaper, not a URL one — it leaves ?, =, # and + untouched. A # in the code ends the URL and turns the rest into a fragment, silently truncating the return_to the user is meant to return to. And the forced-re-auth invariant shouldn't rest on an escaper's incidental handling of one character. quote(..., safe="/") makes the whole question moot.

Not reachable in practice either way — the user_code alphabet contains none of these characters.

Low — "/login" in location could not fail

Correct. /auth/login also contains /login, so all three bounce assertions passed under either provider and proved nothing about login_url in either direction. Now anchored: accounts must bounce to /login?, and the OIDC consent test asserts /auth/login?.

Low — an unquote hid what the state cookie stores

Also correct, and the sharper version of the point: the test decoded the stored value before comparing, so it passed whether or not the query survived the bounce — the one property it exists to check. Asserts the stored form directly now.

The encoding had no test at all

Worth stating plainly: I confirmed by mutation that reverting to html.escape left the entire suite green. Added a test driving a user_code containing exactly the characters html.escape ignores, asserting the consent URL round-trips intact and that reauth=1 is the only reauth. Reverting now fails it.

Doc note

Fair, and worth being explicit about: mounting the grant under OIDC changes neither first-party client — the CLI and Slack both still probe the mode and still take the cli-ticket flow there. Added that, plus what these routes are actually for: external clients needing a scoped, revocable, refreshable credential rather than the server's own session JWT. Migrating the first-party clients is separate work.

Verified

  • 291 tests pass — auth suite, integration, e2e
  • Encoding fix mutation-checked (reverting fails 1)
  • ruff, ruff format, mypy clean

Also closed from the same body, in the previous commit: _source == "oidc" with a None config used to classify as supported and then trip an assert — it is now refused deliberately with a reason.

@polly-review polly-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Polly Review

Approach: Mostly sound

This push is one commit over four files. The only runtime change is _bounce_to_login switching to quote(); the rest is docs and test assertions. All five listed findings are untouched.

Findings

  • High, still openroutes/auth.py:341: _idp_reauthenticated still runs on every callback and still writes proven_at into the session when the state carries no reauth_at, so an unmarked login can mint device-approval proof.
  • Medium, still openaccounts_auth.py:645: /auth/magic lets any valid session mint a token, and redemption stamps a brand-new auth_time without carrying or checking the originating session's proof — a stale session refreshes its own proof with no password.
  • Medium, still opendevice_auth.py:541 (and the identical predicate at :581): re-traced at this head — grant at T0, victim's unrelated sign-in at T1, click at T2 renders consent and one click binds it. _session_auth_time reads only auth_time; nothing reads grant.id or the user_code.
  • Mediumroutes/auth.py:843: _resolve_oidc_email and _idp_reauthenticated each build a fresh PyJWKClient, so every ordinary OIDC callback now makes two sequential synchronous JWKS fetches. Verify once, pass the claims to both.
  • Lowdevice_auth.py:465: the new encoding is single-layer. user_code is interpolated raw into return_to before the outer quote(), so a # survives decoding and Starlette keeps it in the callback's Location; the consent server then receives a truncated user_code. No open redirect and forced re-auth still holds, but the round trip the fix claims doesn't complete. The new test only parses the first bounce and misses it.
  • Low, still openroutes/auth.py:889: still isinstance(auth_time, int) for both claims — admits bool, rejects the fractional NumericDate RFC 7519 permits.
  • Low, still open — doc drift, now self-contradictory: DEVICE_AUTH.md:225 carries the correct "proof rides on the session, not on iat" text while :155,203,208,357,380 still describe the retired gate, and :385 plus test_oidc_reauth_prompt.py:156 name _session_iat, which no longer exists (it is _session_auth_time). Same in device_auth.py:31,281,508 and four spots in test_device_auth.py. Two of these claim the gate "silently passes" without prompt=login; at this head it 403s.
  • Low — neither _build_oidc_app, _build_accounts_app, nor test_consent_page_renders_for_a_real_oidc_session_cookie clears OMNIGENT_DEVICE_CLIENT_SECRET; an ambient value 401s the unheadered authorize call and reads like a mount failure.

Checked

test_bounce_percent_encodes_the_return_to genuinely fails against the old html.escape# is the discriminator, since urlparse split it into a fragment and truncated return_to; %26 alone would not have been. The three tightened startswith("/login?") assertions match accounts-mode login_url, and the new startswith("/auth/login?") is the case the old substring could never fail. Dropping unquote() is genuinely stricter — the stored state value is decoded at that point. safe='/' is fine for a query value. html is still used at device_auth.py:814, so the import is not dead. The DEVICE_AUTH.md:41-48 note about first-party clients is accurate. All new tests are collectable and tear down cleanly.

⚠️ Previously reported, still open: 5


Reviewed at 6e19b47.

# login, not only the forced ones: the device-grant consent gate
# reads this, and a login that skipped the bounce would otherwise
# look identical to one that honoured it.
if _idp_reauthenticated(token_json, config):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High · Proof is still stamped on every login, not only forced ones

Unchanged since the last review. _idp_reauthenticated runs on every callback and proven_at reaches mint_session_cookie at :405-410 even when the signed state carries no reauth_at, so a login the attacker's unmarked /auth/login?return_to=/oauth/device?user_code=… link triggered can still mint device-approval proof — either against an IdP that reports auth_time as token-issuance time, or against a conforming one within the 120s allowance. Fix: validate and stamp only when state_payload contains reauth_at. Ordinary logins then carry no proof and are bounced at consent, which the page already handles.

provider="accounts",
# The server verified the credential itself just now, so this is
# a local fact rather than a third party's claim about one.
auth_time=int(time.time()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium · magic_redeem still refreshes proof with no credential

Unchanged. /auth/magic (:578-590) mints a token for any valid session, and redemption stamps a brand-new auth_time here without carrying or checking whether the originating session ever presented a credential. A session whose proof predates the grant can therefore call /auth/magic then /auth/magic/redeem and come back stamped fresh, clearing the approve gate at device_auth.py:581 with no password entry. Fix: omit auth_time on redemption, or propagate the originating session's existing timestamp without advancing it.

# proven-authentication time must postdate the grant. Bounce with
# reauth=1 so the login page re-prompts rather than auto-returning
# the existing session (which would loop).
proven_at = _session_auth_time(request)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium · Consent proof is still a timestamp, not bound to this grant

Unchanged, and the identical predicate sits at :581. _session_auth_time (:487-495) reads only auth_time; nothing reads grant.id, the user_code, or a per-grant nonce. Re-traced at this head: attacker authorizes at T0, victim signs in for an unrelated reason at T1 (accounts stamps on every password submit), clicks the link at T2 — consent renders and one click binds the grant. DEVICE_AUTH.md:357 still claims the victim "must deliberately re-enter their password". Fix: sign grant_id into the state alongside reauth_at, mint it onto the session, and match it here.

# carrying one would truncate or corrupt the round-trip. `&` survives
# as `&amp;` today, which is why `reauth=1` still wins — an accident
# of the escaper, not a property worth depending on.
query = f"return_to={quote(return_to, safe='/')}&reauth=1"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low · Percent-encoding fix is single-layer

The outer quote() correctly stops user_code from injecting a second parameter onto /auth/login, but the inner value is still interpolated raw into return_to at :459. After /auth/login decodes it, _sanitize_return_to and the signed state both preserve delimiters, and Starlette keeps # in the callback's Location — so AB#CD comes back to the consent page as user_code=AB. Not a security hole (no open redirect, forced re-auth still holds), but the round trip this change is meant to guarantee doesn't complete. The new test parses only the first bounce. Fix: urlencode both layers.

return None

try:
jwks_client = jwt.PyJWKClient(config.jwks_uri)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium · Two synchronous JWKS fetches per OIDC callback

_resolve_oidc_email (:334) and _idp_reauthenticated (:341) each call _verified_id_token_claims, and each call constructs a fresh PyJWKClient here — whose cache is per-instance, so it can never be reused. Every ordinary OIDC callback therefore makes two sequential blocking JWKS requests inside an async handler, doubling login latency and the exposure to a JWKS outage or rate limit. Fix: verify the id_token once per callback and pass the claims to both consumers, ideally holding one router-scoped PyJWKClient.

…ate honest

The device-authorization grant refused to mount unless auth mode was
`accounts`. OIDC deployments were excluded on the reasoning that they
delegate login to the IdP via the cli-ticket flow and so never need it.

That reasoning covers a CLI logging a human in. It does not cover a
third-party application asking to act as a user: the cli-ticket flow
hands back the server's own session JWT — full account authority, no
scope claim, no grant id, no revocation handle, no `act` provenance.
Everything that makes a delegated token safe to give away is exactly
what it lacks. An OIDC deployment therefore had no way to authorize an
application at all, and the device grant needs nothing from `accounts`
that `oidc` does not also provide: both mint the same HS256 session
cookie, and `_check_cookie` already picks between the two configs the
same way this now does.

Header mode stays excluded, and for a real reason rather than symmetry:
identity there is asserted by an upstream proxy, so there is no session
to delegate FROM and no login to bounce a consenting browser through.

## The part that is not a gate flip

Consent requires a login performed AFTER the grant began (session `iat`
>= grant `created_at`). A stale session is bounced to the login page with
`reauth=1`. That gate is the anti-phishing control: a victim handed a
one-click link with the code prefilled must deliberately re-enter their
credentials against a screen naming the identity and the client, rather
than approving by reflex.

`reauth=1` was implemented entirely in the accounts SPA login form, which
holds back its auto-redirect and demands a password. `/auth/login` never
read the parameter. So mounting the router under OIDC and stopping there
would have produced this: bounce to the IdP, IdP recognises its own
session, silent redirect back, callback mints a session with a fresh
`iat`, gate satisfied. The user proves nothing. Nothing errors, no test
fails, and the control is gone while still appearing to be there.

`/auth/login` now forwards `reauth=1` to the IdP as `prompt=login` (OIDC
Core 3.1.2.1) — the standard way to ask an IdP to re-prompt a user it has
already authenticated. Sent only on that path; unconditional re-prompting
would cost a password on every ordinary sign-in, which is how a control
like this ends up switched off by whoever finds it irritating.

Matched strictly against `"1"`. The consent page is the only caller and
sends exactly that, so accepting loose truthy spellings would only widen
the surface for an unrelated query param to trigger a re-prompt.

## Verified

- 220 auth-suite tests pass (device_auth, oidc, callback, invites,
  open-redirect, accounts, and the new file)
- `test_oidc_reauth_prompt.py` pins the parameter both ways: present on
  `reauth=1`, absent otherwise, and `return_to` still round-trips through
  the bounce so the pending grant is not abandoned
- Removing the `prompt=login` block fails that suite; the tests are not
  vacuous
- ruff, ruff format and mypy clean on every touched file

The e2e browser proof (`tests/e2e_ui/auth/test_device_grant_reauth.py`)
remains accounts-only — it drives a real password form, which OIDC does
not have. The OIDC half is covered at the route level above.

Signed-off-by: Andrew Peltekci <andrew@peltekci.com>
…Auth

Addresses three review findings on the device-grant-under-OIDC change.
Both P1s are the same mistake as the one that change was written to fix:
a control that still looks present after it has stopped working.

## The unforced first bounce

`device_consent_page` bounced an unauthenticated caller with
`reauth=False`, forcing re-authentication only when an existing Omnigent
cookie was stale.

Under accounts that distinction is sound: no session means no credential,
so the SPA shows the password form either way. Under OIDC it is not.
"No Omnigent session" says nothing about the IdP's session, which is
separate and may well be live — so the unforced bounce is satisfied
silently, the callback mints a cookie with an `iat` newer than the grant,
and the consent gate passes without the user having proven anything. The
same silent-pass the `prompt=login` work closed, reached by the sibling
path.

The consent page cannot tell the two cases apart, so every bounce now
forces it. The parameter is gone rather than defaulted, because there is
no caller that wants an unforced bounce.

## GitHub OAuth cannot honour the gate

`OIDCConfig.from_env` accepts GitHub as an `oidc` source but points it at
`https://github.com/login/oauth/authorize` — plain OAuth 2.0, with no
`prompt` parameter in the specification. `prompt=login` is ignored,
GitHub reuses its session, and the fresh `iat` clears the gate. The
security property the whole change rests on is absent for that provider,
while every test and every log line reads as though it holds.

`unsupported_reason` now owns the rule and refuses the grant there. A
grant issued behind a gate that cannot hold is worse than no grant,
because it looks protected. `app.py` consults the same predicate and logs
the refusal, so an operator who set OMNIGENT_DEVICE_GRANT_ENABLED learns
why `/oauth/*` is missing rather than concluding the flag did not take —
and the server still boots.

Folding the header-mode check into the same predicate keeps one answer to
"can this provider carry a grant", rather than two that can drift.

## Comment length

The forced-reauth rationale in routes/auth.py ran ten inline lines
against AGENTS.md's three-line guidance. Condensed to three; the full
reasoning already lives in the route docstring and DEVICE_AUTH.md.

## Verified

- 231 tests pass across the auth suite and app integration
- Both P1 fixes mutation-checked: restoring the unforced bounce fails 1
  test, allowing GitHub fails 2
- New coverage: forced bounce with no session at all, GitHub refused,
  and `unsupported_reason` admitting standard OIDC and accounts so the
  predicate cannot over-refuse
- ruff, ruff format, mypy clean

Signed-off-by: Andrew Peltekci <andrew@peltekci.com>
… for it

Addresses the remaining review findings on this branch. All three come
back to the same gap: the gate was requested but never checked.

## prompt=login was a request with no verification

`prompt=login` asks an IdP to re-authenticate. Nothing confirmed it
obeyed, and `auth_time` appeared nowhere in the codebase. A
non-conforming or misconfigured IdP satisfies the bounce from its own
session, the callback mints a cookie with a fresh `iat`, and the consent
gate passes — the same silent failure this branch exists to prevent, one
level further out.

The bounce now sends `max_age=0` alongside `prompt=login`, which obliges
a conforming IdP to return `auth_time`. `/auth/login` signs the bounce
time into the state cookie as `reauth_at`, so the requirement cannot be
stripped by editing the URL, and `/auth/callback` refuses with 403 —
minting no session — unless the returned `auth_time` postdates it.

A missing `auth_time` is refused as well. Silence is indistinguishable
from a reused session, and this is the only gate between a phished
consent link and a delegated grant. Ordinary logins carry no `reauth_at`
and are untouched; most IdPs omit the claim, and requiring it everywhere
would break every sign-in.

Verifying it meant reading the id_token twice, so the validated decode is
now `_verified_id_token_claims`, shared by the email resolver and the
freshness check. One validated path, so no caller can read a claim out of
an unverified token.

GitHub short-circuits `reauth` at the login route as well as being
refused the grant: it can neither be asked to re-authenticate nor report
that it did, and setting `reauth_at` for it would fail every such login
at the callback instead.

## The mount decision was never exercised through create_app

The factory tests prove the router builds; they never proved the app
calls it. A typo in the mount condition would leave `/oauth/*` absent
under OIDC with the whole suite green — which is exactly the failure the
condition was widened to avoid. `create_app` is now driven directly with
a real OIDC provider (constructed in-process, so no IdP discovery
request), asserting the grant is reachable, and that it is absent for
GitHub. The GitHub case asserts against the route table rather than a
status code: the SPA catch-all answers unmounted paths, so "not 200"
would also pass with the routes mounted and merely erroring.

## A SimpleNamespace config proved nothing about the real cookie

`_session_iat` reads `cookie_config.session_cookie_name` and verifies
with `cookie_config.cookie_secret`. If either diverged from what
`/auth/callback` sets, it would return None on every request and the
consent page would bounce forever — a login loop with no error and no
failing test. A hand-built stub cannot catch that. Added a test that
mints a session through the real `mint_session_cookie` from a real
`OIDCConfig` and asserts the consent page renders, naming the identity
and the client.

## Verified

- 280 tests pass across the auth suite, integration and e2e
- Mutation-checked: dropping the callback verification fails 2 tests,
  treating a missing auth_time as a pass fails 1
- ruff, ruff format, mypy clean

Signed-off-by: Andrew Peltekci <andrew@peltekci.com>
Addresses the second review round. The High is the same class of hole as
the previous two: a control that reads as present while a path around it
stays open.

## The gate could be skipped rather than beaten

Consent required the session's `iat` to postdate the grant, on the
reasoning that only a login started for this flow could produce a fresh
one. It could not: `/auth/login` is a public GET accepting any
same-origin `return_to`, so an attacker who starts a grant sends the
victim

    /auth/login?return_to=%2Foauth%2Fdevice%3Fuser_code%3DXXXX

with no `reauth=1`. Nothing is demanded, the IdP satisfies it from its
own session, the callback mints a cookie with `iat` of now, and consent
renders. The forced-re-authentication path was never entered, so
hardening it changed nothing. The victim clicked one link.

`iat` cannot carry this property: every completed callback has a fresh
one, including the ones with no user involvement at all. So the proof is
now recorded on the session as an `auth_time` claim, written only where
a credential was actually presented — an accounts password submit, or an
IdP-attested re-authentication — and consent requires
`auth_time >= grant.created_at`. A login that skipped the bounce carries
no claim, so it bounces and is made to prove itself; the demand no
longer depends on the attacker's link having asked for it.

`reauth_at` stays as the marker that makes the callback 403 outright
rather than merely decline to stamp the proof. Both the consent GET and
the approve POST read the new claim, so neither can be reached by
posting directly.

An IdP that never emits `auth_time` now cannot carry a grant: one
bounce, then a 403 with a clear error. That terminates instead of
looping, and it is the correct answer for a provider that cannot support
the control.

## Freshness is compared on one clock

`auth_time >= reauth_at - 60` mixed the IdP's clock with ours, so a 60s
window admitted an authentication performed *before* the bounce that
demanded it. It now compares `auth_time` against the id_token's own
`iat` — both the IdP's — so skew between the two servers cancels out and
a reused session fails regardless of whose clock is ahead. The remaining
allowance covers the IdP's processing between authenticating the user
and signing the token, which is what it should have been measuring.

## Allowlist, not denylist

`unsupported_reason` denied `provider_type == "github"` and admitted
every other string, including a `None` config. `from_env` yields only
`github` or `oidc` today so nothing was broken, but the next OAuth
dialect modelled under this source would have been admitted by default,
behind a gate that cannot hold for it. Now only `"oidc"` qualifies, and
the refusal names the value it rejected.

## The refusal was invisible where it mattered most

The warning sat inside the auth-router mount, which is gated on
`login_url` being truthy — and header mode's is `None`. Header mode is
one of the two cases the predicate exists to explain, so the operator
least able to work out why `/oauth/*` was missing was the only one who
got no explanation. Hoisted above that gate.

## The chain was tested at both ends and joined nowhere

`max_age=0` and the `reauth_at` marker are what make the gate
enforceable, and neither was asserted: the authorize tests checked
`prompt` only, and the callback tests injected `reauth_at` by hand
instead of letting `/auth/login` write it. Either line could be deleted
with the suite green. Both ends are now pinned, and the state cookie is
decoded to assert the marker is present with `reauth=1` and absent
without it.

## Verified

- 290 tests pass across the auth suite, integration and e2e
- Every fix mutation-checked: gating on `iat` fails 1, denylisting
  instead of allowlisting fails 1, dropping `max_age=0` fails 1,
  renaming the `reauth_at` marker fails 1, stamping `auth_time` without
  the IdP's attestation fails 4
- ruff, ruff format, mypy clean

Signed-off-by: Andrew Peltekci <andrew@peltekci.com>
…gaps

Four items raised in earlier review bodies rather than as inline
comments, which is why they were missed while the anchored findings were
being fixed. None is exploitable; all four are places where a test or an
invariant reads as stronger than it is.

## The bounce built a URL with an HTML escaper

`_bounce_to_login` ran the consent URL through `html.escape` before
appending `&reauth=1`. That is an HTML escaper, not a URL one: it leaves
`?`, `=`, `#` and `+` untouched, so a `#` in the code ended the URL and
turned the rest into a fragment — silently truncating the `return_to`
the user is supposed to come back to.

The review that raised this described a different failure: a crafted
`user_code` injecting `&reauth=0` ahead of the real parameter, letting
the first value win. That does not reproduce — `html.escape` turns `&`
into `&amp;`, so the injected parameter arrives as `amp;reauth` and
`reauth=1` is still the only `reauth`. The conclusion holds anyway, and
for a better reason than the one given: the forced-re-auth invariant
should not depend on an escaper's incidental handling of one character.
`quote(..., safe="/")` is the correct tool and makes the question moot.

A legitimate `user_code` draws from an alphabet with none of these
characters, so nothing was reachable in practice.

## `"/login" in location` could not fail

Three bounce assertions matched a substring that `/auth/login` also
satisfies, so they passed under either provider and proved nothing about
`login_url` in either direction. Now anchored: accounts must bounce to
`/login?`, and the OIDC consent test asserts `/auth/login?`.

## An `unquote` hid what the state cookie actually stores

The `return_to` round-trip test decoded the stored value before
comparing, so it passed whether the query survived the bounce or not —
the exact property it exists to check. Asserts the stored form directly
now, with the input percent-encoded as `_bounce_to_login` emits it.

## The encoding had no test at all

Confirmed by mutation: reverting to `html.escape` left the suite green.
Added a test driving a `user_code` containing the characters
`html.escape` ignores, asserting the consent URL round-trips intact and
that `reauth=1` is the only `reauth` present. Reverting now fails it.

## Documentation

Mounting the grant under OIDC changes neither first-party client: the
CLI and Slack both still probe the mode and still take the cli-ticket
flow there. Noted, along with what the OIDC device-grant routes are
actually for — external clients that need a scoped, revocable,
refreshable credential rather than the server's own session JWT.

## Verified

- 291 tests pass across the auth suite, integration and e2e
- The encoding fix is mutation-checked (reverting it fails 1)
- ruff, ruff format, mypy clean

Signed-off-by: Andrew Peltekci <andrew@peltekci.com>
@appletechie
appletechie force-pushed the feat/device-grant-oidc branch from 6e19b47 to db6f689 Compare August 7, 2026 01:25
@polly-review

polly-review Bot commented Aug 7, 2026

Copy link
Copy Markdown

🤖 Polly Review

Review could not be completed: Diff fetch failed: 406 {"message":"Sorry, the diff exceeded the maximum number of files (300). Consider using 'List pull requests files' API or locally cloning the repository instead.","errors":[{"resource":"PullRequest","f

@appletechie

Copy link
Copy Markdown
Owner Author

Superseded by omnigent-ai#4304 — same branch, same commits, opened against upstream where it belongs.

This was targeting the fork's main, which now tracks upstream/main. Merging 1125 lines into it would have re-diverged the fork — the exact state that made #14 explode into a 39-file conflict — and the fix would have reached no Omnigent user.

Before opening upstream I rebased onto current main (the branch was 103 behind) and signed off all 5 commits (none had a Signed-off-by, so DCO would have failed). Re-verified after the rebase: 73 passed across the three auth suites, 321 passed across tests/server -k "auth or oidc or device".

Closing here. Follow it at omnigent-ai#4304.

@appletechie appletechie closed this Aug 7, 2026
@appletechie appletechie reopened this Aug 7, 2026
@polly-review

polly-review Bot commented Aug 7, 2026

Copy link
Copy Markdown

🤖 Polly Review

Review could not be completed: Diff fetch failed: 406 {"message":"Sorry, the diff exceeded the maximum number of files (300). Consider using 'List pull requests files' API or locally cloning the repository instead.","errors":[{"resource":"PullRequest","f

@appletechie

Copy link
Copy Markdown
Owner Author

Superseded by omnigent-ai#4304, which carries the same five commits (head db6f6894). This fork only stages branches for upstream PRs.

Branch feat/device-grant-oidc is intentionally left in place — omnigent-ai#4304 points at it.

@appletechie appletechie closed this Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Pull request size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.