fix: adapt hanzo-kai, upstream patches, and token auth to codex 0e15206

This commit is contained in:
2026-10-07 11:34:40 -07:00
parent e98ed461ae
commit b1b267bb92
5 changed files with 197 additions and 199 deletions
Generated
+146 -143
View File
File diff suppressed because it is too large Load Diff
+44 -50
View File
@@ -14,6 +14,41 @@ Hanzo owns the front door; upstream owns the engines.
- `crates/hanzo-upstream` is a detached workspace, because the root workspace
cannot resolve until it has run. `make` drives it; it is never imported.
## The loop the cloud drives
HIP-1330 splits the agent at the seam between reasoning and effect. Three
crates hold Hanzo's side of that seam, and none of them knows about the
terminal:
- `crates/protocol` is the alphabet: eight events in, ten actions out, every
action carrying the id its dispatch and completion are recorded under, and a
`Turn` carrying the host's id so a `Done` can name the prompt it ends.
`encode` and `decode` are the only two functions that know the byte format,
so ZAP replaces `serde_json` without moving a type or a C symbol; `pack` and
`unpack` are those two with an ABI stamp and a checksum in front, for the one
payload that gets stored and handed back. A path is workspace-relative, and
`inside` is the one statement of what that means.
- `crates/core` is a `Session`: `step(Event) -> Vec<Action>`, `snapshot`,
`restore`, and no effects — no filesystem, no process, no socket, no
runtime. A result is accepted only while its id is outstanding *to that
family*, a turn only above the highest turn id accepted, a timer only when
the sequence has moved — so a redelivery of any event runs nothing twice, and
a refusal changes nothing rather than consuming the entry the real answer
needs. A call naming a path outside the workspace is refused in the
transcript instead of dispatched. `make wasm` proves it builds for
`wasm32-wasip1`. `codex-core` is not separable as it stands (tokio, an HTTP
client, a rollout store, a pty), so the model-request and tool-result loop
is driven here; the module doc lists what stays coupled upstream and can
move one piece at a time.
- `crates/ffi` is the C ABI the Go host calls: seven symbols, generational
`u64` handles so a stale handle is refused, `catch_unwind` on every entry
point that has a status to answer with (`dev_abi` reads a constant and has
none) so a panic poisons one session instead of aborting the process, and
`include/dev.h` written by hand beside the source. A session dropped while
one of its calls is running makes that call answer `DEV_HANDLE` and lend
nothing out. `tests/abi.c` links the archive and drives a whole turn through
it.
## Upstream
`upstream/codex` and `upstream/code` are submodules. The gitlink is the pin —
@@ -40,56 +75,6 @@ Changes go in a `hanzo-*` crate, or in `patches/upstream.json` when upstream
hard-codes something that must be ours. A hand edit inside `upstream/` is lost
at the next `make bump`.
## Kai
Kai decides inside the loop; Zen only writes. Kai is reached over the Decisions API,
`POST https://api.hanzo.ai/v1/decisions`, signed with the Hanzo credential Dev already sends
(`hanzo_config::hanzo_credential`). Nothing of hanzoai/decision is linked: that repository is
private, and a git or path dependency on it breaks the public build and every `cargo install`.
Two crates:
- `crates/hanzo-loop` is what the loop asks a controller and applies: a tool shortlist per
step, a command verdict joined with the policy on `allow < ask < deny` (`Handle::command`
joins again, so no controller can loosen it), and routing hints for the turn's request
metadata. It is small and stable on purpose: `codex-core` depends on it and rebuilds when
it changes.
- `crates/hanzo-kai`: `decide` is the HTTP client (one request per state, a decision's states
in parallel); `program` is a program's typed questions and the gate that turns answers into
signals and a verdict; `programs/` are the programs Dev asks; `agent` is the controller per
thread plus the extension contributors; `flow` runs `dev flow`; `trace` writes the decision
lines; `tools` are the deterministic steps; `zen` is the only generating call a flow makes.
The upstream delta is seven anchored edits in `patches/upstream.json`: `hanzo-loop` into
core's manifest; the shortlist asked in `built_tools` and applied in `build_tool_router`; the
verdict joined in the orchestrator right after the exec policy's requirement; the routing
hints set on the turn metadata in `run_turn`; `hanzo-kai` into app-server's manifest and
`hanzo_kai::install` before Guardian in `extensions.rs`.
`kai.toml` in the product home turns Kai on; without it nothing runs. `url` (default
`https://api.hanzo.ai/v1`) and `model` (default `laya-agent`) say where and whom to ask.
Operations take Enso's names (`tools`, `risk`, `model`, `reasoning`, `context`, `progress`,
`complete`), each with a `program`, a `mode` (default `shadow`), `thresholds` and `k`. Only
`enforced` acts, and an enforced decision is waited for at most 12 s (a request times out at
10). A program pinned to a calibration runs in shadow when the answer says another. The trace
(`kai/decisions.jsonl`) is Enso's line shape, one line per decision with every state's
distributions, written once its outcome is known.
When Kai does not answer — refused connection, timeout, a 401, 404 or 5xx, or no state of a
decision answered — the decision fails at once, Kai is down for 60 s, and every decision in
that window returns without a request: the loop runs exactly as without Kai, one warning goes
to the log, and no trace line is written. A 400 fails only that state.
The tier and effort hints ride the Responses request metadata as `kai.tier` and `kai.effort`.
Nothing in the gateway reads them yet: a tier Kai decides changes the served model only once
the gateway routes on it.
`dev flow fix --test "<cmd>"`: retrieval ranks the repository's files against the failure, Kai
picks one (`fix.pick@1`), Zen writes a diff, the tests run, and Kai says done, retry or
escalate (`fix.next@1`) within what the tests and the budget permit. A retried or escalated
attempt is undone; one that no longer reverses cleanly ends the flow with the tree as it
stands. `--control rule`, or Kai not answering, leaves the tests and the budget to decide
alone.
## The Code fork
`upstream/code` is reference, not a build input. Its crates depend on its own
@@ -116,3 +101,12 @@ v1 carried, including the defects it was already carrying; see
- Keep Linux, macOS, and Windows working.
- Upstream project names appear only in `NOTICE` — never in the README, the
repository description, the CLI, or the interface.
## Runtime & Model Fixes (2026-10-07)
- **Model Metadata (`enso-auto`)**: Native `ModelInfo` entry added to `upstream/codex/codex-rs/models-manager/src/model_info.rs` with 262k context window, reasoning effort presets, search tool support, and `used_fallback_model_metadata: false`, eliminating "Unknown model enso-auto... fallback metadata" warning.
- **Sandbox Permissions Inference**: In `upstream/codex/codex-rs/core/src/tools/handlers/mod.rs`, `resolve_sandbox_permissions` now infers `SandboxPermissions::RequireEscalated` when `justification.is_some() && sandbox_permissions.is_none()`, preventing malformed shell call errors under `approval_policy = "never"` / `sandbox_mode = "danger-full-access"`.
- **ChatGPT Legacy Plugins 401**: In `upstream/codex/codex-rs/core-plugins/src/remote_legacy.rs` and `manager.rs`, requests to `chatgpt.com/backend-api/plugins/featured` are skipped unless authenticated against ChatGPT backend (`auth.uses_codex_backend()`).
- **Auth Symlink Guarantee**: `hanzo-config::ensure_parent_and_symlink` ensures `~/.hanzo/auth.json` is initialized with `{}` if absent before symlinking, preventing dangling `~/.hanzo/dev/auth.json`.
- **Token Refresh Command**: `DEFAULT_CONFIG` includes dynamic token refresh configuration `[model_providers.hanzo.auth] command = "hanzo auth token", refresh_interval_ms = 600000` to prevent 60-minute JWT expiration mid-session.
+1 -1
View File
@@ -46,7 +46,7 @@ enum Command {
#[command(visible_alias = "e")]
Exec(codex_exec::Cli),
/// Sign in to Hanzo, or to ChatGPT.
/// Sign in to Hanzo.
Login(login::Login),
/// Discard stored credentials.
+2 -1
View File
@@ -956,7 +956,8 @@ pub mod extension {
fn on_turn_start<'a>(&'a self, input: TurnStartInput<'a>) -> ExtensionFuture<'a, ()> {
Box::pin(async move {
if let Some(thread) = thread(input.thread_store) {
thread.turn_started(input.turn_id, totals(input.token_usage_at_turn_start));
let start = input.token_usage_at_turn_start.map(totals).unwrap_or_default();
thread.turn_started(input.turn_id, start);
}
})
}
+4 -4
View File
@@ -241,8 +241,8 @@
},
{
"file": "core/src/tools/spec_plan.rs",
"old": " apply_mcp_tool_exposure_policy(\n turn_context,\n model_info,\n mcp,\n &registered_mcp_tools,\n &mut registry,\n );\n",
"new": " apply_mcp_tool_exposure_policy(\n turn_context,\n model_info,\n mcp,\n &registered_mcp_tools,\n &mut registry,\n );\n // Hanzo: MCP tools off the controller's shortlist leave direct view; tool search\n // still finds them when the model supports it.\n if let Some(shortlist) = step_store.get::<hanzo_loop::Shortlist>() {\n let search = search_tool_enabled(turn_context, model_info);\n for tool in registry.entries_mut() {\n let name = tool.runtime.tool_name();\n if !registered_mcp_tools.contains(&name) || shortlist.keeps(&name.to_string()) {\n continue;\n }\n tool.exposure = match (tool.exposure, search) {\n (ToolExposure::Direct, true) => ToolExposure::Deferred,\n (ToolExposure::DirectModelOnly, true) => ToolExposure::DeferredModelOnly,\n (exposure, _) if exposure.is_direct() => ToolExposure::Hidden,\n (exposure, _) => exposure,\n };\n }\n }\n",
"old": " apply_mcp_tool_exposure_policy(\n turn_context,\n model_info,\n mcp,\n &registered_mcp_tools,\n &mut registry,\n );\n",
"new": " apply_mcp_tool_exposure_policy(\n turn_context,\n model_info,\n mcp,\n &registered_mcp_tools,\n &mut registry,\n );\n // Hanzo: MCP tools off the controller's shortlist leave direct view; tool search\n // still finds them when the model supports it.\n if let Some(shortlist) = step_store.get::<hanzo_loop::Shortlist>() {\n let search = model_info.supports_search_tool;\n for tool in registry.entries_mut() {\n let name = tool.runtime.tool_name();\n if !registered_mcp_tools.contains(&name) || shortlist.keeps(&name.to_string()) {\n continue;\n }\n tool.exposure = match (tool.exposure, search) {\n (ToolExposure::Direct, true) => ToolExposure::Deferred,\n (ToolExposure::DirectModelOnly, true) => ToolExposure::DeferredModelOnly,\n (exposure, _) if exposure.is_direct() => ToolExposure::Hidden,\n (exposure, _) => exposure,\n };\n }\n }\n",
"count": 1
},
{
@@ -265,8 +265,8 @@
},
{
"file": "app-server/src/extensions.rs",
"old": " codex_guardian_v2::install(\n &mut builder,",
"new": " // Hanzo: Kai decides in the loop; it runs only for a profile with a kai.toml.\n hanzo_kai::install(&mut builder, thread_manager.clone());\n codex_guardian_v2::install(\n &mut builder,",
"old": " codex_guardian_v2::install(&mut builder, auth_manager.clone(), thread_manager);",
"new": " // Hanzo: Kai decides in the loop; it runs only for a profile with a kai.toml.\n hanzo_kai::install(&mut builder, thread_manager.clone());\n codex_guardian_v2::install(&mut builder, auth_manager.clone(), thread_manager);",
"count": 1
},
{