@@ -145,6 +145,7 @@ function makeSandboxRunners(
145145 return {
146146 listAllContainerNames : ( ) => "" ,
147147 listRunningContainerNames : ( ) => "" ,
148+ listSandboxNames : ( ) => [ ] ,
148149 portProbe : async ( ) => false ,
149150 ...overrides ,
150151 } ;
@@ -243,7 +244,7 @@ describe("classifySandboxContainerFailure", () => {
243244 expect ( portProbeCalled ) . toBe ( false ) ;
244245 } ) ;
245246
246- it ( "matches the exact prefix and the uuid-suffixed shape but not unrelated containers " , async ( ) => {
247+ it ( "matches the exact prefix and accepts uuid-suffixed shapes that resolve back to the queried sandbox " , async ( ) => {
247248 const exactResult = await classifySandboxContainerFailure ( "my-assistant" , {
248249 runners : makeSandboxRunners ( {
249250 listAllContainerNames : ( ) => "openshell-my-assistant\n" ,
@@ -252,14 +253,17 @@ describe("classifySandboxContainerFailure", () => {
252253 expect ( exactResult ?. layer ) . toBe ( "sandbox_container_stopped" ) ;
253254 expect ( exactResult ?. detail ) . toContain ( "openshell-my-assistant" ) ;
254255
255- const otherSandbox = await classifySandboxContainerFailure ( "my-assistant" , {
256+ // `openshell-my-assistant-7616dcb1` belongs to `my-assistant` because no
257+ // other registered sandbox name claims it via the longest-owner rule.
258+ const uuidResult = await classifySandboxContainerFailure ( "my-assistant" , {
256259 runners : makeSandboxRunners ( {
257260 listAllContainerNames : ( ) =>
258- "openshell-my-assistant-evil\nopenshell-different-sandbox-abc" ,
261+ "openshell-my-assistant-7616dcb1\nopenshell-different-sandbox-abc" ,
262+ listSandboxNames : ( ) => [ "my-assistant" , "different-sandbox" ] ,
259263 } ) ,
260264 } ) ;
261- expect ( otherSandbox ?. layer ) . toBe ( "sandbox_container_stopped" ) ;
262- expect ( otherSandbox ?. detail ) . toContain ( "openshell-my-assistant-evil " ) ;
265+ expect ( uuidResult ?. layer ) . toBe ( "sandbox_container_stopped" ) ;
266+ expect ( uuidResult ?. detail ) . toContain ( "openshell-my-assistant-7616dcb1 " ) ;
263267
264268 const unrelated = await classifySandboxContainerFailure ( "my-assistant" , {
265269 runners : makeSandboxRunners ( {
@@ -269,4 +273,80 @@ describe("classifySandboxContainerFailure", () => {
269273 } ) ;
270274 expect ( unrelated ) . toBeNull ( ) ;
271275 } ) ;
276+
277+ it ( "rejects a longer registered sandbox's container even when the literal prefix matches the queried name" , async ( ) => {
278+ // `openshell-my-assistant-prod-7616dcb1` resolves to the longer
279+ // `my-assistant-prod` sandbox via the longest-owner rule; the query for
280+ // `my-assistant` must not consume it. Mirrors the docker-health.ts
281+ // resolver and prevents the prefix-collision bug.
282+ const collision = await classifySandboxContainerFailure ( "my-assistant" , {
283+ runners : makeSandboxRunners ( {
284+ listAllContainerNames : ( ) =>
285+ "openshell-my-assistant-prod-7616dcb1\nopenshell-cluster-nemoclaw" ,
286+ listSandboxNames : ( ) => [ "my-assistant" , "my-assistant-prod" ] ,
287+ } ) ,
288+ } ) ;
289+ expect ( collision ) . toBeNull ( ) ;
290+ } ) ;
291+
292+ it ( "matches an `openshell-<name>` exact container even when a co-tenant `openshell-<name>-<id>` exists in the same listing" , async ( ) => {
293+ const result = await classifySandboxContainerFailure ( "my-assistant" , {
294+ runners : makeSandboxRunners ( {
295+ listAllContainerNames : ( ) =>
296+ "openshell-my-assistant-7616dcb1\nopenshell-my-assistant" ,
297+ listSandboxNames : ( ) => [ "my-assistant" ] ,
298+ } ) ,
299+ } ) ;
300+ expect ( result ?. layer ) . toBe ( "sandbox_container_stopped" ) ;
301+ expect ( result ?. detail ) . toContain ( "openshell-my-assistant" ) ;
302+ expect ( result ?. detail ) . not . toContain ( "openshell-my-assistant-7616dcb1" ) ;
303+ } ) ;
304+
305+ it ( "ignores an out-of-range dashboardPort and falls back to sandbox_container_stopped" , async ( ) => {
306+ let portProbeCalled = false ;
307+ const result = await classifySandboxContainerFailure ( "my-assistant" , {
308+ dashboardPort : 70000 ,
309+ runners : makeSandboxRunners ( {
310+ listAllContainerNames : ( ) => "openshell-my-assistant-7616dcb1\n" ,
311+ portProbe : async ( ) => {
312+ portProbeCalled = true ;
313+ return true ;
314+ } ,
315+ } ) ,
316+ } ) ;
317+ expect ( result ?. layer ) . toBe ( "sandbox_container_stopped" ) ;
318+ expect ( portProbeCalled ) . toBe ( false ) ;
319+ } ) ;
320+
321+ it ( "ignores a non-integer dashboardPort and falls back to sandbox_container_stopped" , async ( ) => {
322+ let portProbeCalled = false ;
323+ const result = await classifySandboxContainerFailure ( "my-assistant" , {
324+ dashboardPort : 18789.5 as unknown as number ,
325+ runners : makeSandboxRunners ( {
326+ listAllContainerNames : ( ) => "openshell-my-assistant-7616dcb1\n" ,
327+ portProbe : async ( ) => {
328+ portProbeCalled = true ;
329+ return true ;
330+ } ,
331+ } ) ,
332+ } ) ;
333+ expect ( result ?. layer ) . toBe ( "sandbox_container_stopped" ) ;
334+ expect ( portProbeCalled ) . toBe ( false ) ;
335+ } ) ;
336+
337+ it ( "ignores a zero dashboardPort and falls back to sandbox_container_stopped" , async ( ) => {
338+ let portProbeCalled = false ;
339+ const result = await classifySandboxContainerFailure ( "my-assistant" , {
340+ dashboardPort : 0 ,
341+ runners : makeSandboxRunners ( {
342+ listAllContainerNames : ( ) => "openshell-my-assistant-7616dcb1\n" ,
343+ portProbe : async ( ) => {
344+ portProbeCalled = true ;
345+ return true ;
346+ } ,
347+ } ) ,
348+ } ) ;
349+ expect ( result ?. layer ) . toBe ( "sandbox_container_stopped" ) ;
350+ expect ( portProbeCalled ) . toBe ( false ) ;
351+ } ) ;
272352} ) ;
0 commit comments