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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ flight; bare `tap use` prints the resolved keg/flight/fallback and the scope
that set each. A persisted `flight` auto-applies when `--flight` is omitted.

Supported env vars: `TAP_DEFAULT_KEG`, `TAP_FALLBACK_KEG`, `TAP_FLIGHT`,
`TAP_LOG_FILE`, `TAP_LOG_LEVEL`, `TAP_DEFAULT_HUB`, `TAP_FALLBACK_HUB`,
`TAP_AGENT`, `TAP_LOG_FILE`, `TAP_LOG_LEVEL`, `TAP_DEFAULT_HUB`, `TAP_FALLBACK_HUB`,
`TAP_DEFAULT_NAMESPACE`, `TAP_FALLBACK_NAMESPACE`, `TAP_DISABLE_ATLAS_HUB`,
`TAP_DISABLE_LOCAL_HUB`, `TAP_DISABLE_TELEMETRY` (`1`/`true`/`yes`/`on` for
the disable flags).
Expand Down
20 changes: 16 additions & 4 deletions docs/configuration/flights.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,8 @@ capability.
stale authority.
- Hosted `/mcp` selects the account-wide MCP flight preference. Local
initialization and `orient` select explicit `--flight`, then `TAP_FLIGHT`,
the nearest project config, and finally the user baseline.
then the active agent's `flight`, then the nearest project config, and finally
the user baseline.
- Hosted self-deletion clears the account preference through the flight foreign
key. A local config that still names a deleted flight remains a stale external
reference: later `orient` reports it and the session stays in recovery until
Expand All @@ -185,9 +186,20 @@ capability.
`.tapper/config.yaml`; `tap use +slug` uses the resolved default namespace.
Config-driven sessions adopt it on their next orientation.
- Flight selection precedence is explicit runtime `--flight`, then
`TAP_FLIGHT`, then the nearest project config, then the user baseline written
by `tap bootstrap`. Project selection therefore overrides the machine-wide
bootstrap choice without changing it.
`TAP_FLIGHT`, then the active agent's `flight`, then the nearest project
config, then the user baseline written by `tap bootstrap`. Project selection
therefore overrides the machine-wide bootstrap choice without changing it.
- `tap launch --agent NAME` exports `TAP_AGENT=NAME`, not the flight that agent
currently names. The launched session resolves `agents[NAME].flight` on every
orientation, so editing that agent's flight and calling `orient` again moves
the running session. A resolved flight in the environment could not be
changed after launch, since a process cannot alter its own environment.
`TAP_FLIGHT` and `--flight` are direct and still outrank the agent, so either
one pins a launched session to a flight of its own.
- A `TAP_AGENT` naming an agent that is not configured is reported as a warning
in the orientation payload and the flight falls back to project and user
configuration. It is not fatal: a stale agent name is not something a session
can fix from the inside.
- MCP tools have no model-visible `flight` input. Humans change config-driven
selection with `tap use --flight @namespace/+slug` (or `tap use +slug`), then
the existing session calls `orient`. There is no hidden flight-switch tool.
Expand Down
4 changes: 2 additions & 2 deletions docs/configuration/resolution-order.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ If you pass explicit flags, they take precedence:
instructions plus cover caps enforced by the MCP surface. Direct CLI commands
still use normal keg authorization and do not have access reduced by the flight.
Its precedence follows the config cascade: explicit `--flight`, `TAP_FLIGHT`,
the nearest project `flight`, then the user baseline optionally written by
`tap bootstrap`. See [Flights](flights.md).
the active agent's `flight`, the nearest project `flight`, then the user
baseline optionally written by `tap bootstrap`. See [Flights](flights.md).

The local creation flags on `tap keg create` (`--project`, `--cwd`, and
`--path`) only choose where a new filesystem keg is created. They are not
Expand Down
5 changes: 3 additions & 2 deletions docs/configuration/user-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,9 @@ preselected when available, and **Skip for now** leaves the current value
unchanged. For scripts, pass the inherited global flag explicitly, for example
`tap bootstrap --kind local --flight @local/+focused`; bootstrap validates the
flight and stores its canonical `@namespace/+slug` reference. If no baseline is
set, MCP starts in recovery-only mode. A project `flight`, `TAP_FLIGHT`, or an
explicit `--flight` on a later command overrides the bootstrap baseline.
set, MCP starts in recovery-only mode. A project `flight`, the active agent's
`flight`, `TAP_FLIGHT`, or an explicit `--flight` on a later command overrides
the bootstrap baseline.

## Hub Resolution Chain

Expand Down
1 change: 1 addition & 0 deletions integrations/rendered/codex/tapper/.mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"args": ["mcp"],
"env_vars": [
"HOME",
"TAP_AGENT",
"TAP_FLIGHT",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
Expand Down
11 changes: 8 additions & 3 deletions pkg/cli/cmd_launch.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ An agent is an alias for a (model, flight) pair:
flight: "@me/+scratch"

Models are provider-qualified so the launcher knows which protocol the harness
must speak. The agent's flight is exported as TAP_FLIGHT, so a tap mcp session
started inside the harness orients to it.
must speak. The agent name is exported as TAP_AGENT, so a tap mcp session
started inside the harness resolves the agent's flight for itself. Editing the
agent's flight and calling orient again therefore moves a running session,
which exporting the resolved flight would not.

Arguments after -- are passed through to the harness.

Expand Down Expand Up @@ -69,7 +71,10 @@ Experimental and unstable: expect this to change or disappear.`,
return err
}
if result.Flight != "" {
if _, err := fmt.Fprintf(out, "flight: %s\n", result.Flight); err != nil {
// "resolves to" rather than "is": the child re-resolves this
// from TAP_AGENT on every orient, so it can change under a
// running session.
if _, err := fmt.Fprintf(out, "flight: %s (resolves to, via agent)\n", result.Flight); err != nil {
return err
}
}
Expand Down
6 changes: 4 additions & 2 deletions pkg/cli/cmd_launch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ func TestLaunchCommand_DryRunResolvesOllamaThroughOpenAI(t *testing.T) {
require.Contains(t, out, "codex --oss --local-provider ollama --model qwen3.6:35b-mlx")
require.Contains(t, out, "CODEX_OSS_BASE_URL=http://localhost:11434/v1")

require.Contains(t, out, "TAP_FLIGHT=@testuser/+scratch")
require.Contains(t, out, "TAP_AGENT=local")
require.NotContains(t, out, "TAP_FLIGHT=")
}

func TestLaunchCommand_DryRunResolvesAnthropicThroughEnv(t *testing.T) {
Expand All @@ -60,7 +61,8 @@ func TestLaunchCommand_DryRunResolvesAnthropicThroughEnv(t *testing.T) {

out := string(res.Stdout)
require.Contains(t, out, "ANTHROPIC_MODEL=claude-opus-4")
require.Contains(t, out, "TAP_FLIGHT=+dev")
require.Contains(t, out, "TAP_AGENT=opus")
require.NotContains(t, out, "TAP_FLIGHT=")
}

func TestLaunchCommand_DryRunPassesThroughExtraArgs(t *testing.T) {
Expand Down
9 changes: 6 additions & 3 deletions pkg/integrations/adapters/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,11 @@ func renderCodexMarketplace() ([]byte, error) {
// elsewhere tap mcp fails to authenticate while the same tap in the shell
// succeeds, which is precisely how this surfaced in a dev container.
//
// TAP_FLIGHT carries `tap launch --agent` flight selection. Without it the
// harness has the flight but the MCP server it spawns does not, so the session
// silently resolves the configured flight instead of the requested one.
// TAP_AGENT carries `tap launch --agent` selection. Without it the harness has
// the agent but the MCP server it spawns does not, so the session silently
// resolves the configured flight instead of the agent's. TAP_FLIGHT stays
// listed because a human may still export it directly to override; the launcher
// itself no longer sets it.
func renderCodexMCP() []byte {
return []byte(`{
"mcpServers": {
Expand All @@ -214,6 +216,7 @@ func renderCodexMCP() []byte {
"args": ["mcp"],
"env_vars": [
"HOME",
"TAP_AGENT",
"TAP_FLIGHT",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
Expand Down
8 changes: 5 additions & 3 deletions pkg/integrations/adapters/codex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,11 @@ func TestCodexAdapter_RendersNativeMarketplaceAndTwoPlugins(t *testing.T) {
}
// HOME must be forwarded alongside the XDG roots: tap falls back to it when a
// root is unset and when expanding "~", so without it tap mcp fails to
// authenticate under Codex while the same tap works in the shell. TAP_FLIGHT
// carries `tap launch --agent` flight selection through to the server.
wantEnvVars := "HOME,TAP_FLIGHT,XDG_CONFIG_HOME,XDG_DATA_HOME,XDG_STATE_HOME,XDG_CACHE_HOME"
// authenticate under Codex while the same tap works in the shell. TAP_AGENT
// carries `tap launch --agent` selection through to the server, which
// resolves the agent's flight itself; TAP_FLIGHT remains forwarded for a
// human overriding it directly.
wantEnvVars := "HOME,TAP_AGENT,TAP_FLIGHT,XDG_CONFIG_HOME,XDG_DATA_HOME,XDG_STATE_HOME,XDG_CACHE_HOME"
if got := strings.Join(tapperMCP.EnvVars, ","); got != wantEnvVars {
t.Errorf("tapper MCP env_vars = %q, want %q", got, wantEnvVars)
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/mcp/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ func (p *localOrientationProvider) Load(ctx context.Context) (*Orientation, erro
if listErr == nil && len(flights) == 0 {
return p.Render(ctx, tapper.BootstrapFlight("", localBootstrapInstructions(warnings)))
}
payload, payloadErr := tapper.BuildOrientationPayload(nil, "", nil, warnings)
payload, payloadErr := tapper.BuildOrientationPayload(nil, "", p.tap.ActiveAgentName(), nil, warnings)
return &Orientation{Payload: payload, Warnings: warnings}, payloadErr
}
flight, err := p.tap.FlightService.GetFlightFresh(ctx, ref)
Expand Down
113 changes: 113 additions & 0 deletions pkg/mcp/session_agent_flight_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package mcp_test

import (
"context"
"testing"

"github.com/jlrickert/cli-toolkit/toolkit"
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/require"

"github.com/jlrickert/tapper/pkg/mcp"
"github.com/jlrickert/tapper/pkg/tapper"
)

// TestMCP_AgentFlightMovesWithConfig is the regression this whole mechanism
// exists for. `tap launch` used to export the agent's flight as TAP_FLIGHT, so
// a running session could never leave it: env outranks project and user config,
// and a process cannot change its own environment. Editing the agent's flight
// and re-orienting silently did nothing, while the session reported success.
//
// Exporting TAP_AGENT instead makes the flight a reference resolved on every
// orientation, so the edit lands.
func TestMCP_AgentFlightMovesWithConfig(t *testing.T) {
ctx, srv, rt := newAgentOrientationServer(t, "qwen")
session := connectFlightSession(t, ctx, srv, nil)

require.Contains(t, session.InitializeResult().Instructions, "+alpha")
require.Contains(t, session.InitializeResult().Instructions, "Alpha instructions")

writeAgentFlight(t, rt, "qwen", "beta")

oriented := callOrient(t, ctx, session)
require.Contains(t, oriented, "+beta")
require.Contains(t, oriented, "Beta instructions")
require.NotContains(t, oriented, "Alpha instructions")
}

// The payload names the agent, so a reader who wants a different flight is told
// where the current one came from instead of being pointed at a `flight:` key
// the agent silently outranks.
func TestMCP_AgentIsNamedInTheOrientationPayload(t *testing.T) {
ctx, srv, _ := newAgentOrientationServer(t, "qwen")
session := connectFlightSession(t, ctx, srv, nil)

oriented := callOrient(t, ctx, session)
require.Contains(t, oriented, "agent `qwen`")
require.Contains(t, oriented, "call `orient` again")
}

// A direct TAP_FLIGHT still wins, which is the escape hatch for overriding a
// launched session without touching config.
func TestMCP_TapFlightOverridesTheAgentInSession(t *testing.T) {
ctx, srv, _ := newAgentOrientationServerWithEnv(t, map[string]string{
"TAP_AGENT": "qwen",
"TAP_FLIGHT": "+baseline",
})
session := connectFlightSession(t, ctx, srv, nil)

require.Contains(t, callOrient(t, ctx, session), "+baseline")
}

// A stale agent name is reported in the payload rather than locking the
// session: the agent cannot edit its own environment to fix it.
func TestMCP_UnknownAgentWarnsButKeepsTheSessionUsable(t *testing.T) {
ctx, srv, _ := newAgentOrientationServer(t, "ghost")
session := connectFlightSession(t, ctx, srv, nil)

oriented := callOrient(t, ctx, session)
require.Contains(t, oriented, `agent "ghost"`)
require.Contains(t, oriented, "not configured")
// The user baseline still governs, so KEG tools stay available.
require.Contains(t, oriented, "+baseline")
require.False(t, callCat(t, ctx, session).IsError)
}

func newAgentOrientationServer(t *testing.T, agent string) (context.Context, *sdkmcp.Server, *toolkit.Runtime) {
t.Helper()
return newAgentOrientationServerWithEnv(t, map[string]string{"TAP_AGENT": agent})
}

// newAgentOrientationServer builds a config-driven session (no static flight)
// whose flight comes from an agent, mirroring what `tap launch` produces.
func newAgentOrientationServerWithEnv(t *testing.T, env map[string]string) (context.Context, *sdkmcp.Server, *toolkit.Runtime) {
t.Helper()
ctx := context.Background()
sb := newTestSandbox(t)
require.NoError(t, sb.Setwd("/home/testuser/project"))
rt := sb.Runtime()
for k, v := range env {
require.NoError(t, rt.Env().Set(k, v))
}
writeFlight(t, rt, "baseline", "Baseline instructions")
writeFlight(t, rt, "alpha", "Alpha instructions")
writeFlight(t, rt, "beta", "Beta instructions")
// The user baseline is what an unknown or flightless agent falls back to.
writeAgentFlight(t, rt, "qwen", "alpha")

tap, err := tapper.NewTap(tapper.TapOptions{Runtime: rt})
require.NoError(t, err)
srv := mcp.NewServer(tap, "test", mcp.KegDefaults{})
return ctx, srv, rt
}

// writeAgentFlight rewrites the user config so agent `name` points at +slug,
// keeping the baseline `flight:` underneath it to prove the agent outranks it.
func writeAgentFlight(t *testing.T, rt *toolkit.Runtime, name, slug string) {
t.Helper()
body := "defaultKeg: personal\nfallbackNamespace: local\n" +
"hubs:\n home:\n kind: local\n basePath: ~/kegs\n" +
"flight: +baseline\n" +
"agents:\n " + name + ":\n model: ollama/qwen3.6:35b\n flight: +" + slug + "\n"
require.NoError(t, rt.AtomicWriteFile("/home/testuser/.config/tapper/config.yaml", []byte(body), 0o644))
}
5 changes: 4 additions & 1 deletion pkg/mcp/session_flight.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,10 @@ func (g *sessionFlightGate) adoptDeletedFlight(ctx context.Context, target strin
}
g.calls.Lock()
defer g.calls.Unlock()
payload, payloadErr := tapper.BuildOrientationPayload(nil, "", nil, nil)
// No agent name: this is the self-deletion path, where the flight the
// session was running on has just been removed. The gate has no Tap to ask,
// and "your flight is gone" is the whole message.
payload, payloadErr := tapper.BuildOrientationPayload(nil, "", "", nil, nil)
if payloadErr != nil {
payload = errMCPFlightRequired.Error()
}
Expand Down
4 changes: 2 additions & 2 deletions pkg/mcp/session_transition_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func (p *fakeSessionBackend) Load(ctx context.Context) (*mcp.Orientation, error)
flight := copyTransitionFlight(p.flights[p.active])
p.mu.Unlock()
if flight == nil {
payload, err := tapper.BuildOrientationPayload(nil, "", nil, nil)
payload, err := tapper.BuildOrientationPayload(nil, "", "", nil, nil)
return &mcp.Orientation{Payload: payload}, err
}
return p.Render(ctx, flight)
Expand All @@ -81,7 +81,7 @@ func (p *fakeSessionBackend) Render(_ context.Context, flight *tapper.Flight) (*
kegs := []tapper.OrientationKeg{{
Ref: "@local/personal", Namespace: "local", Alias: "personal", Title: "Personal", Role: "admin", Source: "local", FlightCap: "editor",
}, {Ref: "@local/other", Namespace: "local", Alias: "other", Title: "Other", Role: "admin", Source: "local", FlightCap: "editor"}}
payload, err := tapper.BuildOrientationPayload(flight, "", kegs, nil)
payload, err := tapper.BuildOrientationPayload(flight, "", "", kegs, nil)
if err != nil {
return nil, err
}
Expand Down
1 change: 1 addition & 0 deletions pkg/parity/parity_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ var tapMethodsExcluded = map[string]string{
"Use": "writes the project/user keg + flight to config; CLI-only config management by design",
"UseStatus": "CLI-only summary of the resolved keg/flight context; config inspection via `tap use`",
"ActiveFlightName": "internal pure read of the explicit flight or the loaded cascade's selection; backs Orient and MCP session adoption rather than being an operation of its own",
"ActiveAgentName": "internal pure read of the `tap launch` agent driving the process; reported in orientation and telemetry rather than being an operation of its own",
"OrientationForFlight": "internal session-orientation builder used by initialize, orient, and the orient resource",
// Dropped from MCP when the surface was unified behind providers: these
// operate on machine-local Tapper state or perform tenant administration,
Expand Down
26 changes: 24 additions & 2 deletions pkg/tapper/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,18 @@ type configDTO struct {
// flight is the flight context applied when no --flight flag is given. It is
// a flight reference (@namespace/+slug, +slug, or a bare slug) and is
// may be set as a user baseline by bootstrap or overridden in project config;
// TAP_FLIGHT and --flight have higher precedence.
// TAP_FLIGHT, the active agent's flight, and --flight have higher precedence.
Flight string `yaml:"flight,omitempty"`

// agent names the entry in agents{} driving this process, and is set by
// `tap launch` as TAP_AGENT. It selects a flight indirectly: resolution reads
// agents[agent].flight out of the merged config on every load, so an edit to
// the agent's flight is picked up by the next reload. Exporting the resolved
// flight instead would freeze it for the life of the process, which is
// precisely the bug this field exists to avoid. TAP_FLIGHT and --flight,
// being direct, still outrank it.
Agent string `yaml:"agent,omitempty"`

// kegMap maps a project path or pattern to a keg reference.
KegMap []KegMapEntry `yaml:"kegMap"`

Expand Down Expand Up @@ -345,14 +354,24 @@ func (cfg *Config) FallbackKeg() string {
}

// Flight returns the persisted flight reference applied when no --flight flag
// is given.
// is given. On a merged config this may have come from the active agent rather
// than from any file — see ConfigService.load.
func (cfg *Config) Flight() string {
if cfg.data == nil {
cfg.data = &configDTO{}
}
return cfg.data.Flight
}

// AgentName returns the name of the agent driving this process, or "" when none
// is selected. It indexes Agents; it is not itself an agent definition.
func (cfg *Config) AgentName() string {
if cfg.data == nil {
cfg.data = &configDTO{}
}
return strings.TrimSpace(cfg.data.Agent)
}

// LookupAliasForTarget previously reverse-mapped a resolved target back to its
// configured keg alias. The namespace-centric model has no alias table, so a
// target no longer carries a short alias; callers fall back to the canonical
Expand Down Expand Up @@ -1319,6 +1338,9 @@ func MergeConfig(cfgs ...*Config) *Config {
if c.data.Flight != "" {
out.data.Flight = c.data.Flight
}
if c.data.Agent != "" {
out.data.Agent = c.data.Agent
}
if c.data.LogFile != "" {
out.data.LogFile = c.data.LogFile
}
Expand Down
Loading
Loading