feat(auth): mount the device grant under OIDC, and keep its re-auth gate honest - #15
feat(auth): mount the device grant under OIDC, and keep its re-auth gate honest#15appletechie wants to merge 108 commits into
Conversation
) 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>
|
All eight findings addressed across two commits ( P1 — unforced first bounce
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 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 gateConfirmed: Refused outright via a new Medium — requested but never verifiedThis one I'd have missed, and it's the sharpest of the eight. Now: Missing Verification meant decoding the id_token twice, so the validated decode is now Medium — mount never exercised through
|
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
There was a problem hiding this comment.
🤖 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
- Medium —
device_auth.py:290:unsupported_reasondenies onlyprovider_type == "github".from_envyields exactlygithuboroidctoday, 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. - Medium —
routes/auth.py:203(priord7286f4818d1, still open):prompt=loginis requested, never verified.grep -rn 'auth_time' omnigent/returns zero hits. Sendmax_age=0, record the re-auth request time in the signed state, and reject a missing or staleauth_timein the callback before minting the session. - Medium —
app.py:2422: the refusal warning sits inside thelogin_urltruthiness gate atapp.py:2344, andUnifiedAuthProvider.login_urlisNonefor header mode (auth.py:443). So the header-mode refusal — one of the two casesunsupported_reasonexists to explain — is never logged, and the custom-provider reason string at:2417is likewise mostly unreachable. Hoist the warning above that gate. Nocaplogassertion anywhere covers it. - Medium —
tests/server/test_device_auth.py:458(prior1e6d9245dae6, still open): theappfixture is_build_accounts_app(:273) — the one mode where always-onreauthprovably changes nothing. No test intests/exercises the consent gate under OIDC, and all three bounce assertions are"/login" in location, a substring/auth/loginalso satisfies, sologin_urlis untested in both directions. - Medium —
tests/server/test_device_auth.py:94(prior60f80474d531, still open): still aSimpleNamespace_oidc_config. Nothing proves the realOIDCConfigcookie name/secret match what/auth/callbackwrites; a mismatch is an infinite consent bounce with a green suite. - Low —
device_auth.py:449:return_toishtml.escaped, not URL-encoded, and&reauth=1is appended after it. A crafteduser_code=X%26reauth=0yields&reauth=0&reauth=1, andURLSearchParams.getreturns the first. Not exploitable (that path has no session to auto-bounce), but the new always-on invariant rests on param ordering. Usequote(return_to, safe="/"). - Low —
device_auth.py:288:_source == "oidc"with_oidc_config is Noneclassifies as supported, then trips anassert. 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
5 finding(s) without a valid diff anchor
- Medium · Provider classification fails open —
omnigent/server/routes/device_auth.py:290(diff unavailable for validation)
This denies one known-bad value and admits every other string.OIDCConfig.from_envproduces exactlygithuboroidctoday, so the gate holds now — but the next plain-OAuth provider added tofrom_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: returnNoneonly forprovider_type == "oidc"and refuse anything else with a reason naming the unaudited value. - Medium · prompt=login is requested but never verified —
omnigent/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 nomax_ageis sent on the authorization request. A misconfigured or non-conforming IdP satisfies the bounce from its own session, and the callback mints a freshiatthat clears the gate with no error anywhere. Sendmax_age=0alongsideprompt=login(OIDC Core then requires the IdP to returnauth_time), persist the re-auth request time in the signed state cookie, and reject a missing or olderauth_timein the callback before minting the session. - Medium · Refusal warning never fires for header mode —
omnigent/server/app.py:2422(diff unavailable for validation)
This whole block is nested under theauth_provider is not None and getattr(auth_provider, "login_url", None)gate atapp.py:2344, andUnifiedAuthProvider.login_urlreturnsNonefor header mode (auth.py:443). Header mode is one of the two casesunsupported_reasonexists to explain, so an operator who setsOMNIGENT_DEVICE_GRANT_ENABLEDthere 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 thelogin_urlgate, and add acaplogtest (there is none). - Medium · The forced bounce is only tested where it does nothing —
tests/server/test_device_auth.py:458(diff unavailable for validation)
Theappfixture is_build_accounts_app(:273), and under accountsreauth=1on a no-session bounce is inert —LoginPage.tsx:90only suppresses agetMe()probe that would not have redirected anyway. So this test cannot fail if_bounce_to_loginregressed toreauth=False; the OIDC case that motivates the change is untested. Also, this and the two sibling assertions check"/login" in location, which/auth/loginalso satisfies, leavingauth_provider.login_urlunverified in both directions. Add an OIDC-mode app fixture and assert the location starts with/auth/loginand carriesreauth=1, for both the no-session and stale-session bounces. - Medium · SimpleNamespace config still proves nothing about the real cookie —
tests/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 realOIDCConfig.session_cookie_nameandcookie_secretmatch what/auth/callbackwrites — is asserted nowhere.tests/server/test_oidc_reauth_prompt.pyhas a realOIDCConfigbut never mints a session cookie or touches the device router. If they diverge,_session_iatreturnsNoneon every request and the OIDC consent page becomes an infinite bounce with a green suite. Mint viamint_session_cookiefrom a realOIDCConfig, set it undercfg.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>
There was a problem hiding this comment.
🤖 Polly Review
Approach: Needs changes
Three prior findings remain open, and the new verification machinery is bypassable.
Findings
- High —
routes/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%3DXXXXwith noreauth=1— noprompt, nomax_age, noreauth_at, so the callback skips the check, mints a session withiat = now, and the consent gate (iat >= created_at) passes. The victim approves having re-authenticated nothing._sanitize_return_topermits that path (auth.py:681), andmint_session_tokenemits onlysub/iat/exp/provider(oidc.py:53-102), so nothing downstream can distinguish a forced login from a silent SSO. Fix: carry the verifiedauth_timeinto the session (and set an equivalent only after accounts password verification), and have both consent gates compare that againstgrant.created_at, failing closed when absent — or issue a short-lived server-signed marker bound togrant.idand require it on the consent GET and POST. - Medium —
device_auth.py:290(4c77469dc408, unchanged): still a denylist of"github"only;_oidc_config is Nonealso classifies as supported. - Medium —
app.py:2422(ac6cbdc72665, unchanged): still nested under thelogin_urltruthiness gate atapp.py:2337, so header mode never logs the refusal. - Medium —
routes/auth.py:212:max_age=0andreauth_atare the two lines the whole gate rests on, and neither is asserted anywhere. They appear intests/only in the callback tests that injectreauth_atby hand. Delete either line and the suite stays green. - Medium —
routes/auth.py:50:auth_time >= not_before - 60contradicts the doc's "postdates it" — a session authenticated 59s before the bounce passes. Compareauth_timeagainst the id_token's owniat(same clock) with a small processing allowance. - Medium —
tests/server/test_device_auth.py:565(13c9947e2476, unchanged): still no OIDC consent-bounce test; all three assertions are"/login" in location, which/auth/loginalso satisfies, sologin_urlstays untested in both directions. - Low — two JWKS fetches per re-auth callback (
auth.py:866and:920each build a freshPyJWKClient);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_appnever clearsOMNIGENT_DEVICE_CLIENT_SECRET, so an ambient value makes the mount test 401 instead of 200; thetime.sleep(1)attest_oidc_reauth_prompt.py:178is 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
Reviewed at ca94fad.
|
|
||
| # 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") |
There was a problem hiding this comment.
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.
| 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": |
There was a problem hiding this comment.
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.
| 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( |
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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.
| _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 |
There was a problem hiding this comment.
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.
… 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>
|
Second round addressed — High — the gate could be skipped rather than beatenCorrect, 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.
No The root cause is that 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 One consequence worth stating: an IdP that never emits Medium — 60s skew admitted a pre-bounce authenticationRight, and the diagnosis was exact: I was comparing the IdP's clock against ours. Now compares Medium — provider classification failed openFixed as suggested: allowlist Medium — refusal warning never fired for header modeVerified: the block sat under the Medium —
|
There was a problem hiding this comment.
🤖 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
- High —
routes/auth.py:341:_idp_reauthenticatedruns on every login, so proof is stamped whenever the id_token reportsauth_time >= iat - 120, including on a login the attacker's unmarked link triggered. Two ways through: an IdP that setsauth_timeto 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: stampproven_atonly when the signed state carriesreauth_at. - Medium —
accounts_auth.py:645:magic_redeemstampsauth_timeon the strength of a token/auth/magicminted 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 invariantoidc.py:78-81and the design doc both rest on. - Medium —
device_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. - Medium —
routes/auth.py:341:_resolve_oidc_emailand_idp_reauthenticatedeach build a freshPyJWKClient, 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. - Low —
routes/auth.py:889:isinstance(auth_time, int)rejects a fractional NumericDate, which RFC 7519 permits, and acceptsTrue. Same at:897. - Low —
routes/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
iatgate and_session_iat(which no longer exists) survive indevice_auth.py:280,502, three spots intest_device_auth.py, two intest_oidc_reauth_prompt.py, and six inDEVICE_AUTH.md— where:194-201now contradicts:217-233. - Low — test hygiene: neither
_build_oidc_appnortest_consent_page_renders_for_a_real_oidc_session_cookieclearsOMNIGENT_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, butauth_timenow 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): |
There was a problem hiding this comment.
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()), |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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") |
There was a problem hiding this comment.
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 | |||
There was a problem hiding this comment.
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.
|
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 Low — the bounce built a URL with an HTML escaper
The described attack doesn't reproduce. But the conclusion is right, for a better reason. Not reachable in practice either way — the Low —
|
There was a problem hiding this comment.
🤖 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 open —
routes/auth.py:341:_idp_reauthenticatedstill runs on every callback and still writesproven_atinto the session when the state carries noreauth_at, so an unmarked login can mint device-approval proof. - Medium, still open —
accounts_auth.py:645:/auth/magiclets any valid session mint a token, and redemption stamps a brand-newauth_timewithout carrying or checking the originating session's proof — a stale session refreshes its own proof with no password. - Medium, still open —
device_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_timereads onlyauth_time; nothing readsgrant.idor theuser_code. - Medium —
routes/auth.py:843:_resolve_oidc_emailand_idp_reauthenticatedeach build a freshPyJWKClient, so every ordinary OIDC callback now makes two sequential synchronous JWKS fetches. Verify once, pass the claims to both. - Low —
device_auth.py:465: the new encoding is single-layer.user_codeis interpolated raw intoreturn_tobefore the outerquote(), so a#survives decoding and Starlette keeps it in the callback'sLocation; the consent server then receives a truncateduser_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 open —
routes/auth.py:889: stillisinstance(auth_time, int)for both claims — admitsbool, rejects the fractional NumericDate RFC 7519 permits. - Low, still open — doc drift, now self-contradictory:
DEVICE_AUTH.md:225carries the correct "proof rides on the session, not oniat" text while:155,203,208,357,380still describe the retired gate, and:385plustest_oidc_reauth_prompt.py:156name_session_iat, which no longer exists (it is_session_auth_time). Same indevice_auth.py:31,281,508and four spots intest_device_auth.py. Two of these claim the gate "silently passes" withoutprompt=login; at this head it 403s. - Low — neither
_build_oidc_app,_build_accounts_app, nortest_consent_page_renders_for_a_real_oidc_session_cookieclearsOMNIGENT_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.
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): |
There was a problem hiding this comment.
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()), |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 `&` 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" |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 `&`, 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>
6e19b47 to
db6f689
Compare
🤖 Polly ReviewReview could not be completed: |
|
Superseded by omnigent-ai#4304 — same branch, same commits, opened against upstream where it belongs. This was targeting the fork's Before opening upstream I rebased onto current Closing here. Follow it at omnigent-ai#4304. |
🤖 Polly ReviewReview could not be completed: |
|
Superseded by omnigent-ai#4304, which carries the same five commits (head Branch |
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
scopeclaim, nogrant_id, no revocation handle, noactprovenance. 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
accountsthatoidcdoes not also provide: both mint the same HS256 session cookie, and_check_cookiealready 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≥ grantcreated_at); a stale session bounces to the login page withreauth=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=1was implemented entirely in the accounts SPA login form, which holds back its auto-redirect and demands a password./auth/loginnever read the parameter.So flipping the mount gate and stopping there would have produced:
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/loginnow forwardsreauth=1to the IdP asprompt=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:
prompt=loginwould cost a password on every ordinary sign-in, which is how a control like this ends up switched off by whoever finds it irritating."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
app.pyoidcdevice_auth.pyoidc, selects_oidc_configroutes/auth.pyreauth=1→prompt=logindesigns/DEVICE_AUTH.mdtest_device_auth.pyheaderrejected,oidcbuildstest_oidc_reauth_prompt.pyVerified
prompt=loginblock fails the new suite — the tests aren't vacuousreturn_tostill round-trips the bounce, so a re-auth doesn't abandon the pending grantThe 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_PREFIXESallowlist — and the deployment it must reach first runs OIDC.