diff --git a/cmd/gc/bd_env.go b/cmd/gc/bd_env.go index 9a875fcaca..d3f0d19f03 100644 --- a/cmd/gc/bd_env.go +++ b/cmd/gc/bd_env.go @@ -939,6 +939,21 @@ func cityRuntimeEnvMapForCity(cityPath string) map[string]string { return citylayout.CityRuntimeEnvMapForRuntimeDir(cityPath, citylayout.TrustedAmbientCityRuntimeDir(cityPath)) } +// cityIdentityAnchorsForCity returns only the three identity anchors +// (GC_CITY, GC_CITY_PATH, GC_CITY_RUNTIME_DIR) for cityPath. Used by +// session-resolver reseed paths that must restore the city anchor on +// existing-session resume/create without touching the city-uniform +// GC_CONTROL_DISPATCHER_TRACE_DEFAULT (which has to stay per-dispatcher; +// see template_resolve.go control-dispatcher branch). +func cityIdentityAnchorsForCity(cityPath string) map[string]string { + full := cityRuntimeEnvMapForCity(cityPath) + return map[string]string{ + "GC_CITY": full["GC_CITY"], + "GC_CITY_PATH": full["GC_CITY_PATH"], + "GC_CITY_RUNTIME_DIR": full["GC_CITY_RUNTIME_DIR"], + } +} + func cityRuntimeProcessEnvWithError(cityPath string) ([]string, error) { cityPath = normalizePathForCompare(cityPath) overrides := cityRuntimeEnvMapForCity(cityPath) diff --git a/cmd/gc/worker_handle.go b/cmd/gc/worker_handle.go index d870d31e7c..5e47879d6d 100644 --- a/cmd/gc/worker_handle.go +++ b/cmd/gc/worker_handle.go @@ -212,6 +212,7 @@ func newWorkerSessionHandleForResolvedRuntimeWithConfig( return nil, err } sessionCfg, err := resolvedWorkerSessionConfigWithConfig( + cityPath, command, provider, workDir, @@ -231,6 +232,7 @@ func newWorkerSessionHandleForResolvedRuntimeWithConfig( } func resolvedWorkerSessionConfigWithConfig( + cityPath string, command string, provider string, workDir string, @@ -272,6 +274,18 @@ func resolvedWorkerSessionConfigWithConfig( if command == "" { command = providerName } + // Seed the city-anchored identity vars on top of the provider env + // for the CLI create-mode path. Without this, `gc session` / + // `gc session start` style direct creates land with SessionEnv + // lacking GC_CITY / GC_CITY_PATH / GC_CITY_RUNTIME_DIR and the + // spawned shell cannot locate its city. Matches the resume-path + // reseed at resolvedWorkerRuntimeWithConfigAndMetadata and the + // API-side seeding in internal/api/session_resolved_config.go. + // Regression for upstream gastownhall/gascity#101 (re-opened). + sessionEnv := resolved.Env + if strings.TrimSpace(cityPath) != "" { + sessionEnv = mergeEnv(resolved.Env, cityIdentityAnchorsForCity(cityPath)) + } return worker.NormalizeResolvedSessionConfig(worker.ResolvedSessionConfig{ Alias: alias, ExplicitName: explicitName, @@ -283,7 +297,7 @@ func resolvedWorkerSessionConfigWithConfig( Command: command, WorkDir: workDir, Provider: providerName, - SessionEnv: resolved.Env, + SessionEnv: sessionEnv, Resume: session.ProviderResume{ ResumeFlag: resolved.ResumeFlag, ResumeStyle: resolved.ResumeStyle, @@ -485,11 +499,22 @@ func resolvedWorkerRuntimeWithConfigAndMetadata(cityPath string, cfg *config.Cit if err != nil { return nil, err } + // Reseed the city-anchored identity vars (GC_CITY, GC_CITY_PATH, + // GC_CITY_RUNTIME_DIR) on top of the provider env. Without this, + // session restart paths drop the city anchor — bd, mailboxes, and + // other city-relative tooling then fail inside the spawned session. + // Regression for upstream gastownhall/gascity#101 (re-opened). + // + // Identity-only (no GC_CONTROL_DISPATCHER_TRACE_DEFAULT): the + // dispatcher trace path is per-dispatcher-qualified and must not be + // overwritten with the city-uniform default here. template_resolve.go + // owns the qualified override for the CLI create path. + sessionEnv := mergeEnv(resolved.Env, cityIdentityAnchorsForCity(cityPath)) return &worker.ResolvedRuntime{ Command: command, WorkDir: workDir, Provider: firstNonEmptyGCString(info.Provider, resolved.Name), - SessionEnv: resolved.Env, + SessionEnv: sessionEnv, Hints: runtime.Config{ WorkDir: workDir, ReadyPromptPrefix: resolved.ReadyPromptPrefix, diff --git a/cmd/gc/worker_handle_test.go b/cmd/gc/worker_handle_test.go index a59fed0614..cc7b5a55db 100644 --- a/cmd/gc/worker_handle_test.go +++ b/cmd/gc/worker_handle_test.go @@ -205,6 +205,168 @@ func TestResolvedWorkerRuntimeResumesPoolSessionPreservesLaunchFlags(t *testing. } } +// TestResolvedWorkerRuntimeWithConfigSeedsCityRuntimeEnv is a regression +// test for upstream gastownhall/gascity#101 (re-opened): on session +// restart, the worker resolver reseeded the session env from +// resolved.Env (provider-only). That dropped the city-anchored env vars +// (GC_CITY, GC_CITY_PATH, GC_CITY_RUNTIME_DIR), so spawned/restarted +// agent sessions could not locate their city — bd commands failed, +// mailboxes resolved against the wrong path, and downstream tooling +// behaved as if no city was configured. The CLI-side defense in +// cmd/gc/main.go resolveContext (#2062) masked the symptom; this +// resolver-level fix is the root cause: the resolved runtime must +// always carry the city anchor vars so any restart path is sound. +func TestResolvedWorkerRuntimeWithConfigSeedsCityRuntimeEnv(t *testing.T) { + cityDir := t.TempDir() + gcDir := filepath.Join(cityDir, ".gc") + if err := os.MkdirAll(gcDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gcDir, "settings.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + claude := config.BuiltinProviders()["claude"] + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{{ + Name: "worker", + Provider: "claude", + }}, + Providers: map[string]config.ProviderSpec{ + "claude": claude, + }, + } + + resolved, err := resolvedWorkerRuntimeWithConfig(cityDir, cfg, session.Info{ + Template: "worker", + WorkDir: cityDir, + }, "") + if err != nil { + t.Fatalf("resolvedWorkerRuntimeWithConfig: %v", err) + } + if resolved == nil { + t.Fatal("resolvedWorkerRuntimeWithConfig() = nil") + } + + if got := resolved.SessionEnv["GC_CITY"]; got != cityDir { + t.Errorf("SessionEnv[GC_CITY] = %q, want %q", got, cityDir) + } + if got := resolved.SessionEnv["GC_CITY_PATH"]; got != cityDir { + t.Errorf("SessionEnv[GC_CITY_PATH] = %q, want %q", got, cityDir) + } + wantRuntimeDir := filepath.Join(cityDir, ".gc", "runtime") + if got := resolved.SessionEnv["GC_CITY_RUNTIME_DIR"]; got != wantRuntimeDir { + t.Errorf("SessionEnv[GC_CITY_RUNTIME_DIR] = %q, want %q", got, wantRuntimeDir) + } + // Identity-only contract (per Copilot review): the dispatcher trace + // default must NOT be seeded by the resume reseed, because it has to + // stay per-dispatcher-qualified (template_resolve.go owns the + // qualified override). Seeding the city-uniform default here would + // regress trace files for control-dispatcher sessions on restart. + if got, present := resolved.SessionEnv["GC_CONTROL_DISPATCHER_TRACE_DEFAULT"]; present { + t.Errorf("SessionEnv[GC_CONTROL_DISPATCHER_TRACE_DEFAULT] = %q present, want absent (identity-only reseed)", got) + } +} + +// TestResolvedWorkerRuntimeWithConfigCityAnchorsBeatConflictingProviderEnv +// pins the precedence contract: when the resolved provider env carries +// its own GC_CITY (e.g. left over from a stale pool entry, or a +// provider that hard-codes one), the city-anchored reseed must win. +// Without this assertion, future refactors could accidentally reverse +// the merge order and re-introduce upstream #101 from the other side. +func TestResolvedWorkerRuntimeWithConfigCityAnchorsBeatConflictingProviderEnv(t *testing.T) { + cityDir := t.TempDir() + gcDir := filepath.Join(cityDir, ".gc") + if err := os.MkdirAll(gcDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gcDir, "settings.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + claude := config.BuiltinProviders()["claude"] + // Force a conflicting GC_CITY in the provider env so we can prove + // the reseed wins. We can't reach into resolved.Env directly, so we + // instead pin the worker's env on its own ProviderSpec via the + // pool entry's runtime env section (which feeds resolved.Env). + claude.Env = map[string]string{ + "GC_CITY": "/wrong/city", + "GC_CITY_PATH": "/wrong/city", + } + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{{ + Name: "worker", + Provider: "claude", + }}, + Providers: map[string]config.ProviderSpec{ + "claude": claude, + }, + } + + resolved, err := resolvedWorkerRuntimeWithConfig(cityDir, cfg, session.Info{ + Template: "worker", + WorkDir: cityDir, + }, "") + if err != nil { + t.Fatalf("resolvedWorkerRuntimeWithConfig: %v", err) + } + if resolved == nil { + t.Fatal("resolvedWorkerRuntimeWithConfig() = nil") + } + if got := resolved.SessionEnv["GC_CITY"]; got != cityDir { + t.Errorf("SessionEnv[GC_CITY] = %q, want %q (city anchor must win over provider env)", got, cityDir) + } + if got := resolved.SessionEnv["GC_CITY_PATH"]; got != cityDir { + t.Errorf("SessionEnv[GC_CITY_PATH] = %q, want %q (city anchor must win over provider env)", got, cityDir) + } +} + +// TestResolvedWorkerSessionConfigWithConfigSeedsCityAnchorsOnCreatePath +// covers the CLI session-create path (called by `gc session start` / +// `gc session new` etc. through newWorkerSessionHandleForResolvedRuntimeWithConfig). +// Before this fix, the create path passed resolved.Env directly as +// SessionEnv, so direct CLI creates landed without GC_CITY anchors — +// the same upstream #101 symptom as the resume path, just through a +// different door. Companion to the resume-path regression test above. +func TestResolvedWorkerSessionConfigWithConfigSeedsCityAnchorsOnCreatePath(t *testing.T) { + cityDir := t.TempDir() + gcDir := filepath.Join(cityDir, ".gc") + if err := os.MkdirAll(gcDir, 0o755); err != nil { + t.Fatal(err) + } + + cfg, err := resolvedWorkerSessionConfigWithConfig( + cityDir, + "", + "", + cityDir, + "worker", + "", + "worker", + "Worker", + "", + &config.ResolvedProvider{Name: "claude"}, + map[string]string{"session_origin": "test"}, + nil, + ) + if err != nil { + t.Fatalf("resolvedWorkerSessionConfigWithConfig: %v", err) + } + env := cfg.Runtime.SessionEnv + if got := env["GC_CITY"]; got != cityDir { + t.Errorf("Runtime.SessionEnv[GC_CITY] = %q, want %q", got, cityDir) + } + if got := env["GC_CITY_PATH"]; got != cityDir { + t.Errorf("Runtime.SessionEnv[GC_CITY_PATH] = %q, want %q", got, cityDir) + } + if env["GC_CITY_RUNTIME_DIR"] == "" { + t.Error("Runtime.SessionEnv[GC_CITY_RUNTIME_DIR] = empty, want set") + } + if got, present := env["GC_CONTROL_DISPATCHER_TRACE_DEFAULT"]; present { + t.Errorf("Runtime.SessionEnv[GC_CONTROL_DISPATCHER_TRACE_DEFAULT] = %q present, want absent (identity-only)", got) + } +} + func TestShouldPreserveStoredRuntimeCommandForTransportRejectsExecutableOnlyMatch(t *testing.T) { if shouldPreserveStoredRuntimeCommandForTransport( "claude", @@ -1032,6 +1194,7 @@ func TestWorkerNudgeDeliveryForMode(t *testing.T) { func TestResolvedWorkerSessionConfigWithConfigFallsBackToResolvedProviderNameForCommand(t *testing.T) { cfg, err := resolvedWorkerSessionConfigWithConfig( + "", "", "", "/tmp/work", @@ -1059,6 +1222,7 @@ func TestResolvedWorkerSessionConfigWithConfigFallsBackToResolvedProviderNameFor func TestResolvedWorkerSessionConfigWithConfigFallsBackToProviderArgForCommand(t *testing.T) { cfg, err := resolvedWorkerSessionConfigWithConfig( + "", "", "legacy-provider", "/tmp/work", @@ -1084,6 +1248,7 @@ func TestResolvedWorkerSessionConfigWithConfigFallsBackToProviderArgForCommand(t func TestResolvedWorkerSessionConfigWithConfigPersistsStoredMCPMetadata(t *testing.T) { cfg, err := resolvedWorkerSessionConfigWithConfig( + "", "", "legacy-provider", "/tmp/work", @@ -1119,6 +1284,7 @@ func TestResolvedWorkerSessionConfigWithConfigPersistsStoredMCPMetadata(t *testi func TestResolvedWorkerSessionConfigWithConfigSkipsStoredMCPMetadataForTmuxTransport(t *testing.T) { cfg, err := resolvedWorkerSessionConfigWithConfig( + "", "", "legacy-provider", "/tmp/work", diff --git a/internal/api/handler_session_create.go b/internal/api/handler_session_create.go index 921b4094a4..01bb6ebdb2 100644 --- a/internal/api/handler_session_create.go +++ b/internal/api/handler_session_create.go @@ -179,7 +179,7 @@ func (s *Server) handleSessionCreate(w http.ResponseWriter, r *http.Request) { // starts the agent process on the next tick. This avoids blocking the // HTTP response for 10-30s while the agent boots in tmux, and lets real-world apps // show the session in the sidebar immediately via optimistic UI. - resolvedCfg, err := resolvedSessionConfigForProvider(alias, createCtx.ExplicitName, template, title, transport, extraMeta, resolved, command, workDir, mcpServers) + resolvedCfg, err := resolvedSessionConfigForProvider(s.state.CityPath(), alias, createCtx.ExplicitName, template, title, transport, extraMeta, resolved, command, workDir, mcpServers) if err != nil { s.idem.unreserve(idemKey) writeSessionManagerError(w, err) @@ -364,7 +364,7 @@ func (s *Server) createProviderSession(w http.ResponseWriter, r *http.Request, s } } - resolvedCfg, err := resolvedSessionConfigForProvider(alias, "", template, title, transport, extraMeta, resolved, command, workDir, mcpServers) + resolvedCfg, err := resolvedSessionConfigForProvider(s.state.CityPath(), alias, "", template, title, transport, extraMeta, resolved, command, workDir, mcpServers) if err != nil { s.idem.unreserve(idemKey) writeSessionManagerError(w, err) diff --git a/internal/api/huma_handlers_sessions_command.go b/internal/api/huma_handlers_sessions_command.go index 926d7a2585..897764194d 100644 --- a/internal/api/huma_handlers_sessions_command.go +++ b/internal/api/huma_handlers_sessions_command.go @@ -142,6 +142,7 @@ func (s *Server) humaHandleSessionCreate(ctx context.Context, input *SessionCrea } } resolvedCfg, cfgErr := resolvedSessionConfigForProvider( + s.state.CityPath(), alias, explicitName, template, @@ -319,7 +320,7 @@ func (s *Server) humaCreateProviderSession(_ context.Context, store beads.Store, } go func() { defer s.recoverAsRequestFailed(reqID, RequestOperationSessionCreate) - resolvedCfg, cfgErr := resolvedSessionConfigForProvider(alias, "", template, title, transport, extraMeta, resolved, command, workDir, mcpServers) + resolvedCfg, cfgErr := resolvedSessionConfigForProvider(s.state.CityPath(), alias, "", template, title, transport, extraMeta, resolved, command, workDir, mcpServers) if cfgErr != nil { s.emitSessionCreateFailed(reqID, "create_failed", cfgErr.Error()) return diff --git a/internal/api/session_resolved_config.go b/internal/api/session_resolved_config.go index 8e746f111e..8e55566e72 100644 --- a/internal/api/session_resolved_config.go +++ b/internal/api/session_resolved_config.go @@ -10,7 +10,7 @@ import ( ) func resolvedSessionConfigForProvider( - alias, explicitName, template, title, transport string, + cityPath, alias, explicitName, template, title, transport string, metadata map[string]string, resolved *config.ResolvedProvider, command, workDir string, @@ -47,7 +47,7 @@ func resolvedSessionConfigForProvider( Command: firstNonEmptyString(command, resolvedCommand, resolved.Name), WorkDir: workDir, Provider: resolved.Name, - SessionEnv: resolved.Env, + SessionEnv: cityAnchoredSessionEnv(cityPath, resolved.Env), Resume: session.ProviderResume{ ResumeFlag: resolved.ResumeFlag, ResumeStyle: resolved.ResumeStyle, diff --git a/internal/api/session_resolved_config_test.go b/internal/api/session_resolved_config_test.go index 106e5d24f6..e1ec120761 100644 --- a/internal/api/session_resolved_config_test.go +++ b/internal/api/session_resolved_config_test.go @@ -1,6 +1,7 @@ package api import ( + "path/filepath" "testing" "github.com/gastownhall/gascity/internal/config" @@ -34,6 +35,7 @@ func TestResolvedSessionConfigForProviderBuildsNormalizedConfig(t *testing.T) { } cfg, err := resolvedSessionConfigForProvider( + "/tmp/test-city", "worker", "worker-named", "myrig/worker", @@ -92,6 +94,7 @@ func TestResolvedSessionConfigForProviderBuildsNormalizedConfig(t *testing.T) { func TestResolvedSessionConfigForProviderRejectsNilProvider(t *testing.T) { if _, err := resolvedSessionConfigForProvider( + "/tmp/test-city", "worker", "", "myrig/worker", @@ -107,8 +110,101 @@ func TestResolvedSessionConfigForProviderRejectsNilProvider(t *testing.T) { } } +// TestResolvedSessionConfigForProviderSeedsCityRuntimeEnv is a +// regression test for upstream gastownhall/gascity#101 (re-opened): +// session-create paths through the API resolver dropped the +// city-anchored env vars (GC_CITY, GC_CITY_PATH, GC_CITY_RUNTIME_DIR) +// because they only forwarded resolved.Env (provider-only). The +// spawned shell then could not locate the city, so bd, mailboxes, and +// related tooling failed. Non-conflicting provider env vars are +// preserved; this test documents the merge contract. +func TestResolvedSessionConfigForProviderSeedsCityRuntimeEnv(t *testing.T) { + cityPath := t.TempDir() + cfg, err := resolvedSessionConfigForProvider( + cityPath, + "worker", + "", + "myrig/worker", + "Worker", + "", + nil, + &config.ResolvedProvider{ + Name: "stub", + Command: "/bin/echo", + Env: map[string]string{"PROVIDER_TOKEN": "ok"}, + }, + "", + cityPath, + nil, + ) + if err != nil { + t.Fatalf("resolvedSessionConfigForProvider: %v", err) + } + if got := cfg.Runtime.SessionEnv["GC_CITY"]; got != cityPath { + t.Errorf("SessionEnv[GC_CITY] = %q, want %q", got, cityPath) + } + if got := cfg.Runtime.SessionEnv["GC_CITY_PATH"]; got != cityPath { + t.Errorf("SessionEnv[GC_CITY_PATH] = %q, want %q", got, cityPath) + } + wantRuntimeDir := filepath.Join(cityPath, ".gc", "runtime") + if got := cfg.Runtime.SessionEnv["GC_CITY_RUNTIME_DIR"]; got != wantRuntimeDir { + t.Errorf("SessionEnv[GC_CITY_RUNTIME_DIR] = %q, want %q", got, wantRuntimeDir) + } + if got := cfg.Runtime.SessionEnv["PROVIDER_TOKEN"]; got != "ok" { + t.Errorf("SessionEnv[PROVIDER_TOKEN] = %q, want %q (provider env preserved)", got, "ok") + } + // Identity-only contract (per Copilot review): GC_CONTROL_DISPATCHER_TRACE_DEFAULT + // must NOT be seeded by the city-anchor reseed because it has to stay + // per-dispatcher-qualified. template_resolve.go owns the qualified + // override on the CLI create path; the API resume/create path must + // not clobber it with the city-uniform default. + if got, present := cfg.Runtime.SessionEnv["GC_CONTROL_DISPATCHER_TRACE_DEFAULT"]; present { + t.Errorf("SessionEnv[GC_CONTROL_DISPATCHER_TRACE_DEFAULT] = %q present, want absent (identity-only)", got) + } +} + +// TestResolvedSessionConfigForProviderCityAnchorsBeatConflictingProviderEnv +// locks in the precedence contract: when the resolved provider env +// carries its own GC_CITY (e.g. left over from a stale config), the +// city-anchored reseed must win. Future refactors that reverse the +// merge order would re-introduce upstream #101 from the other side; +// this test fails fast on that regression. +func TestResolvedSessionConfigForProviderCityAnchorsBeatConflictingProviderEnv(t *testing.T) { + cityPath := t.TempDir() + cfg, err := resolvedSessionConfigForProvider( + cityPath, + "worker", + "", + "myrig/worker", + "Worker", + "", + nil, + &config.ResolvedProvider{ + Name: "stub", + Command: "/bin/echo", + Env: map[string]string{ + "GC_CITY": "/wrong/city", + "GC_CITY_PATH": "/wrong/city", + }, + }, + "", + cityPath, + nil, + ) + if err != nil { + t.Fatalf("resolvedSessionConfigForProvider: %v", err) + } + if got := cfg.Runtime.SessionEnv["GC_CITY"]; got != cityPath { + t.Errorf("SessionEnv[GC_CITY] = %q, want %q (city anchor must win over provider env)", got, cityPath) + } + if got := cfg.Runtime.SessionEnv["GC_CITY_PATH"]; got != cityPath { + t.Errorf("SessionEnv[GC_CITY_PATH] = %q, want %q (city anchor must win over provider env)", got, cityPath) + } +} + func TestResolvedSessionConfigForProviderSkipsStoredMCPMetadataForTmuxTransport(t *testing.T) { cfg, err := resolvedSessionConfigForProvider( + "/tmp/test-city", "worker", "", "myrig/worker", diff --git a/internal/api/session_runtime.go b/internal/api/session_runtime.go index ae623e3661..8ec414a33a 100644 --- a/internal/api/session_runtime.go +++ b/internal/api/session_runtime.go @@ -6,6 +6,7 @@ import ( "os/exec" "strings" + "github.com/gastownhall/gascity/internal/citylayout" "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/materialize" "github.com/gastownhall/gascity/internal/runtime" @@ -14,6 +15,51 @@ import ( "github.com/gastownhall/gascity/internal/worker" ) +// cityAnchoredSessionEnv returns the provider env merged with the three +// city-anchored env vars (GC_CITY, GC_CITY_PATH, GC_CITY_RUNTIME_DIR). +// City anchors win on conflicts to mirror the canonical create-time +// layering in cmd/gc/template_resolve.go where the per-agent env (which +// carries the same anchors) is applied after the resolved provider env. +// +// Without these anchors, sessions spawned or restarted via the API code +// paths cannot locate their city — bd, mailboxes, and other +// city-relative tooling fail inside the spawned shell. Regression for +// upstream gastownhall/gascity#101 (re-opened). +// +// NOTE: This intentionally seeds only the three identity anchors and +// *not* GC_CONTROL_DISPATCHER_TRACE_DEFAULT. The dispatcher trace path +// must be qualified per-dispatcher (template_resolve.go does this for +// the CLI create path); seeding the city-uniform default here would +// regress per-dispatcher trace files for control-dispatcher sessions +// restarted through the API. Dispatcher-trace handling stays the +// responsibility of the caller that knows the qualified agent name. +func cityAnchoredSessionEnv(cityPath string, providerEnv map[string]string) map[string]string { + anchors := cityIdentityAnchors(cityPath) + if len(providerEnv) == 0 && len(anchors) == 0 { + return nil + } + out := make(map[string]string, len(providerEnv)+len(anchors)) + for k, v := range providerEnv { + out[k] = v + } + for k, v := range anchors { + out[k] = v + } + return out +} + +// cityIdentityAnchors returns just the three GC_CITY/GC_CITY_PATH/ +// GC_CITY_RUNTIME_DIR keys for cityPath. Used by both API and CLI +// session resolvers to keep the "identity-only" subset in one place. +func cityIdentityAnchors(cityPath string) map[string]string { + full := citylayout.CityRuntimeEnvMapForRuntimeDir(cityPath, citylayout.TrustedAmbientCityRuntimeDir(cityPath)) + return map[string]string{ + "GC_CITY": full["GC_CITY"], + "GC_CITY_PATH": full["GC_CITY_PATH"], + "GC_CITY_RUNTIME_DIR": full["GC_CITY_RUNTIME_DIR"], + } +} + var errAmbiguousLegacyACPTransport = errors.New("legacy session transport is ambiguous") func (s *Server) sessionLogPaths() []string { @@ -372,7 +418,7 @@ func (s *Server) resolveWorkerSessionRuntimeWithMetadata(info session.Info, _ st Command: command, WorkDir: firstNonEmptyString(info.WorkDir, workDir), Provider: firstNonEmptyString(info.Provider, resolved.Name), - SessionEnv: resolved.Env, + SessionEnv: cityAnchoredSessionEnv(s.state.CityPath(), resolved.Env), Hints: sessionResumeHints(resolved, firstNonEmptyString(workDir, info.WorkDir), mcpServers), Resume: session.ProviderResume{ ResumeFlag: firstNonEmptyString(resolved.ResumeFlag, info.ResumeFlag), diff --git a/internal/api/worker_factory_test.go b/internal/api/worker_factory_test.go index b05b3797ed..7387bae8b2 100644 --- a/internal/api/worker_factory_test.go +++ b/internal/api/worker_factory_test.go @@ -73,6 +73,25 @@ func TestResolveWorkerSessionRuntimePreservesStoredResolvedCommandAndBackfillsCu if got, want := runtimeCfg.Hints.ReadyDelayMs, 321; got != want { t.Fatalf("Hints.ReadyDelayMs = %d, want %d", got, want) } + // Regression for upstream gastownhall/gascity#101 (re-opened): the + // API resume resolver must seed the three city-anchor identity vars + // so the restarted shell can locate its city. Without this assertion + // the new reseed could silently regress without test coverage. + if got, want := runtimeCfg.SessionEnv["GC_CITY"], fs.cityPath; got != want { + t.Errorf("SessionEnv[GC_CITY] = %q, want %q", got, want) + } + if got, want := runtimeCfg.SessionEnv["GC_CITY_PATH"], fs.cityPath; got != want { + t.Errorf("SessionEnv[GC_CITY_PATH] = %q, want %q", got, want) + } + if runtimeCfg.SessionEnv["GC_CITY_RUNTIME_DIR"] == "" { + t.Error("SessionEnv[GC_CITY_RUNTIME_DIR] = empty, want set") + } + // Identity-only contract (per Copilot review): no dispatcher trace + // default — that must stay per-dispatcher-qualified, not reseeded + // to the city-uniform value here. + if got, present := runtimeCfg.SessionEnv["GC_CONTROL_DISPATCHER_TRACE_DEFAULT"]; present { + t.Errorf("SessionEnv[GC_CONTROL_DISPATCHER_TRACE_DEFAULT] = %q present, want absent (identity-only)", got) + } } func TestResolveWorkerSessionRuntimeUsesResolvedCommandWhenPersistedCommandIsStale(t *testing.T) {