Skip to content

Commit 37bcd0b

Browse files
committed
fix: keep name-based stop inside the current project
`stop --name` filtered background sessions of every project, so the name collision the positional form now guards against was still reachable through the flag. Both forms are project-scoped; an unresolvable project matches nothing instead of the whole host. Only a complete container ID may extend a recorded (truncated) one, since a value of some length in between cannot be verified against it.
1 parent f24bf91 commit 37bcd0b

7 files changed

Lines changed: 90 additions & 50 deletions

File tree

docs/cli-reference.md

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ unchanged. See [windows.md](windows.md).
4141
names are matched in sanitized form on both sides (lowercased,
4242
non-alphanumerics collapsed to `-`, truncated to 32 characters), so a session
4343
started as `--name "My Task"` is reachable as either `my-task` or `"My Task"`.
44-
The same holds for the `--name` filter of `ps`, `status`, and `stop`.
44+
The same holds for the `--name` filter of `ps`, `status`, and `stop`; a `--name`
45+
that is blank or sanitizes to nothing matches no session rather than all of
46+
them.
4547

4648
Session names are resolved against the current project first (same worktree,
4749
then same project); they are not required to be unique, so when one matches
@@ -51,16 +53,17 @@ resolves verbatim, independently of the working directory and `--tool`; an
5153
ambiguous container-ID prefix is reported like an ambiguous name.
5254

5355
`attach` and `theia` widen the search to all projects when the current one has
54-
no match. `stop <name>` does not: removal is destructive and the auto-assigned
55-
names `1`, `2`, … collide across projects by construction, so another project's
56-
session has to be named by its container name or ID.
57-
58-
`stop <name>` and `stop --name <name>` are different commands. The positional
59-
form removes the single session it resolves, whatever its tool and whether it is
60-
running or already stopped. `--name` filters the batch form instead: it removes
61-
every *background* session of the selected tool (`--tool`, default `claude`)
62-
whose session name matches. A `--name` that is blank or sanitizes to nothing
63-
matches no session rather than all of them.
56+
no match. `stop` never does, neither for `stop <name>` nor for `stop --name`:
57+
removal is destructive and the auto-assigned names `1`, `2`, … collide across
58+
projects by construction, so another project's session has to be named by its
59+
container name or ID.
60+
61+
`stop <name>` and `stop --name <name>` still select differently. The positional
62+
form removes exactly the one session it resolves, of any tool unless `--tool` is
63+
passed. `--name` filters the batch form instead: it removes every *background*
64+
session of the current project whose name matches, for the single tool that the
65+
options resolve to (so a profile or config `tool` applies as well). Both include
66+
containers that have already exited.
6467

6568
With no argument, `attach` picks the single detached session of the current
6669
project (a foreground session is entered with `exec` instead), and `theia` picks

docs/persistence.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ enclave resume # Session picker (falls back to continue)
1414

1515
If a container name is already in use, a new session starts with a unique name. Use `exec` to attach to the default container name.
1616

17-
Containers are named `enclave-<tool>-<project-hash>-<session>`, so the same session name can be used in several projects. `attach`, `stop <name>`, and `theia` resolve a session name within the current project first; when a name matches containers in more than one project, the candidates are listed and a full container name must be passed. `attach` and `theia` also accept a name that only exists in another project; `stop` does not, since removing a container is destructive pass its container name instead.
17+
Containers are named `enclave-<tool>-<project-hash>-<session>`, so the same session name can be used in several projects. `attach`, `stop <name>`, and `theia` resolve a session name within the current project first; when a name matches containers in more than one project, the candidates are listed and a full container name must be passed. `attach` and `theia` also accept a name that only exists in another project; `stop` does not — neither by argument nor by `--name`since removing a container is destructive: pass its container name instead.
1818

1919
## Managing Running Containers
2020

internal/app/command_stop.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ func runStop(opts model.Options, projectDir string) int {
4444

4545
// stopSessions removes the containers a `stop` invocation selects: the single
4646
// session named by the positional argument, or every background session of the
47-
// tool, narrowed by `--name` when that flag was given.
47+
// tool, narrowed to the current project's matching sessions when `--name` was
48+
// given.
4849
func stopSessions(ctx context.Context, be backend.Backend, opts model.Options, projectDir string) int {
4950
run := opts.RunOptions
5051

@@ -71,9 +72,12 @@ func stopSessions(ctx context.Context, be backend.Backend, opts model.Options, p
7172
return 1
7273
}
7374
// Sanitized matching happens here rather than as a label filter, so that a
74-
// name which sanitizes to nothing stops nothing instead of everything.
75+
// name which sanitizes to nothing stops nothing instead of everything. Like
76+
// the positional form, `--name` stays inside the current project: session
77+
// names are project-relative and collide across projects.
7578
if name, given := sessionNameFilter(opts); given {
76-
sessions = sessionsMatchingName(sessions, name)
79+
project := sessionTargetProject(projectDir)
80+
sessions = sessionsMatchingName(projectHashSessions(sessions, project.Hash), name)
7781
}
7882

7983
if len(sessions) == 0 {

internal/app/command_stop_test.go

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"testing"
1515

1616
"enclave/internal/backend"
17+
"enclave/internal/config"
1718
"enclave/internal/model"
1819
)
1920

@@ -36,48 +37,64 @@ func TestStopContainerFinalizesThenForceRemovesOnFinalizeError(t *testing.T) {
3637
}
3738
}
3839

39-
func TestStopSessionsRequiresANameToStopSomething(t *testing.T) {
40-
sessions := []backend.Session{
41-
session("enclave-claude-aaaaaaaaaaaa-my-task", "my-task", "aaaaaaaaaaaa", "/repo/a"),
42-
session("enclave-claude-aaaaaaaaaaaa-other", "other", "aaaaaaaaaaaa", "/repo/a"),
40+
func TestStopSessionsSelection(t *testing.T) {
41+
projectDir := t.TempDir()
42+
project, err := config.ResolveProjectFromDir(projectDir)
43+
if err != nil {
44+
t.Fatalf("ResolveProjectFromDir() error = %v", err)
4345
}
46+
mine := session("enclave-claude-"+project.Hash+"-my-task", "my-task", project.Hash, projectDir)
47+
other := session("enclave-claude-"+project.Hash+"-other", "other", project.Hash, projectDir)
48+
foreign := session("enclave-claude-bbbbbbbbbbbb-my-task", "my-task", "bbbbbbbbbbbb", "/repo/b")
49+
4450
tests := []struct {
4551
name string
4652
opts model.Options
4753
sessions []backend.Session
4854
code int
4955
stopped []string
5056
}{
57+
{
58+
name: "--name stops the matching session",
59+
opts: stopOptionsWithName("my-task"),
60+
sessions: []backend.Session{mine, other},
61+
stopped: []string{mine.Ref.Name},
62+
},
63+
{
64+
name: "--name does not leave the project",
65+
opts: stopOptionsWithName("my-task"),
66+
sessions: []backend.Session{foreign},
67+
},
5168
{
5269
name: "blank --name stops nothing",
5370
opts: stopOptionsWithName(" "),
54-
sessions: sessions,
71+
sessions: []backend.Session{mine, other},
5572
},
5673
{
5774
name: "unsanitizable --name stops nothing",
5875
opts: stopOptionsWithName("???"),
59-
sessions: sessions,
76+
sessions: []backend.Session{mine, other},
6077
},
6178
{
6279
// The single session would be auto-selected if the blank argument were
6380
// read as "no argument".
6481
name: "blank positional argument is rejected",
6582
opts: stopOptionsWithArg(" "),
66-
sessions: sessions[:1],
83+
sessions: []backend.Session{mine},
6784
code: 1,
6885
},
6986
{
70-
name: "no filter stops every background session",
87+
name: "without --name every background session is stopped",
7188
opts: model.Options{Sources: model.DefaultOptionSources()},
72-
sessions: sessions,
73-
stopped: []string{sessions[0].Ref.Name, sessions[1].Ref.Name},
89+
sessions: []backend.Session{mine, foreign},
90+
stopped: []string{mine.Ref.Name, foreign.Ref.Name},
7491
},
7592
}
7693

7794
for _, tt := range tests {
7895
t.Run(tt.name, func(t *testing.T) {
7996
be := &stopTestBackend{sessions: tt.sessions}
80-
if code := stopSessions(context.Background(), be, tt.opts, ""); code != tt.code {
97+
if code := stopSessions(context.Background(), be, tt.opts, projectDir); code != tt.code {
8198
t.Fatalf("stopSessions() = %d, want %d", code, tt.code)
8299
}
83100
if !slices.Equal(be.stopped, tt.stopped) {

internal/app/session_target.go

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ import (
2323
// hex-looking session names keep working.
2424
const containerIDMinPrefix = 8
2525

26-
// containerIDMaxLen is the length of a full container ID. Recorded IDs are
27-
// truncated, so a longer value is matched on its leading characters — but only
28-
// up to the length a real ID can have.
29-
const containerIDMaxLen = 64
26+
// containerIDFullLen is the length of a full container ID. Recorded IDs are
27+
// truncated, so only a value of exactly this length may extend one; anything
28+
// between is a prefix or nothing at all.
29+
const containerIDFullLen = 64
3030

3131
// sessionTargetQuery describes how a positional container/session argument is
3232
// resolved to exactly one managed session.
@@ -49,7 +49,8 @@ type sessionTargetQuery struct {
4949
// ProjectScopedNames rejects a session name that only matches outside the
5050
// current project. `stop` sets it: removal is destructive and the
5151
// auto-assigned names `1`, `2`, … collide across projects by construction,
52-
// so another project's session needs its container name or ID.
52+
// so another project's session needs its container name or ID. It requires
53+
// Project to be set; without it no name resolves.
5354
ProjectScopedNames bool
5455
// BackgroundOnly restricts auto-selection to detached sessions, so that a
5556
// bare `attach` cannot grab the TTY of a foreground session that a second
@@ -188,20 +189,29 @@ func projectSessions(sessions []backend.Session, project model.Project) (scoped
188189
return sameWorktree, true
189190
}
190191
}
191-
if hash := strings.TrimSpace(project.Hash); hash != "" {
192-
var sameProject []backend.Session
193-
for _, session := range sessions {
194-
if strings.TrimSpace(session.ProjectHash) == hash {
195-
sameProject = append(sameProject, session)
196-
}
197-
}
198-
if len(sameProject) > 0 {
199-
return sameProject, true
200-
}
192+
if sameProject := projectHashSessions(sessions, project.Hash); len(sameProject) > 0 {
193+
return sameProject, true
201194
}
202195
return sessions, false
203196
}
204197

198+
// projectHashSessions narrows sessions to one project. An unresolvable project
199+
// (empty hash) matches nothing, never everything, so that a name-filtered
200+
// command cannot silently widen to the whole host.
201+
func projectHashSessions(sessions []backend.Session, hash string) []backend.Session {
202+
hash = strings.TrimSpace(hash)
203+
if hash == "" {
204+
return nil
205+
}
206+
var matches []backend.Session
207+
for _, session := range sessions {
208+
if strings.TrimSpace(session.ProjectHash) == hash {
209+
matches = append(matches, session)
210+
}
211+
}
212+
return matches
213+
}
214+
205215
func sessionsForTool(sessions []backend.Session, tool string) []backend.Session {
206216
tool = strings.TrimSpace(tool)
207217
if tool == "" {
@@ -222,9 +232,10 @@ func sessionsForTool(sessions []backend.Session, tool string) []backend.Session
222232
// matched. All matches are returned so that an ambiguous prefix is reported
223233
// instead of resolved by listing order.
224234
func sessionsByContainerID(sessions []backend.Session, requested string) []backend.Session {
225-
if len(requested) < containerIDMinPrefix || len(requested) > containerIDMaxLen || !isHexString(requested) {
235+
if len(requested) < containerIDMinPrefix || len(requested) > containerIDFullLen || !isHexString(requested) {
226236
return nil
227237
}
238+
full := len(requested) == containerIDFullLen
228239
var matches []backend.Session
229240
for _, session := range sessions {
230241
id := strings.TrimSpace(session.Ref.ID)
@@ -233,7 +244,7 @@ func sessionsByContainerID(sessions []backend.Session, requested string) []backe
233244
}
234245
// Session IDs are truncated, so a full ID pasted from docker carries the
235246
// one recorded here as its prefix.
236-
if strings.HasPrefix(id, requested) || strings.HasPrefix(requested, id) {
247+
if strings.HasPrefix(id, requested) || (full && strings.HasPrefix(requested, id)) {
237248
matches = append(matches, session)
238249
}
239250
}

internal/app/session_target_test.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,8 @@ func TestResolveSessionTargetResolvesContainerID(t *testing.T) {
245245
target.Ref.ID = "3f2b1c0d4e5f"
246246
be := &stopTestBackend{sessions: []backend.Session{target}}
247247

248-
for _, requested := range []string{"3f2b1c0d4e5f", "3f2b1c0d", "3f2b1c0d4e5f6a7b8c9d"} {
248+
fullID := target.Ref.ID + strings.Repeat("a", containerIDFullLen-len(target.Ref.ID))
249+
for _, requested := range []string{"3f2b1c0d4e5f", "3f2b1c0d", fullID} {
249250
got, err := resolveSessionTarget(context.Background(), be, sessionTargetQuery{Args: []string{requested}})
250251
if err != nil {
251252
t.Fatalf("resolveSessionTarget(%q) error = %v", requested, err)
@@ -274,14 +275,17 @@ func TestResolveSessionTargetReportsAmbiguousContainerIDPrefix(t *testing.T) {
274275
}
275276
}
276277

277-
func TestResolveSessionTargetRejectsOverlongContainerID(t *testing.T) {
278+
func TestResolveSessionTargetRejectsPartialContainerIDExtension(t *testing.T) {
278279
target := session("enclave-claude-aaaaaaaaaaaa-my-task", "my-task", "aaaaaaaaaaaa", "/repo/a")
279280
target.Ref.ID = "3f2b1c0d4e5f"
280281
be := &stopTestBackend{sessions: []backend.Session{target}}
281282

282-
requested := target.Ref.ID + strings.Repeat("a", containerIDMaxLen)
283-
if _, err := resolveSessionTarget(context.Background(), be, sessionTargetQuery{Args: []string{requested}}); err == nil {
284-
t.Fatal("a value longer than a container ID must not match on its prefix")
283+
// Only a complete ID may extend the recorded (truncated) one; a value of
284+
// some length in between cannot be verified against it.
285+
for _, requested := range []string{target.Ref.ID + "aaaaaaaa", target.Ref.ID + strings.Repeat("a", containerIDFullLen)} {
286+
if _, err := resolveSessionTarget(context.Background(), be, sessionTargetQuery{Args: []string{requested}}); err == nil {
287+
t.Fatalf("resolveSessionTarget(%q) must not match on the recorded ID prefix", requested)
288+
}
285289
}
286290
}
287291

internal/cli/stop_command.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ With an argument, exactly the session it names is removed: a container name from
1919
` + "`enclave ps`" + `, a container ID, or a session name of the current project.
2020
A session name of another project is not accepted here — pass its container name.
2121
22-
Without an argument, every background container of the selected tool is removed;
23-
--name narrows that to sessions with a matching name.`,
22+
Without an argument, every background container of the selected tool is removed,
23+
across all projects; --name narrows that to matching sessions of the current
24+
project.`,
2425
Args: cobra.MaximumNArgs(1),
2526
RunE: func(_ *cobra.Command, cmdArgs []string) error {
2627
res.Action = "stop"

0 commit comments

Comments
 (0)