Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions cmd/relay/model_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,17 @@ func (m *ModelEndpointServer) IsSystemModel(ctx context.Context, id string) (boo
return false, nil
}

// ModelMapKeys returns the modelMap keys that id stands for, from a catalog
// read through the same cache the proxy classifies with. An unreadable catalog
// is an error, never an empty answer.
func (m *ModelEndpointServer) ModelMapKeys(ctx context.Context, id string) ([]string, error) {
rows, err := m.catalog.Resolve(ctx, id)
if err != nil {
return nil, err
}
return modelbroker.ModelMapKeysFor(rows, id), nil
}

// routerStatus adds status.value to each entry, for GET /models only: that
// path is the llama.cpp router dialect, and a client such as pi's llama.cpp
// provider rejects the whole catalog when one entry lacks it. Every row is
Expand Down
21 changes: 11 additions & 10 deletions cmd/relay/server_core.go
Original file line number Diff line number Diff line change
Expand Up @@ -643,16 +643,17 @@ func startServerCore(opts serverOptions) (*App, error) {
// resume, an advisory exit report and a project delete can never observe
// a different ledger, launch table or accounting map than each other.
sessionDeps := sessionRouteDeps{
store: store,
launches: launches,
sessions: sessLedger,
modelKeys: modelKeys,
enhanced: enhancedRegistry,
auditor: rec,
accounting: sessionAccounts,
resumeGuard: newResumeGuard(),
systemModel: app.modelEndpoint.IsSystemModel,
modelOffer: newModelOffer(app.ipcCtx.ModelCatalog),
store: store,
launches: launches,
sessions: sessLedger,
modelKeys: modelKeys,
enhanced: enhancedRegistry,
auditor: rec,
accounting: sessionAccounts,
resumeGuard: newResumeGuard(),
systemModel: app.modelEndpoint.IsSystemModel,
modelMapKeys: app.modelEndpoint.ModelMapKeys,
modelOffer: newModelOffer(app.ipcCtx.ModelCatalog),
}

router.sessionDeps = sessionDeps
Expand Down
33 changes: 33 additions & 0 deletions cmd/relay/session_adopt.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,36 @@ func validAdoptModel(model string) bool {
return !strings.ContainsFunc(model, unicode.IsControl)
}

// brokerAlias resolves a transcript model id served by relay's model broker to
// the Claude alias whose ANTHROPIC_DEFAULT_<ALIAS>_MODEL names one of its
// modelMap keys. Deliberate: exactly one distinct alias answers; none, several
// or an unreadable catalog answer "" so the caller refuses before any signal.
func (a *adoptOps) brokerAlias(ctx context.Context, outsideID, modelID string) string {
if modelID == "" || a.deps.modelMapKeys == nil {
return ""
}
keys, err := a.deps.modelMapKeys(ctx, modelID)
if err != nil {
slog.WarnContext(ctx, "session adopt: model catalog unreadable", "outside_id", outsideID, "error", err)
return ""
}
found := map[string]bool{}
for _, key := range keys {
for _, alias := range []string{"haiku", "sonnet", "opus"} {
if os.Getenv("ANTHROPIC_DEFAULT_"+strings.ToUpper(alias)+"_MODEL") == key {
found[alias] = true
}
}
}
if len(found) != 1 {
return ""
}
for alias := range found {
return alias
}
return ""
}

// adoptState is what the event line reports; fields fill in as the core learns
// them.
type adoptState struct {
Expand Down Expand Up @@ -290,6 +320,9 @@ func (a *adoptOps) adoptOutside(ctx context.Context, req AdoptRequest) (res *Ado
if model == "" {
model = ready.Tail.ModelFamily
}
if model == "" {
model = a.brokerAlias(ctx, row.ID, ready.Tail.Model)
}
if model == "" {
return nil, refuse(http.StatusConflict, adoptModelUnknown, "the model of the session is unknown; send model")
}
Expand Down
4 changes: 4 additions & 0 deletions cmd/relay/session_routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,10 @@ type sessionRouteDeps struct {
// means no check.
systemModel func(ctx context.Context, id string) (bool, error)

// modelMapKeys lists the modelMap keys a transcript model id stands for;
// nil means a broker-served model never resolves.
modelMapKeys func(ctx context.Context, id string) ([]string, error)

// modelOffer lists the models a kind offers, for an agent entry start. nil
// refuses every entry start that names a model.
modelOffer ModelOffer
Expand Down
2 changes: 1 addition & 1 deletion docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ Areas: sessions, sandbox, templates, audit, presence.
| `G1.47` | Pass chosen Claude Code settings to Claude sessions | While `claude_env_passthrough` lists `CLAUDE_CODE_*` names, a local `claude` session starts with each listed name that relay's environment sets, at relay's value. An unlisted name stays dropped, and the list is empty by default. A listed name outside `CLAUDE_CODE_[A-Z0-9_]+`, one with a credential segment, or one relay reserves refuses every local claude launch with 400 `invalid_claude_env`, naming the entry and never a value. Terminals, pi, codex, chat and SSH host sessions do not change. | none | `settings.json` `claude_env_passthrough` | `http:POST /api/sessions` `cli:relay session start` | none | `event:session.launch=ok#session_id` `code:http:POST /api/sessions#400` | `e2e:TestClaudeEnvPassthroughListedReachesSession` `e2e:TestClaudeEnvPassthroughEmptyByDefault` `e2e:TestClaudeEnvPassthroughRefusedNames` `e2e:TestClaudeEnvPassthroughOtherKindsUntouched` |
| `G1.48` | Runner event lines | The runner appends one line to `events.jsonl` per state change: `turn_started`, `turn_ended`, `asking` and `exited`. Each line carries the byte offset in `out.jsonl` of the line that caused it, and precedes that line. | n/a | n/a | `bg:session runner` | none | `event:session.state=ok#to` | `e2e:TestRunnerWritesEventLines` |
| `G1.49` | Local session survives a relay restart | A local Claude session keeps its agent when relay restarts: the next `relay session message` is answered with no resume and no second agent, and a turn running during the restart finishes. Its permission hook still reaches relay's check, and a caller outside the agent's process tree is refused. Its relay tools fail closed after the restart until it is stopped and resumed. A session whose agent exited while relay was down reads dormant and resumes on its conversation. The runner directory is outside every sandbox grant. | none (automatic in eve) | n/a | `cli:relay session message` `cli:relay session resume` | none | `event:session.state=ok#to` | `e2e:TestLocalRunnerSurvivesRelayRestart` `e2e:TestLocalRunnerExitedReadsDormant` `e2e:TestLocalRunnerHookRootAfterRestart` `e2e:TestLocalRunnerAskingEventLine` `e2e:TestLocalRunnerDirOutsideSandbox` |
| `G1.50` | Adopt an outside Claude session | The owner adopts an interactive Claude started in a terminal on this Mac, in a registered local project: the process ends and the same conversation resumes as a headless relay session in the same folder, under the project's sandbox and policy. A row lists its `claudeSessionId` and whether it is `adoptable`, with the reason when not (`ended`, `host_unsupported`, `no_project`, `not_registered`, `invalid_id`, `start_mismatch`, `not_interactive`, `not_command_line`, `ambiguous`). A busy session is refused (409 `busy`) unless the caller asks to interrupt; an interrupt sends SIGINT, then SIGTERM, then SIGKILL, each after a bounded wait on relay's clock, and the first message to the resumed session names the open tool call and asks the agent to check what it touched. A match whose process start time differs from the registry entry is never adopted. Each adopt, interrupt and recovery message writes an audit row before it acts, and with auditing off nothing is signalled. The Chief of Staff scope cannot reach the door. | Eve > Chief of Staff > Agents rail > Adopt | n/a (the body's `claudeSessionId`, `interrupt` and `model`) | `http:POST /api/sessions/adopt` | none | `event:session.adopt=ok#session_id` `event:session.adopt=denied/busy` `audit:session_adopt=ok#session_id` `code:http:POST /api/sessions/adopt#409` `code:http:POST /api/sessions/adopt#403` | `e2e:TestAdoptRowListsConversationAndState` `e2e:TestAdoptReasonsOnRows` `e2e:TestAdoptIdleResumesSameConversation` `e2e:TestAdoptByClaudeSessionID` `e2e:TestAdoptBusyRefusedWithoutInterrupt` `e2e:TestAdoptInterruptLadder` `e2e:TestAdoptRecoveryMessageNamesOpenTool` `e2e:TestAdoptStartMismatchNeverAdopted` `e2e:TestAdoptAuditRowsBeforeAct` `e2e:TestAdoptRefusedWithAuditingOff` `e2e:TestAdoptAmbiguousNeverAdopted` `e2e:TestAdoptScopeRefused` `e2e:TestAdoptChildVetoInLongTranscript` `e2e:TestAdoptIdleIgnoresTurnHelper` `e2e:TestAdoptIdleIgnoresLateLongLivedChild` `e2e:TestAdoptToolChildOfLastTurnVetoes` `journey:session-adopt` |
| `G1.50` | Adopt an outside Claude session | The owner adopts an interactive Claude started in a terminal on this Mac, in a registered local project: the process ends and the same conversation resumes as a headless relay session in the same folder, under the project's sandbox and policy. A row lists its `claudeSessionId` and whether it is `adoptable`, with the reason when not (`ended`, `host_unsupported`, `no_project`, `not_registered`, `invalid_id`, `start_mismatch`, `not_interactive`, `not_command_line`, `ambiguous`). A busy session is refused (409 `busy`) unless the caller asks to interrupt; an interrupt sends SIGINT, then SIGTERM, then SIGKILL, each after a bounded wait on relay's clock, and the first message to the resumed session names the open tool call and asks the agent to check what it touched. A transcript model that the broker serves adopts with the Claude alias that relay's environment maps to its modelMap key. A match whose process start time differs from the registry entry is never adopted. Each adopt, interrupt and recovery message writes an audit row before it acts, and with auditing off nothing is signalled. The Chief of Staff scope cannot reach the door. | Eve > Chief of Staff > Agents rail > Adopt | n/a (the body's `claudeSessionId`, `interrupt` and `model`) | `http:POST /api/sessions/adopt` | none | `event:session.adopt=ok#session_id` `event:session.adopt=denied/busy` `audit:session_adopt=ok#session_id` `code:http:POST /api/sessions/adopt#409` `code:http:POST /api/sessions/adopt#403` | `e2e:TestAdoptRowListsConversationAndState` `e2e:TestAdoptReasonsOnRows` `e2e:TestAdoptIdleResumesSameConversation` `e2e:TestAdoptByClaudeSessionID` `e2e:TestAdoptBusyRefusedWithoutInterrupt` `e2e:TestAdoptInterruptLadder` `e2e:TestAdoptRecoveryMessageNamesOpenTool` `e2e:TestAdoptStartMismatchNeverAdopted` `e2e:TestAdoptAuditRowsBeforeAct` `e2e:TestAdoptRefusedWithAuditingOff` `e2e:TestAdoptAmbiguousNeverAdopted` `e2e:TestAdoptScopeRefused` `e2e:TestAdoptChildVetoInLongTranscript` `e2e:TestAdoptIdleIgnoresTurnHelper` `e2e:TestAdoptBrokerModelAdoptsWithAlias` `e2e:TestAdoptBrokerModelUnknownRefused` `e2e:TestAdoptIdleIgnoresLateLongLivedChild` `e2e:TestAdoptToolChildOfLastTurnVetoes` `journey:session-adopt` |

### G2 · Give an agent access to one project and nothing else — must-have
Intent: grant a project its folder, mail account and chosen tools, and nothing wider.
Expand Down
4 changes: 4 additions & 0 deletions docs/model-endpoint.md
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,10 @@ The point: Claude Code, Codex and pi run with one base URL, a model relay
manages is served by the broker (scoped to the project, audited), and a
provider's own model reaches that provider with the client's own credential.

The adopt door (`POST /api/sessions/adopt`) also reads the modelMap, through
the same cache, to turn a broker-served transcript model into a Claude alias;
it never shows the target to a caller.

**Two credentials, two headers.** The client's own token stays in
`Authorization` (or `x-api-key`), untouched. Relay's per-session key travels in
`X-Relay-Key`. A subscription login sends one credential, its OAuth token, on
Expand Down
11 changes: 9 additions & 2 deletions docs/outside-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,15 @@ Sessions on SSH hosts are not adopted.
- **The resumed session** runs `settings {"headless":true,"agent":true}` under
the project's sandbox, allowed models and permission policy, with
`--resume <claudeSessionId>`. The model is the body's `model`, else the
family alias (`haiku`, `sonnet`, `opus`) of the newest assistant record; none
answers `model_unknown`.
family alias (`haiku`, `sonnet`, `opus`) of the newest assistant record, else
the alias for a broker-served model. The newest assistant record's model id
stands for its modelMap key (the id itself when it is a key, else every key
whose target it is), read from a fresh catalog through the model endpoint's
cache. A key maps to an alias when relay's environment sets
`ANTHROPIC_DEFAULT_<ALIAS>_MODEL` to it. Exactly one distinct alias is the
model, and the session starts with the alias, never the key or the target.
Otherwise, or with an unreadable catalog, the door answers `model_unknown`
before anything is signalled.
- **Recovery message.** After a takeover that sent SIGINT, relay sends one
message with origin `relay-recovery` (no event line of its own; the
`session.adopt` event covers it), quoting at most 300 characters of the
Expand Down
93 changes: 93 additions & 0 deletions e2e/features/bug562_adopt_broker_model_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package features

import (
"encoding/json"
"net/http"
"testing"

"relaye2e/harness"
)

// These tests adopt an idle outside Claude whose transcript names a model the
// broker serves, sending no model, the way eve's Adopt button does. The model
// host row set holds one real model and one modelMap key that routes to it.

const (
bkTarget = "acme-lab/fake-echo"
bkKey = "Acme-Local-27B"
)

func bkHost() *harness.FakeModelHostSpec {
return &harness.FakeModelHostSpec{
ID: "acme-models",
Models: []json.RawMessage{
json.RawMessage(`{"id":"` + bkTarget + `","owned_by":"acme-lab"}`),
json.RawMessage(`{"id":"` + bkKey + `","owned_by":"anthropic-map","target":"` + bkTarget + `"}`),
},
}
}

// bkStart boots an adopt rig with the model host and env, and returns after
// the host has registered, so the catalog is readable by the adopt.
func bkStart(t *testing.T, env ...string) *adRig {
t.Helper()
a := adStart(t, func(o *harness.Options) {
o.FakeModelHost = bkHost()
o.Env = append(o.Env, env...)
})
a.i.WaitModelHost(osBound)
return a
}

func TestAdoptBrokerModelAdoptsWithAlias(t *testing.T) {
t.Parallel()
for _, c := range []struct{ name, model string }{
{"key", bkKey},
{"target", bkTarget},
} {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
a := bkStart(t, "ANTHROPIC_DEFAULT_HAIKU_MODEL="+bkKey)
f := a.fake("app")
f.idle(a.i.Home, c.model)
s, _ := a.probeFrame()
live := s.liveAt(f.dir)
if live == nil {
t.Fatalf("no live row for %s; snapshot %+v", f.dir, s)
}
resp := a.adopt(map[string]any{"outsideId": live.ID, "interrupt": false})
res := adCreated(t, resp)
f.signalAt(f.expect("signal TERM"), "TERM")
f.gone()
argv := adStartLine(t, a.i, res.SessionID, "acme next")
if !adHasFlag(argv, "--resume", f.sid) || !adHasFlag(argv, "--model", "haiku") {
t.Fatalf("start line argv = %v, want --resume %s and --model haiku", argv, f.sid)
}
a.i.WaitEvent(harness.EventQuery{Key: "session.adopt", Trace: resp.Trace, Fields: map[string]any{
"status": "ok", "outside_id": live.ID, "session_id": res.SessionID,
}}, osBound)
})
}
}

func TestAdoptBrokerModelUnknownRefused(t *testing.T) {
t.Parallel()
for _, c := range []struct {
name string
model string
env []string
}{
{"not served", "Acme-Unserved-9B", []string{"ANTHROPIC_DEFAULT_HAIKU_MODEL=Acme-Unserved-9B"}},
{"no alias", bkKey, nil},
{"two aliases", bkKey, []string{"ANTHROPIC_DEFAULT_HAIKU_MODEL=" + bkKey, "ANTHROPIC_DEFAULT_SONNET_MODEL=" + bkKey}},
} {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
a := bkStart(t, c.env...)
f := a.fake("app", "INT", "TERM")
f.idle(a.i.Home, c.model)
otRefusal(t, a.adopt(map[string]any{"claudeSessionId": f.sid, "interrupt": true}), http.StatusConflict, "model_unknown")
adNoSignals(t, f)
})
}
}
20 changes: 20 additions & 0 deletions internal/modelbroker/normalise.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,26 @@ func findRow(rows []Row, id string) (Row, bool) {
return Row{}, false
}

// ModelMapKeysFor returns the modelMap keys a transcript's model id stands
// for, in catalog order: [id] when id is itself a modelMap row (a client that
// echoes the key it asked for), else every modelMap row whose target is id (a
// client that echoes the routed target). Nil when nothing matches.
func ModelMapKeysFor(rows []Row, id string) []string {
if id == "" {
return nil
}
if row, ok := findRow(rows, id); ok && classify(row) == groupModelMap {
return []string{id}
}
var keys []string
for _, row := range rows {
if classify(row) == groupModelMap && row.Target == id {
keys = append(keys, row.ID)
}
}
return keys
}

// canonicalize maps a requested model id to the router id relayLLM actually
// dispatches on, accepting the spellings spec §6.2 documents in addition to
// the bare router id itself:
Expand Down
1 change: 1 addition & 0 deletions internal/sessions/adopt/adopt.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ type Tail struct {
LastPrompt string // the newest user record's plain text
LastRecord time.Time // the newest user or assistant record's timestamp
ModelFamily string // "haiku", "sonnet", "opus" or ""
Model string // the newest assistant record's model id; never "" or "<synthetic>" once seen
}

// Readiness is the answer of Check. Tail is read whether or not the session is
Expand Down
3 changes: 3 additions & 0 deletions internal/sessions/adopt/recovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ func ReadTail(data []byte) Tail {
}
switch rec.Type {
case "assistant":
if m := rec.Message.Model; m != "" && m != "<synthetic>" {
t.Model = m
}
if family := modelFamily(rec.Message.Model); family != "" {
t.ModelFamily = family
}
Expand Down