Repository navigation
Description
Problem
A raw-model start with a model no provider offers launches anyway and fails after launch. The person sees only "the session started but the prompt could not be delivered; it was ended". Seen in real use on 2026-10-11: asked to start a codex session on a project, the Chief of Staff sent model: "codex" and got that message twice. It then guessed at the cause.
Root cause
A start with a raw model string is not checked before launch. "codex" matched no provider prefix, so relay started a plain chat session with that model. The model endpoint answered HTTP 404: model not found, the first prompt was not delivered, and the session was ended. The 404 never reaches the caller.
Fix
Before launch, check a raw model against the models the resolved provider offers. Refuse an unknown model with an error that names it and lists the valid choices. Starts by agent-list entry (relay#371) already check up front; this covers projects with no agent list and raw starts on every door.
Follow-on from the relay#371 contract, open question 8 (approved by the owner on 2026-10-12).
ship-blocker: no (a start with an unknown model fails late with a vague message; nothing lost, no security effect)
Contract
Planner, 2026-10-18. Bug, standalone (not an epic). Branch fix/384-raw-model-offer off main; one PR to main with Closes #384. Evidence: the reproduction on main bf176b8 in the comments. Threat model: no promise moves; this is an early refusal, not access control. The model endpoint's grant stays the enforcement. ship-blocker: no (unchanged).
Smallest fix: the check that agent-entry starts already run (checkEntryModel) runs for raw model starts too, in the one core every door shares (AuthorizeLaunch). No new abstraction.
Acceptance criteria
- Relay refuses a fresh start whose raw model its kind does not offer, before any launch.
- A refused start leaves no session and starts no agent process.
- The refusal is
400 model_not_offered. - The message names the requested model and the kind relay derived from it.
- The message lists the offered models that the project allows, at most 50.
- Each listed model has the spelling that
relay session start --modelaccepts. - When the model is
codex,piorclaude, the message also says how to spell a model of that kind. - When relay cannot read the kind's model list, it refuses the start with
503 models_unavailable. - A start whose model the kind offers starts as before.
- A Claude alias or a full Claude tier id starts as before, with no model list check.
- The check applies to
POST /api/sessions,relay session start(flags and--file) and the bridgesession.startop. - The check applies to
POST /api/chief-of-staff/sessions, for amodelstart and for a chat-template start. - A Chief of Staff refusal carries the code in
errorand the listed models inchoices. - A refused start ends the
session.launcheventdenied, with the refusal code asreasonand the requested model inmodel. - A refused Chief of Staff start ends the
chief_of_staff.startevent the same way. - Resume, agent-entry starts, terminal starts and drop-in keep their current checks.
- A start that an earlier check refuses keeps that check's code, for example
model_not_allowed,model_system_onlyorread_only_needs_claude.
Paths
- Simple path: a person picks a model in eve's New Session picker, which lists only offered models, so this refusal does not show there. A person who asks the Chief of Staff in words ("start a codex session") gets the refusal with
choices, and the Chief of Staff can retry with one of them. - Power path:
relay session start --model M(or--file), withrelay model listfor the valid ids. No new flag and no config: the offer comes from the catalogue relay already reads.
Interfaces
cmd/relay/agent_models.go:
// newModelOffer: KindChat joins KindPi's case. Both offer the model
// endpoint's non-system row ids; an empty list or a read error answers
// errModelsUnavailable.
case KindPi, KindChat:
// rawModelOffered reports whether a raw session model names a model in its
// kind's offer: codex drops "codex/"; pi keeps <id> of "pi/<provider>/<id>"
// (a malformed pi model is never offered); chat matches the id, or <x> of
// "llama/<x>" or "mlx/<x>", the spellings docs/model-endpoint.md accepts.
func rawModelOffered(kind, model string, offered []string) boolcmd/relay/session_launch.go:
// checkRawModel refuses a fresh chat, codex or pi start with no entry whose
// model the kind does not offer. A list that cannot be read refuses the start.
func checkRawModel(req LaunchRequest, proj *config.Project, fields sessionLaunchAuditFields) *LaunchRefusalAuthorizeLaunchcalls it whenreq.Entry == "",!req.Resume,req.Model != ""andreq.Kindischat,codexorpi.- The call sits after the
persist_session_invalidand read-only checks and beforebuildSessionRequest, so every existing refusal keeps its code (criterion 17). model_system_onlyinlaunchstays before it, unchanged.- Refusals use
deniedRefusal, so the deniedsession_launchaudit row is written as today. The audit row shape does not change.
Refusals (texts are exact; <model> passes through capAuditText(…, maxRefusalNameRunes)):
| Status | Code | Message |
|---|---|---|
| 503 | models_unavailable |
the model list for <kind> could not be read (the text entry starts use) |
| 400 | model_not_offered |
model "<model>" is not offered for <kind>; models: <c1>, <c2> |
| 400 | model_not_offered, no choice left |
model "<model>" is not offered for <kind>; <project> allows none of the offered models |
- Choices: the kind's offer, each spelled
project.SessionModel(config.AgentEntry{Kind: kind, Model: o})(codex/<slug>,pi/relay-router/<id>, a chat id unchanged). They are kept whenprojectAllowsModelpasses and capped atmaxRefusalChoices. They go inLaunchRefusal.Choices. - Kind-name hint: when
<model>is exactlycodex,piorclaude, the message ends with; for a codex session use codex/<model>,; for a pi session use pi/relay-router/<model>or; for a claude session use haiku, sonnet or opus.
Wire (no shape changes):
POST /api/sessions:{"error": "<message>"}with the status above.relay session startand bridgesession.start: exit 1, and stderr carries the message.POST /api/chief-of-staff/sessions:{"error": "<code>", "message": "<message>", "choices": [...]}.choicesis omitted when empty.- Events:
session.launch(inlaunchWithEvent) andchief_of_staff.startenddeniedwithreasonset to the code. Both also setmodelto the capped requested model, only onmodel_not_offeredandmodels_unavailable.kindandproject_idare set as today.
File ownership
| Task | Owns |
|---|---|
| T1 dev | cmd/relay/agent_models.go, cmd/relay/session_launch.go, cmd/relay/session_routes.go (the event model field only), cmd/relay/session_chief_of_staff_start.go (the event model field only), docs/routes.md, docs/cli.md, docs/events.md, docs/FEATURES.md |
| T2 test writer | e2e/features/bug384_raw_model_offer_test.go (new), e2e/coverage/proof/breaks.json (one added entry) |
| T3 verification | no files |
- No file is shared. T2 reuses the
featurespackage's helpers (agStart,agProject,agSimple,agStartArgvs,cosDo,cosRefusal,g1sListSessions,requireEvent) and does not edit them ore2e/harness/*. A helper it lacks goes in its own file. - Docs:
routes.mdgets a raw-model step beside "Start by agent entry" and listsmodel_not_offeredandmodels_unavailablefor raw starts.cli.mdsession startgets one sentence on the refusal.events.mdaddsmodelto thesession.launchandchief_of_staff.startfield cells. FEATURES.md: rowG1.01adds the refusal, the proof itemsevent:session.launch=denied/model_not_offeredandevent:session.launch=denied/models_unavailable, and the T2 tests. RowG1.26adds the refusal,event:chief_of_staff.start=denied/model_not_offeredande2e:TestBug384CosRawModelNotOffered. The names below are fixed so the rows can cite them.
Acceptance mapping and test surface
All criteria are e2e feature tests (integration) in T2's file. Nothing is Playwright: eve's screens do not change. The test-machine pass is T3's real-app check.
| Test (T2) | Criteria | Interface |
|---|---|---|
TestBug384RawModelNotOfferedRefused: fake model host offering fake-echo and fake-other, project allowed_models: ["fake-echo","codex","nosuchmodel-xyz"] (so model_not_allowed does not fire first). CLI --model codex and HTTP nosuchmodel-xyz are refused; the message lists fake-echo and not fake-other; the codex hint is there; no session and no agent start |
1-7, 11, 14 | checkRawModel, rawModelOffered, launchWithEvent |
TestBug384RawCodexAndPiModelNotOffered: codex/gpt-x lists codex/fake-echo; pi/relay-router/nosuch lists pi/relay-router/fake-echo |
1, 3, 5, 6 | rawModelOffered codex and pi |
TestBug384RawModelListUnavailable: no fake model host, a chat start is 503 and nothing starts |
2, 8 | newModelOffer chat case |
TestBug384OfferedRawModelsStillStart: fake-echo, codex/fake-echo, pi/relay-router/fake-echo and claude-haiku-5-5 each answer 201 |
9, 10 | offered path |
TestBug384CosRawModelNotOffered: a scoped model: "codex" start, and a chat template with an unknown model, are refused with error, choices and the chief_of_staff.start reason |
12, 13, 15 | checkRawModel via d.launch |
- Criteria 16 and 17 are held by the existing suite:
TestAgentStartModelNotOffered,TestCosStartEntryRefusals,TestReadOnlyProjectsSession,TestCodexModelOutsideAllowedRefused,TestModelOutsideAllowedRefused,TestSystemModelChatRefusedand the resume tests. - Red on main: on
maineach refusal test gets 201 or exit 0 where it asserts the refusal. The door is the CLI and HTTP route that exist onmain. - Break-and-restore: make
checkRawModelreturn nil;TestBug384RawModelNotOfferedRefusedgoes red. T2 records that break inbreaks.json.
Existing tests and journeys checked. I expect none to break, so no fixture changes:
- e2e raw non-Claude starts:
TestChatSessionAnswers,TestSystemModelChatRefused,TestModelKeyRequiredandTestCosStartsChatTemplateuse a fake host that offersfake-echo.TestFakePiAnswerspicks its id from the list.TestFakeCodexAnswersand the codex starts ing1_terminals_test.gousecodex/fake-echo, which the fake codex offers. TestReadOnlyProjectsSessionstartspi/fake/fake-echowith no model host. It stays green only because the check runs after the read-only check; T1 must keep that order.TestModelOutsideAllowedRefusedandTestCodexModelOutsideAllowedRefusedare refused bymodel_not_allowedfirst.cmd/devboxverifyapi-phase journeys start Claude models (haiku) or a blank model.- The screen-phase chat and codex journeys (nightly only) pick their model from
GET /api/models, and the codex journey already FAILs whencodex/gpt-6-lunais not listed. eve's journeys also pick from the offered list.
Waits
- T1: none; the build only.
- T2:
- Relay boot:
harness.Start's ready signal. - Session host: the
service.manifest.registerevent (WaitSessionHost). - Model host: the
model.host.registerevent (WaitModelHost). - The refusal is decided before any launch, so the HTTP response or the CLI process exit is the completion handle. "No session" and "no agent start" assertions read after it.
- The event line: read by trace after the response (
requireEvent). - Positive starts: the 201 response; no turn.
- The audit row is written by an async queue with no flush signal, so T2 does not assert it. Row
G1.18already covers the denied row for every launch refusal.
- Relay boot:
- T3:
stack-upprintsSTACK up r384 <stack.json>.- Each harness and suite run ends with its process exit.
- The real-app CLI ends with its process exit.
- After
./build.sh, wait forservice listto showrelaysessions running.
Verification task (T3, last task, on the PR head)
- repo-suite:
(cd e2e && go test -race ./features/)on the head. - devboxverify:
- Unlock the devid keychain, then run
npm ciin an evemaincheckout. ./stack-up.sh r384 --relay <head checkout> --eve <eve main checkout>.- From the head checkout:
go run ./cmd/devboxverify --stack <stack.json> --phase api. ./stack-down.sh r384.
- Unlock the devid keychain, then run
- real-app:
- Hold the devlock (RESTART/SCREEN/WORLD) and install the head with
./build.sh. - On a local project with no agent list, run
relay session start --project <id> --model codex. It must exit 1, namecodexand list the offered models. Before: exit 0started session …(repro on bf176b8). - Same with
--model nosuchmodel-xyz. --model haikustill starts; stop that session.- Reinstall from
mainunder the same lock.
- Hold the devlock (RESTART/SCREEN/WORLD) and install the head with
- Post
Verified: <head sha> · repo-suite: <result> · devboxverify: <result> · real-app: <result> · log: <path>.
The stack run takes no devlock. Only the real-app install does.
Budget
- T1: 4 code files and 4 docs. About +80/-5 lines of Go and +25 lines of docs.
- T2: 1 new file of about 250 lines, plus a few lines in
breaks.json. - Going past either is a question for the coordinator.
Open questions (recommendation first; the coordinator takes it)
- INSIDE. Claude raw models: skip the offer check. The Claude offer lists only aliases, and a full tier id is a Claude start by design (relay#494), so a check would refuse valid starts.
- INSIDE. List unavailable: fail closed with
503 models_unavailable, as entry starts do. This keeps the repo's "every default fails closed" rule. Today such a start launches and then fails on its first message, so no journey loses a working path.- User-noticeable: with the model broker down, a chat start now answers 503 at once.
- Log it in the run ledger.
- INSIDE. Host projects: a raw codex start is checked against this Mac's codex list, as relay#371 question 5 decided. eve's picker already shows that list for a host project.
- INSIDE. Hint text for
codex,piandclaude(criterion 7): add it. It speaks to the incident, where the caller sent a kind name as a model and then guessed. - INSIDE. Choices use the
--modelspelling (codex/…,pi/relay-router/…), not the bare ids that entry refusals list, so a caller can send one back unchanged. - BEYOND. fakerelay still accepts any raw model. Recommend no change here. If an eve test needs this refusal, file a follow-on with
needs-reviewandfrom-dev.
Activity
barelyworkingcode commentedon Oct 15, 2026
Groomed (coordinator, 2026-10-14): added
ship-blocker: noagainst the ship-blocker criteria in the flow rules (a security hole inside the threat model that real use can reach; loss of a user's work, data or login; a defect in a must-have feature group). A poor error message. The owner can flip it. No other change.barelyworkingcode commentedon Oct 19, 2026
Reproduced on main bf176b8 (coordinator, 2026-10-18), installed app.
relay session start --model codexand--model nosuchmodel-xyzon a local project with no agent list each printstarted session ...and exit 0. The first message then fails withsession host answered 500.relay model listoffers neither id. Both sessions were stopped. Fix proceeds.barelyworkingcode commentedon Oct 19, 2026
Contract written to the issue body under
## Contract(planner, 2026-10-18). It lives in the body becausemerge-checkreads the ownership list from there. Branchfix/384-raw-model-offer; one PR tomain.barelyworkingcode commentedon Oct 19, 2026
Contract amendment (coordinator, 2026-10-18), review round 1: the kind-name hint names a literal placeholder, not the requested string. The hints read
; for a codex session use codex/<model>and; for a pi session use pi/relay-router/<model>, with the claude hint unchanged. The old text sentcodex/codex, which no provider offers.docs/events.mdalso states that a raw-modelmodels_unavailableendsdenied, and thatmodelis set only on a start with no entry.