Skip to content

Commit 2c06a2c

Browse files
bmsaadatclaude
andauthored
Add session name & tags to browsers update (kernel-go-sdk v0.66.0) (#184)
## Summary Bumps `kernel-go-sdk` v0.65.0 → v0.66.0, which adds `Name`/`Tags` to `BrowserUpdateParams` (PATCH /browser), and uses it to give `browsers update <id-or-name>` the `--name` / `--clear-name` / `--tag KEY=VALUE` / `--clear-tags` flags that #177 had left creation-only. The flags mirror the existing `--clear-proxy` convention and honor the SDK semantics exactly (omit = unchanged, empty = clear, tags = full replace), with validation that rejects `--name`/`--clear-name` and `--tag`/`--clear-tags` conflicts, guards against an accidental `--name ""`, and stays correct via a `SetName`/`SetTags` flag signal even when a malformed `--tag` is dropped to an empty map. Also refreshes the now-stale `create --name` help and the `update --tag` cap in help/README, and adds unit tests across the new flags, clear/omit marshaling, and the malformed-tag edge cases. ## Test Plan - [x] `go build ./...`, `go vet ./cmd/`, `gofmt` — clean - [x] `go test ./...` — passing (16 new `TestBrowsersUpdate_*` cases) - [x] Binary exercised end-to-end: `--help` shows flags; every validation path errors before any API call - [x] **Live round-trip verified against a local Kernel** (`localhost:3001`): 18/19 🌐 rows pass, **0 product defects** — full-replace tags, `--name`-only does not clobber tags (omit = unchanged), clear name/tags, by-id/by-name resolution, not-found, JSON echo, name+telemetry, and the 50-tag cap (`Invalid_tags: too many tags: 51 (maximum 50)`). Sessions were cleaned up. The one unrun row, B3 (rename-collision between two **concurrent** active sessions), isn't exercisable on the local stack (it returns `Capacity_exhausted` for a second simultaneous browser); name uniqueness is server-enforced and the CLI only relays the error. ## Manual test matrix **Status:** ✅ = verified locally (path returns before any API call, or pinned by a named unit test asserting the exact forwarded params / JSON) · 🌐 = needs a live Kernel API round-trip (not yet run here). **Notes** - *Error display:* `pterm` capitalizes the first letter (`Cannot specify…`, `--Name requires…`); the raw `fmt.Errorf` strings are lowercase — same error. - *Success output:* the `Name:` / `Tags:` lines echo the **server's returned** values, not the request. Unit tests pin the **request body** (✅); the displayed value is a 🌐 observation — except after a *clear*, where `Name: -` / `Tags: -` is correct regardless. - *Scope:* `disable_default_proxy` (also new in v0.66) is intentionally **not** exposed by this PR. **Preconditions for 🌐 rows:** one session with a known starting name + tags (`browsers create --name mtx-base --tag a=1 --tag team=qa`); D2 needs the session to start with `{a=1}` then run `--tag b=2`; B2/B3 need a second session (`--name mtx-taken`) so a colliding active name exists. ### A. Build / help | # | Scenario | Command | Expected | Status | |---|----------|---------|----------|--------| | A1 | Builds & flags wired | `go build ./cmd/kernel` | Builds clean | ✅ | | A2 | Help lists new flags | `browsers update --help` | Shows `--name`, `--clear-name`, `--tag`, `--clear-tags`; the `--tag` line carries the full-replace + "up to 50 pairs" notes | ✅ | | A3 | `Long` help documents ops | `browsers update --help` | Long text lists rename/clear-name and replace/clear-tags, and the "--tag replaces the entire tag set" note | ✅ | ### B. Set name | # | Scenario | Command | Expected | Status | |---|----------|---------|----------|--------| | B1 | Set/rename | `browsers update <id> --name new-name` | Request body `{"name":"new-name"}` ✅; output `Updated browser <id>` then `Name: new-name` (value echoed from server response) 🌐 | ✅ (request body unit-tested); 🌐 (displayed value + persistence) | | B2 | Rename resolves by current name | `browsers update old-name --name newer` | Resolves session by name, applies rename | 🌐 | | B3 | Rename to a name used by another active session | `browsers update <id> --name taken` | API rejects: `Conflict: browser session name already exists` | 🌐 | ### C. Clear name | # | Scenario | Command | Expected | Status | |---|----------|---------|----------|--------| | C1 | Clear name | `browsers update <id> --clear-name` | Request body `{"name":""}`; output `Name: -` | ✅ (request body unit-tested); 🌐 (persisted: get shows no name) | | C2 | `--name ""` rejected (use --clear-name) | `browsers update <id> --name ""` | Error: `--name requires a non-empty value; use --clear-name to clear the name` | ✅ | | C3 | `--name=` (explicit empty) rejected | `browsers update <id> --name=` | Same error as C2 | ✅ | ### D. Set tags (full replace) | # | Scenario | Command | Expected | Status | |---|----------|---------|----------|--------| | D1 | Replace tag set | `browsers update <id> --tag team=backend --tag env=prod` | Request body `{"tags":{"team":"backend","env":"prod"}}` ✅; output `Tags: env=prod, team=backend` (sorted; echoed from server response) 🌐 | ✅ (request body unit-tested); 🌐 (displayed value + persistence) | | D2 | Full-replace semantics (not merge) | Session has `{a=1}`, run `--tag b=2` | Resulting tags are `{b=2}` only — `a` is gone | 🌐 | | D3 | Special-char tag key round-trips | `browsers update <id> --tag region.us=1` | Body contains `"region.us":"1"` | ✅ (parser unit-tested); 🌐 (persisted) | | D4 | 50-tag cap | `--tag` ×51 | API rejects over-limit (server-enforced) | 🌐 | | D5 | Tag value containing `=` | `browsers update <id> --tag url=a=b` | Splits on first `=` only → body `{"tags":{"url":"a=b"}}` (not dropped as malformed) | ✅ (parser unit-tested: `k=v=w`→`k:"v=w"`); 🌐 (persisted) | ### E. Clear tags | # | Scenario | Command | Expected | Status | |---|----------|---------|----------|--------| | E1 | Clear all tags | `browsers update <id> --clear-tags` | Body `{"tags":{}}`; output `Tags: -` | ✅ (request body unit-tested); 🌐 (persisted: get shows no tags) | ### F. Combined name + tags | # | Scenario | Command | Expected | Status | |---|----------|---------|----------|--------| | F1 | Set name + set tags together | `browsers update <id> --name combo --tag k=v` | Body has both `"name":"combo"` and `"tags":{"k":"v"}` | ✅ (unit-tested); 🌐 (persisted) | | F2 | Clear name + set tags | `browsers update <id> --clear-name --tag env=prod` | Body `{"name":"","tags":{"env":"prod"}}` | ✅ (unit-tested) | | F3 | Set name + clear tags | `browsers update <id> --name renamed --clear-tags` | Body `{"name":"renamed","tags":{}}` | ✅ (unit-tested) | ### G. Combine with existing update flags | # | Scenario | Command | Expected | Status | |---|----------|---------|----------|--------| | G1 | Name/tags + proxy in one call | `browsers update <id> --proxy-id <pid> --name combo --tag k=v` | All three forwarded: `proxy_id`, `name`, `tags` | ✅ (unit-tested); 🌐 (persisted) | | G2 | Name + telemetry | `browsers update <id> --name n --telemetry=all` | Name applied and telemetry summary printed | 🌐 | | G3 | Partial update does NOT clobber unrelated fields | Session has tags + proxy; run `--name` only | Only name changes; tags & proxy untouched (omit = unchanged) | ✅ (omit-not-sent unit-tested); 🌐 (persisted) | ### H. Conflict & "at least one" validation | # | Scenario | Command | Expected | Status | |---|----------|---------|----------|--------| | H1 | `--name` + `--clear-name` | `browsers update <id> --name x --clear-name` | Error: `cannot specify both --name and --clear-name` | ✅ | | H2 | `--tag` + `--clear-tags` | `browsers update <id> --tag a=1 --clear-tags` | Error: `cannot specify both --tag and --clear-tags` | ✅ | | H3 | No options at all | `browsers update <id>` | Error: `must specify at least one of: …` (full list of 10 flags, ending `--name, --clear-name, --tag, or --clear-tags`) — substring check | ✅ | | H4 | Name-only satisfies "at least one" | `browsers update <id> --name x` | Proceeds (no "at least one" error) | ✅ | | H5 | Tags-only satisfies "at least one" | `browsers update <id> --tag k=v` | Proceeds | ✅ (param path); 🌐 (persisted) | ### I. Malformed-tag edge cases | # | Scenario | Command | Expected | Status | |---|----------|---------|----------|--------| | I1 | All `--tag` values malformed, alone | `browsers update <id> --tag foo` | Warns `Ignoring malformed tag: foo`, then error `no valid --tag KEY=VALUE pairs provided` (not the generic "at least one") | ✅ | | I2 | Malformed `--tag` + `--clear-tags` still conflicts | `browsers update <id> --tag foo --clear-tags` | Warns, then error `cannot specify both --tag and --clear-tags` (does NOT silently clear) | ✅ | | I3 | Partially malformed list warns & continues (documented sharp edge) | `browsers update <id> --tag a=1 --tag bad` | Warns on `bad`, replaces tag set with `{a=1}` only | ✅ (parser unit-tested); 🌐 (persisted) | ### J. ID-vs-name resolution & errors | # | Scenario | Command | Expected | Status | |---|----------|---------|----------|--------| | J1 | Update by ID | `browsers update <cuid> --name n` | Resolves by ID | 🌐 | | J2 | Update by name | `browsers update <name> --name n2` | Resolves by name | 🌐 | | J3 | Not found | `browsers update nonexistent --name n` | Clean not-found error | 🌐 | ### K. JSON output | # | Scenario | Command | Expected | Status | |---|----------|---------|----------|--------| | K1 | JSON echoes persisted name/tags | `browsers update <id> --name n --tag k=v -o json` | Prints full `BrowserUpdateResponse` JSON including `name` and `tags` | 🌐 | | K2 | `-o yaml` rejected | `browsers update <id> --name n -o yaml` | Error: `unsupported --output value "yaml"; use "json"…` | ✅ | ### L. Round-trip / persistence | # | Scenario | Command | Expected | Status | |---|----------|---------|----------|--------| | L1 | `get` reflects rename | update `--name n` → `browsers get <id>` | Detail table + JSON show new name | 🌐 | | L2 | `get` reflects tag replace | update `--tag k=v` → `browsers get <id>` | Shows exactly the new tag set | 🌐 | | L3 | `get`/`list` reflect clears | `--clear-name`/`--clear-tags` → `get` & `list --query` | No name / no tags after clear; `list --tag` no longer matches | 🌐 | | L4 | `list --tag` filter after replace | replace tags → `browsers list --tag k=v` | Session appears under the new tag, not the old | 🌐 | | L5 | `list` Name column after rename | `--name newname` → `browsers list` | Name column shows `newname` for that session | 🌐 | > Display asymmetry (L1–L3): `update`'s success output prints `Name: -` / `Tags: -` after a clear, but `get`'s detail table **omits** the Name/Tags rows entirely when empty. Both mean "no name / no tags" — a missing `get` row is not a failure. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > CLI-only changes to browser session metadata with local validation and tests; API behavior is delegated to the bumped SDK. > > **Overview** > Bumps **kernel-go-sdk** to **v0.66.0** and wires `browsers update` to the new PATCH fields for session **name** and **tags**, so renaming and retagging no longer require recreating a session. > > `kernel browsers update` gains **`--name` / `--clear-name`** and **`--tag` / `--clear-tags`**, following the same omit-vs-clear pattern as **`--clear-proxy`**: fields are omitted when unchanged, empty values clear, and **`--tag` fully replaces** the tag set (not a merge). Client-side validation rejects conflicting flag pairs, empty **`--name`**, and malformed-only **`--tag`** input (using **`SetName` / `SetTags`** so edge cases still fail before the API). Success output echoes **Name** / **Tags** when those were updated; README and **`create --name`** help note that names can be changed via update. > > Adds broad **`TestBrowsersUpdate_*`** coverage for param forwarding, JSON marshaling, and validation edge cases. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit feb115c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9fdb40f commit 2c06a2c

5 files changed

Lines changed: 345 additions & 8 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ Commands with JSON output support:
213213
- `-H, --headless` - Launch browser without GUI access
214214
- `--kiosk` - Launch browser in kiosk mode
215215
- `--start-url <url>` - Initial page to open on launch
216-
- `--name <name>` - Optional unique name for the session (set at creation; used to find it later by name)
216+
- `--name <name>` - Optional unique name for the session (used to find it later by name; can be changed with `browsers update --name`)
217217
- `--tag <KEY=VALUE>` - Set a tag on the session, repeatable; up to 50 pairs
218218
- `--pool-id <id>` - Acquire a browser from the specified pool (mutually exclusive with --pool-name; ignores other session flags). `--name`/`--tag` still apply to the acquired session.
219219
- `--pool-name <name>` - Acquire a browser from the pool name (mutually exclusive with --pool-id; ignores other session flags)
@@ -230,6 +230,10 @@ Commands with JSON output support:
230230
- `kernel browsers get <id-or-name>` - Get detailed browser session info by ID or name
231231
- `--output json`, `-o json` - Output raw JSON object
232232
- `kernel browsers update <id-or-name>` - Update a running browser session by ID or name
233+
- `--name <name>` - Set a new unique name for the session (mutually exclusive with `--clear-name`)
234+
- `--clear-name` - Clear the session name
235+
- `--tag <KEY=VALUE>` - Set a tag, repeatable; up to 50 pairs. Replaces the entire tag set (not merged); mutually exclusive with `--clear-tags`
236+
- `--clear-tags` - Remove all tags from the session
233237
- `--telemetry=all` - Enable telemetry for all categories
234238
- `--telemetry=off` - Disable telemetry
235239
- `--telemetry=<list>` - Per-category config, e.g. `--telemetry=network=on,page=off`

cmd/browsers.go

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,12 @@ type BrowsersUpdateInput struct {
298298
Viewport string
299299
Force bool
300300
Telemetry string
301+
Name string
302+
SetName bool
303+
ClearName bool
304+
Tags map[string]string
305+
SetTags bool
306+
ClearTags bool
301307
Output string
302308
}
303309

@@ -697,9 +703,37 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error {
697703
return fmt.Errorf("cannot specify both --proxy-id and --clear-proxy")
698704
}
699705

706+
// Cannot specify both --name and --clear-name
707+
if in.SetName && in.ClearName {
708+
return fmt.Errorf("cannot specify both --name and --clear-name")
709+
}
710+
711+
// Cannot specify both --tag and --clear-tags. Use SetTags (the raw flag
712+
// signal) as well as the parsed map so the check still fires when every
713+
// --tag value was malformed and dropped to an empty map.
714+
if (in.SetTags || len(in.Tags) > 0) && in.ClearTags {
715+
return fmt.Errorf("cannot specify both --tag and --clear-tags")
716+
}
717+
718+
// --tag was provided but parsed to zero valid pairs (every value malformed).
719+
// Treat as a user error rather than silently leaving tags unchanged.
720+
if in.SetTags && len(in.Tags) == 0 {
721+
return fmt.Errorf("no valid --tag KEY=VALUE pairs provided")
722+
}
723+
724+
// --name must carry a value; clearing is done explicitly via --clear-name.
725+
// (A set name combined with --clear-name is already rejected above, so the
726+
// ClearName case cannot reach here.)
727+
if in.SetName && in.Name == "" {
728+
return fmt.Errorf("--name requires a non-empty value; use --clear-name to clear the name")
729+
}
730+
700731
hasProxyChange := in.ProxyID != "" || in.ClearProxy
701732
hasProfileChange := in.ProfileID != "" || in.ProfileName != ""
702733
hasViewportChange := in.Viewport != ""
734+
// By this point a set name is guaranteed non-empty (the guard above rejects --name "").
735+
hasNameChange := in.SetName || in.ClearName
736+
hasTagsChange := len(in.Tags) > 0 || in.ClearTags
703737

704738
// Validate --save-changes is only used with a profile
705739
if in.ProfileSaveChanges.Set && !hasProfileChange {
@@ -712,12 +746,27 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error {
712746
}
713747

714748
// Validate that at least one update option is provided
715-
if !hasProxyChange && !hasProfileChange && !hasViewportChange && in.Telemetry == "" {
716-
return fmt.Errorf("must specify at least one of: --proxy-id, --clear-proxy, --profile-id, --profile-name, --viewport, or --telemetry")
749+
if !hasProxyChange && !hasProfileChange && !hasViewportChange && in.Telemetry == "" && !hasNameChange && !hasTagsChange {
750+
return fmt.Errorf("must specify at least one of: --proxy-id, --clear-proxy, --profile-id, --profile-name, --viewport, --telemetry, --name, --clear-name, --tag, or --clear-tags")
717751
}
718752

719753
params := kernel.BrowserUpdateParams{}
720754

755+
// Handle name changes
756+
if in.ClearName {
757+
params.Name = kernel.Opt("")
758+
} else if in.SetName {
759+
params.Name = kernel.Opt(in.Name)
760+
}
761+
762+
// Handle tag changes. Tags are a full replace, not a merge: providing --tag
763+
// replaces the entire set, and --clear-tags removes all tags.
764+
if in.ClearTags {
765+
params.Tags = kernel.Tags{}
766+
} else if len(in.Tags) > 0 {
767+
params.Tags = kernel.Tags(in.Tags)
768+
}
769+
721770
// Handle proxy changes
722771
if in.ClearProxy {
723772
params.ProxyID = kernel.Opt("")
@@ -781,6 +830,12 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error {
781830
}
782831

783832
pterm.Success.Printf("Updated browser %s\n", browser.SessionID)
833+
if hasNameChange {
834+
pterm.Info.Printf("Name: %s\n", util.OrDash(browser.Name))
835+
}
836+
if hasTagsChange {
837+
pterm.Info.Printf("Tags: %s\n", util.OrDash(formatTags(browser.Tags)))
838+
}
784839
if in.Telemetry != "" {
785840
printTelemetrySummary(browser.Telemetry)
786841
}
@@ -2338,8 +2393,12 @@ Supported operations:
23382393
- Load a profile into a session that doesn't have one (--profile-id or --profile-name)
23392394
- Change viewport dimensions (--viewport)
23402395
- Force viewport resize during active live view or recording (--force with --viewport)
2396+
- Rename or clear the session name (--name or --clear-name)
2397+
- Replace or clear the session tags (--tag or --clear-tags)
23412398
2342-
Note: Profiles can only be loaded into sessions that don't already have a profile.`,
2399+
Notes:
2400+
- Profiles can only be loaded into sessions that don't already have a profile.
2401+
- --tag replaces the entire tag set (it is not merged with existing tags).`,
23432402
Args: func(cmd *cobra.Command, args []string) error {
23442403
if len(args) == 0 {
23452404
return fmt.Errorf("missing required argument: browser ID or name\n\nUsage: kernel browsers update <id-or-name> [flags]")
@@ -2379,6 +2438,10 @@ func init() {
23792438
browsersUpdateCmd.Flags().String("viewport", "", "Browser viewport size (e.g., 1920x1080@25). Supported: 2560x1440@10, 1920x1080@25, 1920x1200@25, 1440x900@25, 1024x768@60, 1200x800@60, 1280x800@60")
23802439
browsersUpdateCmd.Flags().Bool("force", false, "Force viewport resize even when a live view or recording/replay is active")
23812440
browsersUpdateCmd.Flags().String("telemetry", "", "Update telemetry: --telemetry=all (reset to default set), --telemetry=off (disable), or --telemetry=console,network (merge those categories into the current selection)")
2441+
browsersUpdateCmd.Flags().String("name", "", "Set a new unique name for the browser session (mutually exclusive with --clear-name)")
2442+
browsersUpdateCmd.Flags().Bool("clear-name", false, "Clear the browser session name")
2443+
browsersUpdateCmd.Flags().StringArray("tag", nil, "Set a tag KEY=VALUE (repeatable; up to 50 pairs). Replaces the entire tag set; mutually exclusive with --clear-tags")
2444+
browsersUpdateCmd.Flags().Bool("clear-tags", false, "Remove all tags from the browser session")
23822445

23832446
browsersCmd.AddCommand(browsersListCmd)
23842447
browsersCmd.AddCommand(browsersCreateCmd)
@@ -2645,7 +2708,7 @@ func init() {
26452708
browsersCreateCmd.Flags().String("pool-id", "", "Browser pool ID to acquire from (mutually exclusive with --pool-name)")
26462709
browsersCreateCmd.Flags().String("pool-name", "", "Browser pool name to acquire from (mutually exclusive with --pool-id)")
26472710
browsersCreateCmd.Flags().String("telemetry", "", "Configure telemetry (opt-in): --telemetry=all (default set), --telemetry=off (disable), or --telemetry=console,network (capture exactly those categories)")
2648-
browsersCreateCmd.Flags().String("name", "", "Optional unique name for the browser session (used to find it later; set at creation only)")
2711+
browsersCreateCmd.Flags().String("name", "", "Optional unique name for the browser session (used to find it later; can be changed with 'browsers update --name')")
26492712
browsersCreateCmd.Flags().StringArray("tag", nil, "Set a tag KEY=VALUE on the session (repeatable; up to 50 pairs)")
26502713
browsersCreateCmd.Flags().String("chrome-policy", "", "Custom Chrome enterprise policy as a JSON object")
26512714
browsersCreateCmd.Flags().String("chrome-policy-file", "", "Read Chrome enterprise policy (JSON object) from a file (use '-' for stdin)")
@@ -2921,6 +2984,10 @@ func runBrowsersUpdate(cmd *cobra.Command, args []string) error {
29212984
viewport, _ := cmd.Flags().GetString("viewport")
29222985
force, _ := cmd.Flags().GetBool("force")
29232986
telemetry, _ := cmd.Flags().GetString("telemetry")
2987+
name, _ := cmd.Flags().GetString("name")
2988+
clearName, _ := cmd.Flags().GetBool("clear-name")
2989+
tags := tagsFromFlag(cmd, "tag")
2990+
clearTags, _ := cmd.Flags().GetBool("clear-tags")
29242991

29252992
svc := client.Browsers
29262993
b := BrowsersCmd{browsers: &svc}
@@ -2934,6 +3001,12 @@ func runBrowsersUpdate(cmd *cobra.Command, args []string) error {
29343001
Viewport: viewport,
29353002
Force: force,
29363003
Telemetry: telemetry,
3004+
Name: name,
3005+
SetName: cmd.Flags().Changed("name"),
3006+
ClearName: clearName,
3007+
Tags: tags,
3008+
SetTags: cmd.Flags().Changed("tag"),
3009+
ClearTags: clearTags,
29373010
Output: out,
29383011
})
29393012
}

0 commit comments

Comments
 (0)