Commit graph

27 commits

Author SHA1 Message Date
pre-commit-ci[bot]
cd8284d5cd [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-27 13:31:21 +00:00
Daniel Han
f01011e4dd Studio: real Codex parallel-call tab UI + in-process SDK spoof
Two paired changes that finally make the Codex parallel-calls fan-out
visible as actual clickable tabs in the chat surface, plus a credit-
free spoof that lets the whole pipeline run in dev / CI without ever
touching the upstream API.

1. Real tab UI (frontend).

   The chat-adapter used to render the per-worker outputs as inline
   `[Codex tab 1/N] ...` text blocks in the assistant message body,
   which collapsed into one big run-on block once more than a handful
   of tokens had streamed. Now each `codex_*` SSE event is folded into
   `codexParallelState` and re-published as the `args.state` of a
   single tool-call part with `toolName === "codex_parallel"`. The
   assistant-ui surface dispatches that to the new
   `CodexParallelToolUI` wrapper, which mounts the existing
   `CodexParallelTabs` component -- one tab per worker, one Synthesis
   tab, click to switch. The stable `toolCallId` keeps assistant-ui
   updating the SAME card across stream yields rather than spawning
   new cards.

   `renderCodexTabsBlock` now returns the empty string so the message
   body no longer contains the labelled-text fallback (kept the
   function name so the rest of the adapter's `renderFullContent` /
   pin-signature paths are untouched).

2. Credit-free Codex SDK spoof (backend).

   New `studio/backend/core/inference/codex_spoof.py` exposes a drop-in
   subset of the upstream `openai_codex` surface (`AsyncCodex`,
   `AppServerConfig`, `ApprovalMode.deny_all`, `SandboxMode.read_only`,
   thread with `turn().stream()` + `run_streaming()` + `run()`) and
   emits deterministic per-tab streaming events tagged with the worker
   index, so flipping between tabs in the UI shows visibly distinct
   text. Activated by `UNSLOTH_CODEX_SPOOF=1`; `_import_codex` installs
   the spoof into `sys.modules` under both `openai_codex` and
   `codex_app_server` and the rest of the provider keeps running
   unchanged. OFF by default; production is unaffected.

   Six new tests cover the spoof itself (module install, env-flag
   gating, delta + completion event shape, per-tab tagging, provider
   import path, safety-kwargs resolution against the spoof). 69/69
   tests pass with and without the flag; TypeScript clean.
2026-05-27 13:30:53 +00:00
pre-commit-ci[bot]
dd0aeec388 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-27 06:58:56 +00:00
Daniel Han
e2b7f5958b Studio: round 9 -- three P2 fixes from latest Codex bot review
1. Codex SSE wrapper terminates on exact `data: [DONE]` only.

   The old substring check `if "[DONE]" in line` would flip
   sent_done True when a normal model response carried the literal
   text "[DONE]" in delta.content (for example an explanation of
   the OpenAI stream sentinel). The real terminator was then
   suppressed, leaving OpenAI-compatible clients that finalise on
   the explicit sentinel hung on stream close. Now compares the
   stripped line to the exact `data: [DONE]` form.

2. Legacy `thread.run_streaming` path no longer returns an empty
   reply on completion-only streams.

   If the SDK exposes `thread.run_streaming` but the stream emits
   ONLY item.completed / agentMessage events with no message
   deltas, the loop previously exited with emitted_any False and
   never reached the agent-message fallback. The request returned
   200 with an empty assistant reply even though Codex produced a
   final answer. Mirror the canonical-path behavior: collect
   `_completed_agent_message_text` strings in a sidecar list and
   emit the last one when no deltas arrived. Match the canonical
   payload-extraction (`getattr(event, "payload", event)`) so the
   event-vs-payload SDK shape difference is handled the same way
   in both branches.

3. Parallel-calls fan-out propagates CodexUnavailableError so the
   route layer can return 503.

   When the SDK is not importable or the safety enums are missing
   without the dev opt-in, every worker raised the same
   CodexUnavailableError. The previous catch-all converted the
   error into a per-tab codex_tab_error event, the outer stream
   never raised, and clients saw a 200 with only tool events and
   an empty synthesis -- OpenAI-compatible consumers that ignore
   _toolEvent saw a successful empty reply. Now CodexUnavailableError
   re-raises out of the worker (no spurious per-tab error event),
   _await_workers re-raises it when EVERY worker hit the same
   setup failure, and the finally-block drain await propagates the
   exception out of the parallel function so the route's existing
   CodexUnavailableError handler can emit the right 503 SSE error
   frame. Per-tab runtime failures (model rejected, timeout, mid-
   stream SDK crash) still get swallowed into codex_tab_error
   events so a single bad model in the fan-out does not kill the
   others.

Test counts: 63/63 passing (60 round 6-8 plus 3 new round 9 regression
tests). Each new test was first run against a `git stash`-restored
pre-fix tree to confirm it catches the bug, then run against the
patched tree.
2026-05-27 06:58:06 +00:00
pre-commit-ci[bot]
5185032b28 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-25 15:25:04 +00:00
Daniel Han
b17765b5ed Studio: round 8 -- replay guard on non-visible events + Add-flow Codex preselect
Two P1 fixes from the round 8 reviewer pass:

1. _stream_thread_run no longer replays a Codex turn that fired
   non-visible events before crashing.

   The replay guard only tracked `emitted_any` (visible text). A
   Codex turn that emitted, say, a command.delta or file.delta event
   first -- both filtered to "" by _coerce_text -- and THEN crashed
   would leave emitted_any=False and fall through to the buffered
   `thread.run(prompt)` fallback, re-executing the same turn and
   duplicating its side effects (shell commands, file writes,
   tool calls). This is exactly the case the guard was added to
   prevent in earlier rounds; the missing bit was tracking
   "the turn ran at all", not just "the turn yielded text".

   Fix: add a separate turn_started flag that flips True the moment
   we ask the SDK for a turn handle or observe any event from a
   streaming helper. When the buffered fallback is gated on
   turn_started instead of emitted_any, a partial-turn crash
   correctly stops without replaying. Regression test reproduces
   the bug against the pre-fix code (assertion catches the extra
   thread.run call) and locks the fix in.

2. openAddProvider now mirrors the providerType-change effect's
   Codex pre-check.

   The first-run UX fix from `26799d9a` pre-checked every Codex
   default model in the providerType-change effect, but
   openAddProvider() calls resetForm() (which clears
   selectedModelIds) and then only restores availableModels, not
   selectedModelIds. If the user closes the Add connection form
   and re-opens it while Codex is still the current providerType,
   the effect does not re-run, so the form opens with Codex
   defaults available but none selected -- the "Add at least one
   model ID" save guard then blocks the Save click.

   Fix: openAddProvider now seeds selectedModelIds with the full
   default-models list when the provider is Codex, matching the
   providerType-change effect so the two entry paths produce the
   same first-run state.
2026-05-25 15:24:49 +00:00
pre-commit-ci[bot]
be15c57fee [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-25 14:45:40 +00:00
Daniel Han
dee1b68b6d Studio: round 7b -- tighten device-login log filter + harden timeout kill
Two more P1 follow-ups from the round 7 reviewer pass:

1. Device-login log filter no longer leaks sensitive lines.

   The old `_safe_to_forward` used unanchored substring matches like
   `"logged in"`, so a line such as

     Not logged in: refresh_token=rt_LEAK auth.json=/home/u/.codex/auth.json

   slipped through the safe-vocabulary filter and was streamed to the
   browser. A malicious codex shim earlier on PATH can print that
   line trivially, defeating the "opaque output stays in backend
   logs" safety guarantee the route claimed.

   Round 7b fix: anchored regex set (must start with one of the
   known upstream phrases) plus an explicit blocklist for
   refresh_token / access_token / api_key / secret / auth.json / the
   codex config dir / "not logged in" / "not authenticated". A line
   that matches the blocklist is dropped regardless of which safe
   pattern would otherwise have accepted it. New tests reconstruct
   the regex set inline and assert both the leak cases drop and the
   clean upstream phrases pass.

2. `_run_cli` timeout cleanup no longer 500s on a kill race.

   `_run_cli` would call `proc.kill()` after `os.killpg(pid, SIGTERM)`
   reaped the process group. If the SIGTERM landed first, the
   subsequent `proc.kill()` raised `ProcessLookupError` and bubbled
   out of `_run_cli`, turning `/api/codex/status` into a 500 during
   a timeout race. The device-login cleanup at codex_provider.py
   already wraps the same destructive call in a try / except. Mirror
   that exception guard here so the two timeout paths behave the
   same.
2026-05-25 14:45:26 +00:00
Daniel Han
f05517ac79 Studio: round 7 Codex hardening (cross-wrapper env scrub + device URL allowlist)
Two P1 fixes from the round 7 reviewer pass:

1. _ScrubbedEnvAsyncCodex no longer leaks secrets across overlapping
   sessions.

   The fallback env-scrub wrapper (used when the installed SDK build
   does not accept AppServerConfig(env=...)) refcounts deleted env
   vars under a shared lock so concurrent fan-out workers do not
   restore Studio's secrets while a peer is still inside SDK startup.
   The round 6 implementation enumerated keys to scrub via
   _codex_sdk_env_override() which only returns keys currently in
   os.environ. If wrapper A had already deleted HF_TOKEN before
   wrapper B entered, B's overrides dict no longer contained HF_TOKEN,
   B never bumped the refcount for it, and A's exit restored
   HF_TOKEN into the process env while B was still running -- so
   B's spawned codex app-server inherited the secret.

   Round 7 fix: under the lock, the union of (a) the current
   overrides dict and (b) every key still refcounted by an earlier
   wrapper is the set of keys this session must scrub. Originals are
   tracked module-level rather than per-instance so the last wrapper
   to release a key always restores the right pre-scrub value
   regardless of who first saw it. New regression test reproduces
   the leak against the round 6 code (asserts refcount == 2 after
   B enters; old code records 1) and locks the fix in.

2. Device-auth verification URL is now host-allowlisted.

   The codex login --device-auth output parser pulled any https URL
   matching /device, /activate, or /verify out of the CLI's stdout
   and emitted it as a device_url event. The frontend rendered that
   URL as an "Open verification page" CTA the user can click. A
   compromised codex shim earlier on PATH could print
   https://evil.example/activate?code=ABCD and Studio would surface
   the phishing link verbatim, even though every other login output
   line goes through a strict safe-vocabulary filter.

   Round 7 fix: device_url events only fire for URLs whose host is
   on a small allowlist (auth.openai.com / chatgpt.com over https).
   Anything else is logged at warn and dropped. Tests cover the
   known-good upstream URLs, several attacker patterns (lookalike
   subdomains, http downgrade, javascript:), and garbage input.
2026-05-25 14:43:10 +00:00
Daniel Han
2eaf1bbd31 Studio: pass codex_bin to AppServerConfig so PATH-only codex installs work
Reproduces with pip install openai-codex --no-deps (lightweight install
that skips the pinned openai-codex-cli-bin runtime) or any host where
the codex CLI is installed via npm i -g @openai/codex / Homebrew /
manual download. Studio constructs AsyncCodex(config=AppServerConfig(
env=...)) without codex_bin, so the SDK runs _installed_codex_path
which 'from codex_cli_bin import bundled_codex_path' and raises
FileNotFoundError: Unable to locate the pinned Codex runtime. Install
the published SDK build with its openai-codex-cli-bin dependency, or
set AppServerConfig.codex_bin explicitly. -- even though a perfectly
good codex is on PATH and was the binary the availability probe
already verified.

Fix: resolve shutil.which("codex") and pass it as
AppServerConfig(codex_bin=...). The PR's availability probe already
returns that exact path in /api/codex/status.cli_path, so we are
giving the SDK back the binary the user can see in the Connections
form. Falls back to AppServerConfig(env=...) (no codex_bin) when the
SDK build does not accept the kwarg yet, and falls back to PATH lookup
returning None on hosts without codex on PATH (in which case the
availability probe would have reported installed=false and Studio
never gets here).

Test fixture: also inject the fake module under openai_codex (the
canonical name the production importer prefers) so the test does not
silently exercise the real SDK on developer venvs that have
pip install openai-codex already done.

End-to-end verified live: pip install -e openai/codex sdk/python plus
Studio with codex CLI on PATH yielded ROUND_TRIP_OK streaming for
gpt-5.4-mini through the OpenAI-compat completions route.
2026-05-25 13:47:16 +00:00
Daniel Han
2874abbfec Studio: fail closed when Codex SDK cannot enforce safety pins
Round 6 reviewer noted that the warn-and-proceed path in
`_start_thread_with_system` is "failing open" on a server-side
chat surface: an SDK rev that does not expose ApprovalMode or
SandboxMode would log a warning then call `thread_start(model=...)`
with NO safety kwargs, letting the model run under the SDK's
`auto_review` default. For a route that takes a user-controlled
prompt and can spawn shell commands or file writes, that is the
wrong tradeoff.

Now fails closed: when `_safe_thread_safety_kwargs()` returns the
empty dict the helper raises `CodexUnavailableError`, which the
route layer translates to a 503 with a clear error message telling
the operator to upgrade `openai_codex` (or set the explicit
override env var). The error message names the override so users
who hit this on a pre-release alpha can opt in with eyes open
rather than discovering the unsafe default after the fact.

`UNSLOTH_CODEX_ALLOW_UNSAFE_DEFAULTS=1` is the deliberately-verbose
escape hatch. Variable name long and explicit so it does not creep
into production environments by accident, kept on the codex
subprocess safe-list so the round 6 SDK env-scrub wrapper does not
delete it before the gate sees it.

Tests: 50 cases total (was 49). The previous old-SDK test was
renamed and replaced by two new ones:
- `test_thread_start_fails_closed_when_safety_unavailable` asserts
  the raise fires and `thread_start` is never called.
- `test_thread_start_allows_unsafe_defaults_with_explicit_opt_in`
  asserts the override env var lets the request through and
  `thread_start` runs without the safety kwargs (with a logged
  warning).
The `_install_fake_codex_sdk` helper now injects fake ApprovalMode
and SandboxMode by default so the general translation tests do
not need to opt into the override; the two round-6b tests above
pass `with_safety_enums=False` to exercise the fail-closed branch.
2026-05-24 17:45:20 +00:00
Daniel Han
8ee60019a4 Studio: round 6 Codex hardening (5 follow-ups)
Reviewer round 6 surfaced five real follow-ups on top of rounds 5
through 5g. Each one is a fix for an asymmetric guard or a wrong-
shape lookup in the new Codex provider code:

1. Re-gate `installed=True` on having BOTH the SDK AND a `codex`
   binary on PATH. Round 5 widened the gate to SDK-only, but the
   login route still shells out to the binary, so an SDK-only host
   would surface a Codex row whose Sign-in button immediately
   failed with "codex CLI not found on PATH". The canonical
   `openai-codex` package depends on `openai-codex-cli-bin` which
   places the shim on PATH for free, so the common install still
   lights up; the gate just refuses to advertise a provider Studio
   cannot actually drive end-to-end.

2. `_safe_thread_safety_kwargs` now also probes `.api` and
   `.generated.v2_all` for `SandboxMode`. Upstream `openai_codex`
   exports `ApprovalMode` at the top level but `SandboxMode` lives
   under `openai_codex.generated.v2_all`. The previous lookup
   returned `{}` on the canonical SDK install, so every thread_start
   ran with the unsafe `auto_review` default. Submodule probe
   resolves the canonical layout and keeps backwards-compat with
   builds that DID re-export at the top level.

3. `_coerce_text` now applies the answer-event-type filter on the
   object path too. The upstream SDK emits typed payload classes
   like `CommandExecutionOutputDelta`, `FileChangeDelta`,
   `ToolCallDelta`, `PatchApplyDelta`, etc., all of which carry a
   `.delta` string of local stdout / file paths / tool args. The
   dict path already filtered these out; the object path used to
   return `.delta` unconditionally, so a real SDK install could
   leak tool output into the visible chat reply.

4. `_ScrubbedEnvAsyncCodex` is now process-wide concurrency-safe
   AND fails-closed if the SDK constructor raises:
   - Refcount each scrubbed key under an `asyncio.Lock` so a fan-out
     wrapper that exits early cannot restore a secret while another
     wrapper is still inside SDK startup (round 6 reproduced this:
     wrapper A exited, wrapper B's SDK saw the restored HF_TOKEN).
   - Move `_async_codex_cls()` and its `__aenter__` INSIDE a
     try/except in `__aenter__`; on failure, run the release path
     so the scrubbed env vars are restored even though `__aexit__`
     never fires for the failed construction.

5. `_run_cli` now detaches into its own process group via
   `start_new_session=True` (Unix) / `CREATE_NEW_PROCESS_GROUP`
   (Windows) and kills the whole group on timeout, matching the
   protected path in `stream_codex_device_login`. A shimmed
   `codex login status` that forks a helper and blocks no longer
   leaves the child running after we killed the parent.

Frontend follow-up: chat-adapter now routes every rendered yield
through a `renderFullContent()` helper so the Codex per-tab text
accumulated in earlier `_toolEvent` frames is preserved when the
synthesis content delta arrives. Previously the next regular
content yield rebuilt `parts` from `cumulativeText` alone and the
tab section vanished from the final assistant message.

Tests: 49 cases total (was 47). New regressions:
- `installed_requires_both_cli_and_sdk` (round 6 revert).
- `safety_kwargs_finds_sandbox_mode_in_submodule` (canonical SDK
  layout where `SandboxMode` is in `.generated.v2_all`).
- `scrubbed_env_construction_failure_restores_env` (no permanent
  env leak when the SDK constructor raises).
- Expanded `coerce_text_drops_non_answer_event_types` to also
  exercise the object-shape code path with `CommandExecutionOutputDelta`,
  `FileChangeDelta`, `ToolCallDelta`, `PatchApplyDelta`,
  `PlanUpdateDelta`, `AgentReasoningDelta`, plus the
  positive `AgentMessageDelta` allow-through.
2026-05-24 17:30:01 +00:00
Daniel Han
aa258b983d Studio: account for every Codex turn in fan-out usage chunk
The fan-out path used to report usage as if a single Codex call had
run: `prompt_tokens = max(1, len(prompt)//4)` and
`completion_tokens = len(synthesis)//4`. In reality it had spawned
N parallel worker turns (each carrying the same prompt) plus a
synthesis turn that re-sent the prompt and every tab's output. For
`parallel_calls=20` that meant the cost / context widget under-
reported the request by roughly 20x.

Now sums:

- `prompt_tokens ≈ (N * prompt + synthesis_prompt) / 4` where
  `synthesis_prompt = sum(tab_outputs) + prompt`.
- `completion_tokens ≈ (sum(tab_output_chars) + synthesis_chars) / 4`.

Tests: new regression
`test_parallel_usage_accounts_for_all_calls` runs a 4-way fan-out
against a fake SDK with deterministic chunk lengths and asserts
the reported usage scales with N, not the single-call shape.
2026-05-24 17:11:42 +00:00
Daniel Han
f97a800d5b Studio: round 5e Codex hardening (4 follow-ups)
1. parallel_calls validator now clamps instead of 422-rejecting.
   The Pydantic schema was `int Field(ge=1, le=20)`, which was a
   regression from the pre-PR OpenAI-extra behaviour: a non-Codex
   client that sent the field with a legacy value like 0 (or a
   stray string from a misconfigured wrapper) now got a 422 even
   though the route silently ignores the field on every non-Codex
   provider. Replaced with a `field_validator(mode="before")` that
   coerces any input to the [1, 20] range, keeping the schema docs
   self-documenting while accepting legacy inputs.

2. Buffered Codex result with `final_response=None` no longer
   leaks `TurnResult(...)` Python object repr into the chat. The
   upstream SDK documents `TurnResult.final_response` as nullable
   for turns that perform tool work without producing a final
   assistant message; the previous `... or str(result)` fallback
   would render the repr as visible assistant text. New
   `_buffered_result_text` helper returns the empty string in that
   case so the stream finishes cleanly with no extra content
   chunk. Same fix applied to `_run_codex_synthesis`.

3. Device-login SSE no longer forwards arbitrary subprocess output
   to the browser. The previous code yielded every CLI line under
   `{type:"log"}`, which on a shimmed binary could leak refresh
   tokens, auth JSON, or local config paths into the authenticated
   stream. Filtered to a known-safe vocabulary ("Welcome to
   Codex", "Initializing", "Successfully logged in", etc.).
   `device_url` and `device_code` events still fire as before.

4. CodexLoginButton no longer calls `window.open` from inside an
   awaited SSE handler. Browser popup blockers (Firefox, Safari,
   Chrome strict) silently block popups triggered outside a fresh
   user gesture, so the auto-open was unreliable. Replaced with a
   prominent "Open verification page" button styled as an anchor;
   the click handler is a real user gesture and is never blocked.
   The URL string is still shown below the button for copy/paste.

Tests: 46 cases total (was 43). New regressions cover the
parallel_calls clamp path on three garbage inputs, the buffered
TurnResult-with-None-final repr leak guard, and the device-login
log filter (asserts refresh tokens / auth.json paths are dropped
while known-safe progress lines pass through).
2026-05-24 16:59:46 +00:00
Daniel Han
8c1c63a64d Studio: surface Codex final agent message when no deltas stream
The canonical openai_codex SDK can complete a turn successfully
without emitting any `message.delta` events: the final assistant
text arrives only as an `ItemCompletedNotification` whose item is
an `agentMessage`. Before this change `_stream_thread_run` would
loop through the stream, see no delta text, return, and Studio
would emit only the empty usage + stop + `[DONE]` frames -- the
user sees a blank reply for what was actually a complete answer.

Track agent-message texts collected during the stream loop and, if
no streamed deltas came through, yield the last one before
returning. The buffered `thread.run()` fallback is still gated by
the existing `emitted_any` flag so it never replays a turn that
already executed side effects (file writes, shell commands).

`_completed_agent_message_text` accepts both the upstream object
shape (`ItemCompletedNotification(item.root.text=...)`) and the
dict shape pre-release builds and tests use, so it works across SDK
revs without an explicit version gate.

Tests: new regression
`test_empty_stream_falls_back_to_completed_agent_message` exercises
a fake SDK whose `turn().stream()` yields only an `item.completed`
event with an `agentMessage`; the test asserts the final text
reaches the visible chat output and that the buffered `run()` path
is NOT re-executed.
2026-05-24 16:44:22 +00:00
Daniel Han
c5289f0249 Studio: pin Codex thread approvals + sandbox to safe defaults
The upstream openai_codex SDK defaults `approval_mode` to
`ApprovalMode.auto_review` (described in the SDK docs as "automatically
execute tools when permission escalations occur, without user
intervention") and leaves `sandbox` unset. Studio drives Codex from
a server-side chat request with no per-action approval UI, so leaving
those at the SDK defaults would let a model decide on its own to run
shell commands, write files, or hit the network on the operator's
machine.

This wires every `thread_start` call (single-turn, parallel-worker,
synthesis) through a helper that pins:

- `approval_mode = ApprovalMode.deny_all` -- reject any tool /
  command escalation rather than auto-approving it.
- `sandbox = SandboxMode.read_only` -- the policy that bans file
  writes and disables network.

The kwargs are looked up dynamically: when the installed SDK is too
old to expose either enum we log a structured warning and proceed
without them rather than refusing to run, so users on pre-release
alpha builds are not bricked. Once the canonical openai-codex SDK
is what every install pulls, the warning will be silent and the
safety pins will always apply.

Tests: three new regressions in TestCodexHardenedRegressions cover
the safe-pin path on a fake SDK that exposes the enums, the
warn-and-proceed path on a fake SDK that does not, and the same
pins on the synthesis turn so a fan-out tab cannot sneak an unsafe
default into the unification step.
2026-05-24 16:40:48 +00:00
Daniel Han
1a107651c4 Studio: round 5 hardening for the Codex provider
Five tightening fixes driven by the reviewer pass on top of round 4:

1. Drop OPENAI_API_KEY from the codex subprocess safe-list.
   The OpenAI provider key belongs to the OpenAI provider; a shimmed
   `codex` binary on PATH must not receive it. Users who want to wire
   the same key into Codex now set CODEX_OPENAI_API_KEY, which is the
   one OpenAI-shaped key we still forward.

2. Fail-closed env scrub on the SDK path.
   When AppServerConfig is missing from the installed openai_codex
   build, the bare AsyncCodex() constructor used to inherit the full
   os.environ via the SDK's internal os.environ.copy(). Replaced the
   fallback with a _ScrubbedEnvAsyncCodex wrapper that swaps
   os.environ for the lifetime of the session so HF_TOKEN, GH_TOKEN,
   WANDB_API_KEY etc never reach the spawned app-server.

3. Prefer the upstream-canonical base_instructions kwarg.
   The real openai_codex SDK takes the system prompt as
   `base_instructions`; our previous helper only knew `system`. Now
   tries base_instructions first, falls back to system, then inlines
   the system text in the user prompt as a last resort.

4. Filter visible text to answer-bearing event types only.
   _coerce_text used to render any payload that exposed a `delta` /
   `text` / `content` field, which let command output, file paths and
   tool call arguments leak into the Chat Completions reply. Gated on
   a _ANSWER_EVENT_TYPES allow-list (message.delta, completed,
   text_delta, etc.); untyped legacy dicts still pass through.

5. Treat the SDK as the install gate.
   openai-codex-cli-bin ships the codex runtime that backs
   AsyncCodex(...), so SDK alone is sufficient to drive the provider.
   `installed` no longer also requires a standalone codex binary on
   PATH; cli_path remains reported separately so the UI can still
   show whether a CLI is also installed.

Also added the matching positive-match regex line ("Authenticated:
Yes") for one more login-status wording the CLI ships in some
locales.

Tests: grown to 39 cases. New regressions cover the SDK-only install
gate, base_instructions kwarg priority + system fallback, the
fail-closed env scrub wrapper, the answer-only delta filter, the
Authenticated: Yes wording, and the CODEX_OPENAI_API_KEY-vs-
OPENAI_API_KEY split.
2026-05-24 16:20:27 +00:00
pre-commit-ci[bot]
593fc9edba [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 15:44:59 +00:00
Daniel Han
67b837e9f1 Merge branch 'feat/codex-provider' of https://github.com/unslothai/unsloth into feat/codex-provider 2026-05-24 15:44:11 +00:00
Daniel Han
4be807bbd8 Studio: round 4 hardening for the Codex provider
Fourth reviewer.py pass surfaced one more security finding and a
handful of correctness gaps. Each is small but the env-scrub for the
SDK path closes the asymmetric-fix loop opened in the previous round.

* Codex SDK construction now passes an `AppServerConfig(env=...)`
  that overrides every non-safe-listed env key to an empty string.
  Upstream openai/codex/sdk/python/client.py builds the spawn env as
  `os.environ.copy()` then `env.update(self.config.env)`, so this
  scrubs HF_TOKEN / GH_TOKEN / WANDB_API_KEY / ANTHROPIC_API_KEY etc.
  out of the codex app-server subprocess env on the chat / parallel
  / synthesis paths, matching the CLI/login paths from the previous
  round. The helper falls back to bare `AsyncCodex()` when the SDK
  version does not expose AppServerConfig, with logged warning.

* Install hint now names the actual upstream PyPI project,
  `openai-codex` (canonical), with `codex_app_server` documented as
  the legacy alias. The probe still accepts both import names so
  forward compat is preserved.

* Device-auth URL regex broadened to accept upstream's current
  `chatgpt.com/activate` shape and any `/device|/activate|/verify`
  variant, not just `/codex/device`. The frontend can now open the
  verification page on CLI builds that print the documented
  ChatGPT-style URL.

* `_run_codex_synthesis` now takes a `system` arg and forwards it
  to `thread_start(system=...)`, falling back to a prompt-prefix on
  older SDK revs that reject the kwarg. Previously a fan-out with
  "Always answer in Spanish" produced Spanish per-tab attempts but
  an English synthesis.

* `_detect_logged_in` negative regex now also matches "Not signed
  in", "Please sign in" (alternative localisations / future CLI
  releases). Same word-boundary anchoring as before.

* Frontend `CodexLoginEvent` union gains `device_code` and a `code`
  field. `CodexLoginButton` now renders the one-time code under the
  verification URL so users on a headless / remote install can copy
  the code without scraping the log pane. Also fixes a closure-stale
  bug where setError(message) was followed by a stale `error` read,
  losing specific backend errors; the new path keeps `lastStreamError`
  inside the closure.

* Replaced four hardcoded `/mnt/disks/...` paths in the new
  regression tests with `_backend_file()` resolved from `__file__`,
  so the suite runs in any checkout (CI, local dev, the review
  worker tree). Found by the round-4 reviewer.

Four new pytest cases pin the behaviour:
`test_not_signed_in_wording_also_handled`,
`test_device_url_accepts_generic_verification_url`,
`test_synthesis_call_forwards_system_prompt`, and
`test_sdk_env_scrubbed_via_appserverconfig`. 32/32 codex_provider
tests pass; `tsc --noEmit` clean.
2026-05-24 15:44:11 +00:00
pre-commit-ci[bot]
4b4c8553f8 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 15:16:52 +00:00
Daniel Han
fd8f25f507 Studio: scrub Codex subprocess env, guard partial-stream replay, abort login on unmount
Third reviewer.py pass found three remaining sharp edges. Each fix
is small and paired with a regression test where applicable.

* Codex subprocess env is now scrubbed to a safe-list before spawn.
  Both `_run_cli` in codex_availability and the device-auth spawn
  in stream_codex_device_login switch from `env=os.environ.copy()`
  to `env=_codex_subprocess_env()`, which forwards only PATH /
  HOME / USER / Windows-equivalents / CODEX_HOME / OPENAI_API_KEY /
  OPENAI_BASE_URL. Other-provider secrets like HF_TOKEN, GH_TOKEN,
  WANDB_API_KEY, ANTHROPIC_API_KEY no longer reach the local codex
  binary, so a shimmed `codex` earlier on PATH cannot harvest them.

* `_stream_thread_run` now tracks `emitted_any` and refuses to fall
  through to the buffered `await thread.run(prompt)` after either
  streaming helper has already yielded text. Previously a network
  glitch mid-stream re-executed the same Codex turn, which can
  duplicate file writes, shell commands, and other Codex side
  effects. The buffered path is now reserved for the zero-output
  case (no streaming helper resolved, or streaming returned empty).

* `CodexLoginButton` now aborts the SSE reader on unmount via a
  useEffect cleanup that calls `abortRef.current?.abort()`. The
  underlying `codex login --device-auth` subprocess no longer
  keeps streaming (and holding a device-auth session) after the
  dialog closes.

Two new pytest cases pin the behaviour: `test_codex_subprocess_env_scrubbed`
sets HF/GH/WANDB/ANTHROPIC keys and asserts none reach the codex
env while OPENAI_API_KEY / CODEX_HOME survive; and
`test_partial_stream_failure_does_not_replay_turn` injects a fake
`turn().stream()` that yields "partial output " then raises, and
asserts `thread.run()` is never called. 28/28 codex_provider tests
pass; `tsc --noEmit` clean.
2026-05-24 15:16:38 +00:00
pre-commit-ci[bot]
e2ac4907bf [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 14:46:17 +00:00
Daniel Han
d6c47f6664 Studio: wire Codex provider through the UI end-to-end
Post-review pass driven by reviewer.py. The original PR shipped the
backend codex provider, the registry entry (with `hidden:true`), the
status API, and the `CodexParallelTabs` component, but the chat UI
never surfaced the row, required an API key for the connection, and
never sent `parallel_calls` over the wire. Also fixes a CodeQL leak
in the parallel fan-out error path and adds the canonical streaming
hook upstream actually exposes.

Frontend
* chat-providers-dialog.tsx now calls `/api/codex/status` alongside
  `/api/providers/registry`. When the host has Codex installed the
  Add connection dialog gains a synthetic Codex row (curated model
  list comes from `supported_models`) so the picker is reachable.
* The Add / Edit connection guards now skip the API-key requirement
  for Codex the same way they do for the custom OpenAI-compat
  presets; the field itself is also hidden so the user is not asked
  for a key Studio will not use.
* chat-adapter.ts now also exempts Codex from the "Missing API key"
  pre-flight, and emits `parallel_calls` on the outgoing request
  when the selected connection is Codex (clamped to [1, 20] by the
  shared helper, defaults to 1).
* external-providers.ts adds `codexParallelCalls` to
  ExternalProviderConfig so future composer UI can persist the
  user's pick per connection.

Backend
* `_stream_thread_run` now tries `thread.turn(prompt).stream()`
  first, mirroring the canonical openai_codex API
  (`openai/codex/sdk/python/src/openai_codex/api.py`). The legacy
  `thread.run_streaming(prompt)` path is kept as a fallback and the
  buffered `await thread.run(prompt)` stays as the last resort.
* `_stream_codex_parallel` no longer echoes `str(exc)` in the
  `codex_tab_error` SSE event. Per-tab failures now surface a
  generic "Codex tab failed" message plus an `exception_type`
  discriminator; `CodexUnavailableError` is the only exception
  whose text is forwarded verbatim because it is a user-actionable
  install hint with no sensitive content (CodeQL
  `py/information-exposure-through-exception`).

Tests
* New `TestCodexHardenedRegressions::test_parallel_tab_error_sanitised`
  injects a fake SDK that raises with a path-like message and
  asserts the SSE frames do not echo it.
* New `TestCodexHardenedRegressions::test_thread_turn_stream_path_taken`
  verifies the canonical `thread.turn(prompt).stream()` hook is
  preferred over the legacy helper.

All 26 codex_provider tests pass. Frontend `tsc --noEmit` clean.
2026-05-24 14:46:03 +00:00
pre-commit-ci[bot]
861da31fcf [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 14:13:24 +00:00
Daniel Han
b8cd677397 Studio: harden Codex provider against upstream CLI/SDK shape
Followups on the post-merge review pass for the Codex SDK chat
provider. Verified against codex-cli 0.133.0 + the upstream
`openai/codex` Rust + Python sources, then pinned each fix with
a regression test in `test_codex_provider.py` (24/24 passing).

* Probe both `openai_codex` (canonical upstream Python package at
  `openai/codex/sdk/python`) and the legacy `codex_app_server`
  alias. Without this the availability probe always reported
  `sdk_importable: false` even when the SDK was installed, so the
  provider was permanently hidden.
* Switch the device-auth and login-status invocations from
  `codex auth login --device-auth` / `codex auth status` to the
  real upstream subcommands `codex login --device-auth` and
  `codex login status`. The former path returns
  `unrecognized subcommand 'auth'` on a real CLI.
* Strip ANSI control sequences before extracting the device URL
  (upstream wraps the URL in `\x1b[34m...\x1b[0m`) and tighten the
  pattern to the canonical `.../codex/device` shape. Also surface
  the one-time code as a `device_code` SSE event so the UI can
  show it alongside the URL.
* Fix `_detect_logged_in` substring footgun: `"logged in" in
  combined` matched inside `"not logged in"`, flipping logged-out
  users to logged-in. Anchor on word boundaries with negative
  prefixes winning regardless of return code.
* Cancel in-flight fan-out workers on SSE disconnect. Previously
  every parallel Codex turn ran to completion against a
  disconnected client and burned quota; now `_stream_codex_parallel`
  cancels its worker + drain tasks in a try/finally on
  `CancelledError`/`GeneratorExit`.
* Tear down the device-login subprocess on disconnect via
  `start_new_session=True` + `os.killpg(SIGTERM)` (Unix) or
  `CREATE_NEW_PROCESS_GROUP` + `CTRL_BREAK_EVENT` (Windows), with
  a bounded `proc.wait()` and `proc.kill()` fallback. Previously
  `finally: await proc.wait()` blocked the SSE close path because
  `codex login --device-auth` only exits on user action.
* Render the full conversation transcript in `_last_user_prompt`
  instead of returning only the most recent user message. The PR
  opens a fresh thread per request so prior assistant turns were
  dropped, degrading multi-turn chats to single-shot prompts.
  Single-turn input is unchanged.
* Make `ChatCompletionRequest.parallel_calls` default to 1 (`int`
  with `ge=1, le=20`) instead of `Optional[int] = None`. The
  runtime already coerced `None` -> 1, but the schema now matches
  the documented `[1, 20]` range.
* Replace the registry's hardcoded `default_models` (which
  contained `o3`, not in the upstream catalog) with the current
  `gpt-5.5 / 5.4 / 5.4-mini / 5.3-codex / 5.2` set from
  `codex-rs/models-manager/models.json`.
* Stop echoing `str(exc)` in SSE error frames in both
  `routes/inference.py` and `routes/codex.py`. The Codex SDK can
  raise with local paths, env-var content, or traceback fragments
  (CodeQL `py/information-exposure-through-exception`). Surface a
  generic message + `exception_type` discriminator; log the full
  reason server-side via `logger.error(..., exc_type=..., error=...)`.

Doc / comment updates throughout to refer to `codex login` /
`openai_codex` rather than the older incorrect strings.

Tested: pytest 24 cases in `test_codex_provider.py` (the original
14 + 10 new `TestCodexHardenedRegressions`) plus the rest of the
Studio-backend test suite the PR touches (209 passing). Also
verified live against Studio launched from this branch on a
Blackwell B200 via `UNSLOTH_STUDIO_HOME=$WORKSPACE/temp/...
./install.sh --local` then a Playwright probe.
2026-05-24 14:13:03 +00:00
Daniel Han
cbc3c43655 Studio: add Codex SDK as a chat provider with parallel-calls fan-out
Wires the OpenAI Codex CLI / Python SDK (codex_app_server) into Studio
as a new chat provider type. Hosts that don't have the CLI or the SDK
installed never see the entry; on logged-out hosts the provider config
dialog renders a device-auth Sign-in button that surfaces the
verification URL and streams CLI progress back over SSE.

Backend
- new core/inference/codex_availability.py probes the CLI + SDK and
  reports {installed, logged_in, version, supported_models}; it never
  imports codex_app_server at module top level so the rest of the
  backend keeps starting cleanly on hosts that don't have the SDK.
- new core/inference/codex_provider.py wraps AsyncCodex and translates
  Codex events into OpenAI chat-completion chunks. Supports the
  thread.run_streaming path with a non-streaming fallback for older
  SDK revs.
- parallel_calls > 1 fans the turn out across N tasks (capped at 20)
  via asyncio.gather and emits codex_tab_open / codex_tab_chunk /
  codex_tab_close tool-events per attempt plus a final codex_gather
  synthesis event. A separate standalone Codex call produces the
  unified answer.
- new routes/codex.py exposes GET /api/codex/status and POST
  /api/codex/login. The login route shells out to
  codex auth login --device-auth and streams events; the first event
  carries the verification URL so the frontend can window.open it.
- ChatCompletionRequest gains a parallel_calls field bounded [1, 20]
  by pydantic. The codex registry entry stays hidden by default; the
  /api/codex/status probe is the authoritative gate.
- routes/inference.py dispatches provider_type=codex through the
  local CLI/SDK pipeline instead of the standard HTTP client, with
  graceful error surfacing for CodexUnavailableError.

Frontend
- new api/codex-api.ts exposes fetchCodexStatus() and an async
  generator streamCodexDeviceLogin() that drives the SSE stream and
  yields parsed events.
- new components/codex-parallel-tabs.tsx renders the tabbed parallel-
  calls UI with a Synthesis tab highlighted once the codex_gather
  event arrives. Pure reducer keeps the state transitions unit-
  testable.
- new components/codex-login-button.tsx posts to /api/codex/login,
  opens the verification URL in a new tab via window.open, and shows
  the streamed CLI log as it lands.
- external-providers.ts exports CODEX_PROVIDER_TYPE,
  CODEX_MAX_PARALLEL_CALLS, isCodexProviderType, and
  clampCodexParallelCalls. Codex is marked text-only so the composer
  hides image-attach affordances when selected.

Tests
- tests/test_codex_provider.py (14 cases) covers the availability
  probe across the four install / login states, the streaming +
  parallel-calls translation against a fake codex_app_server module
  injected into sys.modules, the [1, 20] pydantic clamp, the
  CodexUnavailableError surfacing path, and the parallel_calls=1
  single-call shape (no tab tool-events).
2026-05-23 14:00:31 +00:00