@@ -214,6 +214,8 @@ func TestResolveTemplateRigScopedEnvCarriesRigRoots(t *testing.T) {
214214}
215215
216216func TestResolveTemplateUsesCityManagedDoltPort (t * testing.T ) {
217+ // Hermetic: ambient GC_BEADS would poison the "bd" assertion below.
218+ t .Setenv ("GC_BEADS" , "" )
217219 cityPath := t .TempDir ()
218220 writeTemplateResolveCityConfig (t , cityPath , "" )
219221 stateDir := filepath .Join (cityPath , ".gc" , "runtime" , "packs" , "dolt" )
@@ -269,8 +271,120 @@ func TestResolveTemplateUsesCityManagedDoltPort(t *testing.T) {
269271 if got := tp .Env ["GC_BIN" ]; got == "" {
270272 t .Fatalf ("GC_BIN = %q, want non-empty" , got )
271273 }
272- if got := tp .Env ["GC_BEADS" ]; got != "exec:" + filepath .Join (cityPath , ".gc" , "system" , "bin" , "gc-beads-bd" ) {
273- t .Fatalf ("GC_BEADS = %q, want exec gc-beads-bd provider" , got )
274+ // Agent sessions route data ops to raw bd, not the lifecycle wrapper.
275+ // See #647.
276+ if got := tp .Env ["GC_BEADS" ]; got != "bd" {
277+ t .Fatalf ("GC_BEADS = %q, want %q" , got , "bd" )
278+ }
279+ }
280+
281+ // Regression for #647: agent-session data ops must not route through the
282+ // lifecycle-only gc-beads-bd wrapper.
283+ func TestResolveTemplateRoutesAgentSessionDataOpsToRawBd (t * testing.T ) {
284+ cases := []struct {
285+ name string
286+ provider string
287+ want string
288+ }{
289+ {name : "default bd" , provider : "" , want : "bd" },
290+ {name : "explicit bd" , provider : "bd" , want : "bd" },
291+ {name : "file passthrough" , provider : "file" , want : "file" },
292+ {name : "custom exec passthrough" , provider : "exec:/custom/gc-beads-br" , want : "exec:/custom/gc-beads-br" },
293+ }
294+ for _ , tc := range cases {
295+ t .Run (tc .name , func (t * testing.T ) {
296+ // Hermetic: the city.toml value is authoritative for this test,
297+ // regardless of what the developer has exported in their shell.
298+ t .Setenv ("GC_BEADS" , "" )
299+ cityPath := t .TempDir ()
300+ writeTemplateResolveCityConfig (t , cityPath , tc .provider )
301+
302+ params := & agentBuildParams {
303+ cityName : "city" ,
304+ cityPath : cityPath ,
305+ workspace : & config.Workspace {Provider : "test" },
306+ providers : map [string ]config.ProviderSpec {"test" : {Command : "echo" , PromptMode : "none" }},
307+ lookPath : func (string ) (string , error ) { return "/bin/echo" , nil },
308+ fs : fsys.OSFS {},
309+ beaconTime : time .Unix (0 , 0 ),
310+ beadNames : make (map [string ]string ),
311+ stderr : io .Discard ,
312+ }
313+
314+ agent := & config.Agent {Name : "worker" }
315+ tp , err := resolveTemplate (params , agent , agent .QualifiedName (), nil )
316+ if err != nil {
317+ t .Fatalf ("resolveTemplate: %v" , err )
318+ }
319+
320+ if got := tp .Env ["GC_BEADS" ]; got != tc .want {
321+ t .Fatalf ("GC_BEADS = %q, want %q" , got , tc .want )
322+ }
323+ })
324+ }
325+ }
326+
327+ // Regression for #647: if a parent process leaked GC_BEADS pointing at the
328+ // city-managed lifecycle wrapper, nested agent sessions would re-inherit it
329+ // and recreate the exit-2/empty-JSON crash on data ops. The raw provider
330+ // normalizes that well-known wrapper path back to "bd".
331+ func TestResolveTemplateRawBeadsProviderStripsLifecycleWrapper (t * testing.T ) {
332+ cityPath := t .TempDir ()
333+ writeTemplateResolveCityConfig (t , cityPath , "" )
334+ contaminated := "exec:" + filepath .Join (cityPath , ".gc" , "system" , "bin" , "gc-beads-bd" )
335+ t .Setenv ("GC_BEADS" , contaminated )
336+
337+ params := & agentBuildParams {
338+ cityName : "city" ,
339+ cityPath : cityPath ,
340+ workspace : & config.Workspace {Provider : "test" },
341+ providers : map [string ]config.ProviderSpec {"test" : {Command : "echo" , PromptMode : "none" }},
342+ lookPath : func (string ) (string , error ) { return "/bin/echo" , nil },
343+ fs : fsys.OSFS {},
344+ beaconTime : time .Unix (0 , 0 ),
345+ beadNames : make (map [string ]string ),
346+ stderr : io .Discard ,
347+ }
348+
349+ agent := & config.Agent {Name : "worker" }
350+ tp , err := resolveTemplate (params , agent , agent .QualifiedName (), nil )
351+ if err != nil {
352+ t .Fatalf ("resolveTemplate: %v" , err )
353+ }
354+
355+ if got := tp .Env ["GC_BEADS" ]; got != "bd" {
356+ t .Fatalf ("GC_BEADS = %q, want %q (ambient wrapper must be normalized)" , got , "bd" )
357+ }
358+ }
359+
360+ // Genuine user exec: overrides must pass through untouched — only the
361+ // well-known lifecycle wrapper path is stripped.
362+ func TestResolveTemplateRawBeadsProviderPreservesCustomExec (t * testing.T ) {
363+ cityPath := t .TempDir ()
364+ writeTemplateResolveCityConfig (t , cityPath , "" )
365+ custom := "exec:/usr/local/bin/my-beads-backend"
366+ t .Setenv ("GC_BEADS" , custom )
367+
368+ params := & agentBuildParams {
369+ cityName : "city" ,
370+ cityPath : cityPath ,
371+ workspace : & config.Workspace {Provider : "test" },
372+ providers : map [string ]config.ProviderSpec {"test" : {Command : "echo" , PromptMode : "none" }},
373+ lookPath : func (string ) (string , error ) { return "/bin/echo" , nil },
374+ fs : fsys.OSFS {},
375+ beaconTime : time .Unix (0 , 0 ),
376+ beadNames : make (map [string ]string ),
377+ stderr : io .Discard ,
378+ }
379+
380+ agent := & config.Agent {Name : "worker" }
381+ tp , err := resolveTemplate (params , agent , agent .QualifiedName (), nil )
382+ if err != nil {
383+ t .Fatalf ("resolveTemplate: %v" , err )
384+ }
385+
386+ if got := tp .Env ["GC_BEADS" ]; got != custom {
387+ t .Fatalf ("GC_BEADS = %q, want %q (custom exec: must pass through)" , got , custom )
274388 }
275389}
276390
0 commit comments