fix: Daybreak follows the launch policy and reaches isolated turns

Platform.LOCAL is the canonical transport, not the owner: hosted rooms
(bot_room) and A2A peers stage LOCAL sessions too, so the platform gate
still sent Daybreak on their turns. The gateway now reads the route's
frozen launch policy and allows only the owner's own creation labels
(cli, tui, gui, oneshot; gateway.session_policy.DAYBREAK_LABELS). Third-
party tool integrations, editor (ACP) clients and an unreadable policy
get standard requests.

With turn isolation the live agent is in the compute-host child, which
read the switch once at build; the parent's live flip only reaches its
own process. The child now re-reads it from its profile's config.yaml on
every reused turn, with the same local-surface gate.
This commit is contained in:
unsupportedpastels authored and Mark S. committed 2026-11-02 15:06:27 -05:00
1 parent 7dbe1bff8f
commit b51e4272ea
6 files changed
+99 -18

No files matched your search

+11 -4
View File
@@ -280,15 +280,22 @@ class GatewayConfigLoadersMixin:
return _parse_service_tier_config(cls._cfg_str("agent", "service_tier"))
@staticmethod
def _load_daybreak(source) -> bool:
def _load_daybreak(self, source) -> bool:
"""``agent.daybreak`` (off unless explicitly true) for a turn from ``source``. Read per turn so the
Desktop switch reaches the next turn of a running chat without rebuilding its cached agent. Only the
owner's local surfaces carry it: Daybreak is the approved account's own access (OpenAI: no third-party
or downstream use), and a messaging platform's chats can be other people."""
owner's own chats carry it: Daybreak is the approved account's own access (OpenAI: no third-party
or downstream use), and a messaging platform's chats, a hosted room or an A2A peer can be other people.
LOCAL is the canonical transport, not the owner, so the route's frozen launch policy decides."""
from gateway.run import _load_gateway_config
from gateway.session_policy import DAYBREAK_LABELS, policy_for_source
if getattr(source, "platform", None) != Platform.LOCAL:
return False
try:
policy = policy_for_source(self, source)
except Exception: # health: allow BLE001 -- an unreadable policy fails closed to standard requests
return False
if getattr(policy, "source", None) not in DAYBREAK_LABELS:
return False
return cfg_get(_load_gateway_config(), "agent", "daybreak") is True
@staticmethod
+4
View File
@@ -84,6 +84,10 @@ _TURN_PROFILE: ContextVar = ContextVar('follow_profile_read', default=None) # (
# pickers hide them (INTERNAL_LISTING_SOURCES).
SURFACES = {'cli': 'cli', 'tui': 'tui', 'gui': 'desktop', 'acp': 'acp', 'tool': 'cli', 'oneshot': 'cli'}
# ``tool`` and ``oneshot`` stay stored under their own labels so pickers can hide them.
# Creation labels of the owner's own chats: the only LOCAL routes that carry OpenAI Daybreak (agent.daybreak),
# the approved account's own access (no third-party or downstream use). Not ``tool`` (third-party
# integrations), ``acp`` (editor clients), or the ``bot_room`` / ``a2a`` policies hosted rooms and peers stage.
DAYBREAK_LABELS = frozenset({'cli', 'tui', 'gui', 'oneshot'})
# Every other creation label is rewritten to ``source='local'`` on peer refresh.
_KEEP_STORED_LABEL = frozenset({'tool', 'oneshot'})
STORED_AS_LOCAL = frozenset(SURFACES) - _KEEP_STORED_LABEL
+40 -13
View File
@@ -107,36 +107,63 @@ def test_offered_only_where_the_account_lists_it(catalog):
access_token=token("A"))
def _owner_policies(home, monkeypatch):
"""Real launch policies, built the way each producer builds them (build_policy, then the hosted-room and
A2A rewrites in gateway/session_hosted_rpc.py and gateway/session_a2a.py), keyed by a LOCAL chat id."""
from dataclasses import replace
from gateway import run as gateway_run
from gateway import session_policy
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(gateway_run, "_hermes_home", home) # frozen at import by an earlier test
policies = {label: session_policy.build_policy({"source": label}, {}, private_secrets={})
for label in ("gui", "cli", "tui", "oneshot", "tool", "acp")}
policies["bot_room"] = replace(policies["gui"], source="bot_room", platform="bot_room")
policies["a2a"] = replace(policies["cli"], source="a2a", platform="a2a")
def policy_for_source(runner, source):
if source.chat_id == "unreadable":
raise RuntimeError("storage_unavailable")
return policies[source.chat_id]
monkeypatch.setattr(session_policy, "policy_for_source", policy_for_source)
return policies
def _load(platform, chat_id="gui"):
from gateway.run_config_loaders import GatewayConfigLoadersMixin
return GatewayConfigLoadersMixin._load_daybreak(SimpleNamespace(), SimpleNamespace(platform=platform,
chat_id=chat_id))
def test_gateway_reads_the_profile_switch_each_turn(tmp_path, monkeypatch):
"""The owner's chat turns (Desktop, TUI, ``hermes chat``: ``Platform.LOCAL``) run in the gateway, which copies
the switch onto the turn's agent from the profile's config.yaml, so a flip reaches the next turn of a running chat."""
from gateway.config import Platform
from gateway.run_config_loaders import GatewayConfigLoadersMixin
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
_owner_policies(home, monkeypatch)
seen = {}
for value in ("true", "false", '"yes"', None):
(home / "config.yaml").write_text("model: {}\n" if value is None else f"agent:\n daybreak: {value}\n",
encoding="utf-8")
seen[value] = GatewayConfigLoadersMixin._load_daybreak(SimpleNamespace(platform=Platform.LOCAL))
seen[value] = _load(Platform.LOCAL)
assert seen == {"true": True, "false": False, '"yes"': False, None: False}
def test_messaging_platform_turns_never_carry_daybreak(tmp_path, monkeypatch):
"""Daybreak is the approved account's own access (OpenAI: no third-party or downstream use). A messaging
platform's chats can be other people, so with the switch on only the owner's local surfaces carry it."""
from gateway import run as gateway_run
def test_only_the_owners_own_chats_carry_daybreak(tmp_path, monkeypatch):
"""Daybreak is the approved account's own access (OpenAI: no third-party or downstream use). Messaging
chats, hosted rooms, A2A peers, third-party ``tool`` integrations and editor clients can be other people,
so with the switch on only the owner's Desktop, TUI and CLI chats carry it."""
from gateway.config import Platform
from gateway.run_config_loaders import GatewayConfigLoadersMixin
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(gateway_run, "_hermes_home", home) # frozen at import by an earlier test
policies = _owner_policies(home, monkeypatch)
(home / "config.yaml").write_text("agent:\n daybreak: true\n", encoding="utf-8")
loaded = {platform: GatewayConfigLoadersMixin._load_daybreak(SimpleNamespace(platform=platform))
for platform in (Platform.LOCAL, Platform.TELEGRAM, Platform.DISCORD, Platform.API_SERVER)}
assert loaded == {Platform.LOCAL: True, Platform.TELEGRAM: False, Platform.DISCORD: False,
Platform.API_SERVER: False}
platforms = {platform: _load(platform) for platform in (Platform.TELEGRAM, Platform.DISCORD, Platform.API_SERVER)}
assert platforms == {Platform.TELEGRAM: False, Platform.DISCORD: False, Platform.API_SERVER: False}
local = {label: _load(Platform.LOCAL, label) for label in [*policies, "unreadable"]}
assert local == {"gui": True, "cli": True, "tui": True, "oneshot": True, "tool": False, "acp": False,
"bot_room": False, "a2a": False, "unreadable": False}
def test_model_menu_capability_marks_only_listed_subscription_models(catalog, monkeypatch):
+24
View File
@@ -95,3 +95,27 @@ def test_new_agents_read_the_switch_only_for_local_surfaces(monkeypatch, source,
agent = server._make_agent("sid", "session-key", platform_override=source)
assert agent.daybreak is expected
def test_an_isolated_turns_reused_child_agent_follows_the_switch(homes, monkeypatch):
"""With turn isolation the live agent is in the compute-host child, out of reach of the parent's live
flip, so each turn the child re-reads the switch from its profile's config.yaml, in both directions."""
from tui_gateway.compute_host import ComputeHost
_launch, worker = homes
child = object.__new__(ComputeHost)
child._transport = object()
desktop = SimpleNamespace(daybreak=True, platform="desktop")
bot = SimpleNamespace(daybreak=False, platform="telegram")
monkeypatch.setitem(server._sessions, "desktop-sid", {"agent": desktop, "profile_home": str(worker)})
monkeypatch.setitem(server._sessions, "bot-sid", {"agent": bot, "profile_home": str(worker)})
config = worker / "config.yaml"
config.write_text("agent:\n daybreak: false\n", encoding="utf-8")
child._ensure_server_session(server, {"sid": "desktop-sid", "profile_home": str(worker)})
assert desktop.daybreak is False
config.write_text("agent:\n daybreak: true\n", encoding="utf-8")
for sid in ("desktop-sid", "bot-sid"):
child._ensure_server_session(server, {"sid": sid, "profile_home": str(worker)})
assert desktop.daybreak is True
assert bot.daybreak is False
+19
View File
@@ -331,6 +331,7 @@ class ComputeHost:
session[key] = str(frame[key])
if "explicit_cwd" in frame:
session["explicit_cwd"] = bool(frame["explicit_cwd"])
self._refresh_daybreak(server, session)
else:
session = self._build_server_session(server, frame, sid)
if isinstance(frame.get("attached_images"), list):
@@ -342,6 +343,24 @@ class ComputeHost:
session["pending_model_switch"] = dict(frame["pending_model_switch"])
return session
@staticmethod
def _refresh_daybreak(server: Any, session: dict) -> None:
"""``agent.daybreak`` is read when the agent is built, and the parent's live flip only reaches agents in
its own process, so a reused child agent re-reads it each turn from its profile's config.yaml."""
agent = session.get("agent")
if agent is None:
return
from agent.session_source import DAYBREAK_SOURCES
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
profile_home = str(session.get("profile_home") or "")
token = set_hermes_home_override(profile_home) if profile_home else None
try:
on = (server._load_cfg().get("agent") or {}).get("daybreak") is True
finally:
if token is not None:
reset_hermes_home_override(token)
agent.daybreak = on and getattr(agent, "platform", None) in DAYBREAK_SOURCES
def _build_server_session(self, server: Any, frame: dict[str, Any], sid: str) -> dict:
"""Build the agent under the frame's profile scope and register the session."""
key = str(frame.get("session_key") or sid)
+1 -1
View File
@@ -1961,7 +1961,7 @@ agent:
In Desktop, open a model's options in the model menu: the **Daybreak** switch appears next to **Fast**, only on models your signed-in account's Codex catalog lists for Daybreak. Accounts without Daybreak never see it. The switch is profile-wide, like Codex's own toggle, and applies from the next turn.
Every request checks the catalog again, so `access_programs.cyber: daybreak_blue` is sent only when the switch is on **and** that request's account and model are listed. Switching to an unlisted model, rotating to an account without access, or an unreadable catalog sends standard requests. It is not sent on API-key routes or the opt-in `codex_app_server` runtime, and only your own chat turns use it (Desktop, the TUI and the CLI). Messaging-platform chats, cron jobs and subagents use standard safeguards: OpenAI allows Daybreak no third-party or downstream access, and a bot's chats can be other people. The setting grants no access: approval comes from OpenAI.
Every request checks the catalog again, so `access_programs.cyber: daybreak_blue` is sent only when the switch is on **and** that request's account and model are listed. Switching to an unlisted model, rotating to an account without access, or an unreadable catalog sends standard requests. It is not sent on API-key routes or the opt-in `codex_app_server` runtime, and only your own chat turns use it (Desktop, the TUI and the CLI). Messaging-platform chats, hosted rooms, A2A peers, third-party `--source tool` integrations, editor (ACP) clients, cron jobs and subagents use standard safeguards: OpenAI allows Daybreak no third-party or downstream access, and those chats can be other people. The setting grants no access: approval comes from OpenAI.
:::note Adaptive-thinking models (Claude 4.6+, Fable/Mythos-class) over OpenRouter
These models use *adaptive* thinking and don't accept the usual `reasoning.effort`