Repository navigation
Shared Go SDK for the CLI, MCP server and Webex Admin Studio - #20
Conversation
…rograms - pkg/webexapi: the CLI's HTTP transport as a public package with no process state (401 refresh, 429 retry, structured errors, fail-closed paging). internal/client now sends through it. - pkg/changeset and --changeset: record write commands as a portable change set file instead of sending them, with `webex changeset` new/show/validate/intent/schema and the webex_propose and webex_changeset MCP tools. - auth token --min-ttl and JSON output for auth token, status and list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
auth status now resolves the requested user once, for both text and JSON output. Successful MCP calls return stderr as a separate text item for every tool, which also covers change set warnings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
Every command's name, aliases, help and flags, and the exact request each API command sends for a fixed set of flags (all flags, required flags only, --body, --paginate), answered by a recording transport with no network. Rebuilding how commands are made must leave these files unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
Hosts gives the base URL of every Webex API for a Contact Center data center and a data region. The pacer, from Webex Admin Studio, spaces Contact Center requests below the rate Webex refuses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
codegen now writes pkg/webexops: one exported *Operation per API call, with its route, parameters, body fields, paging and CLI names, and a Call that sends it through webexapi. internal/apicmd builds the cobra commands from that table at startup, replacing about 82,000 lines of generated command code with about 31,000 lines of data. Every command, flag, help text and request matches the golden files unchanged. make test runs the Go and codegen tests, and make golden rewrites the golden files; the weekly collection refresh runs it so its PR shows command changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
--cc-region (or WEBEX_CC_REGION, or `webex config set cc-region`) picks the Contact Center and AI Agent Studio hosts from webexapi.Hosts, which were fixed to us1. Every request in a process now shares a pacer that keeps Contact Center below the rate it refuses. Base URLs and org ID conversion come from the SDK. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
Each operation carries its Go name as ID. Seven Contact Center list routes that Webex Admin Studio reads, and that Cisco's collection replaced with newer versions, come from a supplemental collection as `list-v1` (`list-v2` for queues). The golden files gain those seven commands and nothing else. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
pkg/resource defines object types as data, pkg/catalog lists the types, pkg/webex reads and writes them, pkg/guard is the write protocol (conflict check, read-back, stop after a failure), and pkg/fakeapi is the fake Webex the tests run against. They come from Webex Admin Studio unchanged in behavior, with two differences: - Each type names the pkg/webexops operations it uses for list, create, read, update and delete, instead of URL templates. Catalog tests check each operation's method and host and that every path parameter is bound. Route.Template gives the old template form for change set files. - Presentation (sidebar folder, icon, columns, form sections) is left to Studio. Definitions hold API facts only. The catalog was compared field by field with Studio's before the move, and Studio's catalog output for its app is byte-for-byte the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
webex resource list-types, describe, list, get, set, create and delete work from the SDK catalog. set reads the object and sends only the named fields' changes, as the whole object where the route replaces it (every Contact Center PUT), so fields the command does not name survive. Writes go through the write guard: a conflict check, a read-back of the result, and no retry after a failure that may have committed. Field values are checked against the catalog (known field, kind, options, immutability). --changeset records set, create and delete like other writes, and --dry-run prints them. MCP tool descriptions point agents at resource set for changing objects. internal/client.SDK gives SDK packages a transport set up from the CLI's flags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
pkg/aiagent looks up an agent's runtime token from the v2 agent list, redacts Studio credentials from responses, and Call sends a private Studio operation with both done. The CLI's internal/aiagent keeps only the one-time private-API notice and calls the SDK with this run's login, org and hosts. Requests are unchanged (golden files pass). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
README and the docs site describe the Go SDK packages and the resource commands, with new Resource Commands and Go SDK pages. The skill tells agents to change objects with resource set rather than a raw update that erases what it leaves out. --cc-region joins the global flag lists, and AGENTS.md lists the SDK packages and their boundary with the CLI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
webexapi.List reads Contact Center lists with any page size parameter and offset lists with any parameter names, SCIM's 1-based startIndex and totalResults, or Link headers only, finding the items array when a route does not name it. A list that cannot be read to its end still fails. webexops.List pages an operation the way its table entry says, and aiagent.BotToken now uses it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
The golden files only protect the published commands if they are checked on every pull request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
edumansky
left a comment
There was a problem hiding this comment.
Codex review (gpt-6-astra, reasoning effort: high)
The existing Go and codegen tests pass, but targeted checks reproduce wrong-organization recording, lost edits, invalid dependencies, and broken resource capture workflows. Live validation also reports success when verification cannot complete.
| err = changeset.Edit(path, newChangeSet, func(cs *changeset.ChangeSet) error { | ||
| if org := config.OrgID(); org != "" && cs.Scope.OrgID != org { | ||
| return fmt.Errorf("%s is for org %s, but this command is for org %s", path, cs.Scope.OrgID, org) | ||
| } |
There was a problem hiding this comment.
[P1] Validate the request's actual organization before recording
When a command supplies --orgid or --org-id different from the configured organization, those flags change the request but not config.OrgID(). This check therefore accepts the wrong scope, while captureTarget removes the request's organization. For example, a skill creation targeting organization B is recorded as a creation in organization A. Derive and validate the effective request organization before stripping it, or reject conflicting overrides.
There was a problem hiding this comment.
Valid. Fixed in 1f99f93: captureTarget reads the request's own org (the Contact Center {orgid} path parameter, or the orgId query decoded to a UUID) and refuses the request when it differs from the --organization org that the change set records for. Regression test: TestCaptureRefusesARequestForAnotherOrg (a CC create and a Calling update for another org are both refused, and nothing is recorded).
| changes := changeset.Leaves(fields) | ||
| if current != nil { | ||
| changes = changeset.Diff(current, fields) | ||
| } | ||
| if len(changes) == 0 { | ||
| return map[string]any{"recorded": nil, "unchanged": true}, nil | ||
| } | ||
| id := cs.AddUpdate(target, changes) |
There was a problem hiding this comment.
[P2] Honor edits that restore a pending field's live value
If the live name is Desk, recording Front and then recording Desk reports the second command as unchanged but leaves Front in the change set. Comparing only against live state discards the second edit before it can replace the pending value, violating the latest-value-wins behavior. Reconcile explicitly supplied fields with existing changes before dropping unchanged values, removing pending changes that have been reverted.
There was a problem hiding this comment.
Valid, with one limit. Fixed in 1f99f93: an update now withdraws the pending change at any path it explicitly sets back to the live value (ChangeSet.Withdraw), and drops the operation if nothing is left. "Explicitly" means every field of a Webex Calling body, and for Contact Center the paths the caller names with Request.Sets, which resource set does for its fields. A raw Contact Center PUT carries the whole object as read, so a field it repeats from Webex is not taken as a revert: otherwise recording capacity=4 from a fresh read would silently undo an earlier name change. Regression tests: TestCaptureSettingAFieldBackWithdrawsItsPendingChange, TestCaptureWholeObjectPutKeepsEarlierChanges, TestResourceSetBackToTheLiveValueWithdrawsOnlyThatChange, TestWithdrawRemovesChangesAndEmptiedUpdates.
| func (cs *ChangeSet) AddUpdate(target Target, changes []Change) string { | ||
| if op := cs.find(target.Key()); op != nil && op.Action == Update { | ||
| for _, c := range changes { | ||
| mergeChange(op, c) | ||
| } |
There was a problem hiding this comment.
[P2] Preserve dependency order when merging repeated updates
If an existing object is updated as op-1, a new object is created as op-2, and another update references that new object's handle, merging into op-1 creates a forward reference. All recording commands succeed, but changeset.Validate then rejects the file because references must name earlier creates. Reorder merged operations after their dependencies, or otherwise preserve valid dependency ordering.
There was a problem hiding this comment.
Valid. Fixed in 1f99f93: after a merge, AddUpdate moves the update to just after the last create its new values refer to. UpdateCreate had the same problem when a pending create came to refer to a later create, so it does the same, and refuses (changing nothing) when an operation it would move past refers to the create being moved. Regression tests: TestAnUpdateThatComesToReferALaterCreateMovesAfterIt, TestACreateThatComesToReferALaterCreateMovesAfterIt (both check Validate).
| Unreadable []string `json:"unreadable,omitempty"` | ||
| } | ||
|
|
||
| func (r LiveReport) OK() bool { return len(r.Drift) == 0 && len(r.Missing) == 0 } |
There was a problem hiding this comment.
[P2] Fail live validation when objects cannot be read
When live reads fail with authentication errors, HTTP 500s, or network failures, CheckLive populates Unreadable, but this predicate still returns true. Consequently, changeset validate --live emits "valid": true and exits successfully even when none of its preconditions were checked. Include unreadable targets in the failure condition so callers can distinguish successful verification from an unavailable check.
There was a problem hiding this comment.
Valid. Fixed in 1f99f93: LiveReport.OK also requires Unreadable to be empty, so changeset validate --live reports "valid": false when it could not check an object. Regression test: TestLiveCheckIsNotOKWhenAnObjectCannotBeRead. The Change Sets page says so.
| t.Route = req.path | ||
| if t.API == changeset.APIContactCenter { | ||
| if m := ccOrgPrefix.FindStringSubmatch(t.Route); m != nil { | ||
| t.Route = t.Route[len(m[0]):] | ||
| } |
There was a problem hiding this comment.
[P2] Reject or represent Contact Center routes outside the org prefix
Commands such as cc flow patch-draft use /{orgid}/project/{projectId}/v2/flows/{flowId}, so this branch retains the entire path while labeling it as an organization-relative cc target. readTarget subsequently prepends /organization/{orgid}, producing a nonexistent URL and reporting an existing flow as missing. Either support host-relative routes throughout the format and reader or reject these requests instead of recording an unusable target.
There was a problem hiding this comment.
Valid. Fixed in 1f99f93 by rejecting: the format defines Contact Center routes as relative to /organization/{orgId} (pkg/changeset/types.go), so captureTarget now refuses a CC route without that prefix, such as cc flow patch-draft, instead of recording a target that reads back wrong. Regression test: TestCaptureRefusesContactCenterRoutesOutsideTheOrgPath. README, skill and site list it as not recordable.
| key, err := objectKey(ctx, c, t, args[1]) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| fresh, err := c.Read(ctx, t, key) | ||
| if err != nil { | ||
| return readError(t, key, err) |
There was a problem hiding this comment.
[P2] Handle pending-create IDs before reading resource state
After resource create cc_skill ... --changeset file returns a placeholder such as new:review, resource set cc_skill new:review name=Revised --changeset file attempts to read that placeholder from Webex and fails with not found. It never reaches the capture logic that folds updates into pending creates. Detect placeholder IDs in capture mode before the live read and merge the supplied fields into the recorded create.
There was a problem hiding this comment.
Valid. Fixed in 1f99f93: with --changeset, resource set on a new:<handle> ID skips the live read and sends the named fields as a patch, which capture folds into the recorded create. Regression test: TestResourceSetOfARecordedCreateFoldsIntoIt.
| if config.ChangesetPath() != "" || config.DryRun() { | ||
| parent, _ := desired[t.ParentField].(string) | ||
| return sendRaw(t.Routes.Create, resource.Key{Parent: parent}, t.CreateBody(desired)) |
There was a problem hiding this comment.
[P2] Include copied create fields in captured resource requests
There was a problem hiding this comment.
Valid. Fixed in 1f99f93: the SDK now has webex.Client.CreateBody, which Create itself uses and which applies CopyOnCreate. The capture and dry-run path of resource create calls it, so the recorded body is the one the SDK would send. Regression test: TestResourceCreateRecordsTheBodyTheSDKWouldSend (a desktop layout records the global layout's jsonFileContent).
- Refuse a request whose org differs from the change set's, instead of
recording it under the wrong org.
- Refuse Contact Center routes outside /organization/{orgid}, which the
change set format cannot name.
- Setting a field back to its live value withdraws its pending change, for
Webex Calling bodies and the fields named to resource set. Fields a
whole-object Contact Center PUT repeats leave earlier changes in place.
- Move an update or create after a later create it comes to refer to, so
merged edits keep references pointing at earlier creates.
- validate --live is not valid when an object could not be read.
- resource set on a new:<handle> folds into the recorded create, and
resource create records the body the SDK would send, including the copied
desktop layout file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
Codex review: outcomeCodex (
README, the skill and the Change Sets page describe the new recording rules. Checks
🤖 Generated with Claude Code |
- webex_propose refuses "--", after which the server's --changeset flag would be an argument and the write would be sent, and accepts only write commands of the API areas and resource. - The SDK pagers fail instead of looping when a page of any size repeats, and the object client pages each list as the operation table says. - --paginate reads past a full bare-array Contact Center page. - A DELETE that names no object is recorded as an invoke with its body. - Change sets check org IDs in Webex paths, refuse an update to an object they delete, and merge updates past invokes on the same object. - resource set records against the object it read, for types whose item route cannot be read. - Parallel 401s refresh the token once, without a data race. - --dry-run records nothing in a change set. - A null field no longer counts as an array when finding a list's items. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
edumansky
left a comment
There was a problem hiding this comment.
Fable review (fable, reasoning effort: high)
Review of PR #20 at 1f99f93 (git diff origin/main...1f99f93). All reproductions were throwaway tests against pkg/fakeapi or httptest stubs; no live Webex calls.
P0: none found.
[P2] --paginate on the new cc … list-v1 commands stops at 100 items — internal/client/pagination.go:170 (outside this PR's diff); commands added by codegen/naming_overrides.py:28-36 and codegen/supplemental/Contact Center lists.postman_collection.json
- The CLI pager treats a bare array as the only page. The v1 site, user, team, skill, entry-point and business-hours lists return bare arrays, per
pkg/fakeapiand the comment atpkg/webexapi/pager.go:55. - Reproduced with 151 skills in the fake:
cc skill list-v1 --paginatereturned 100 with no error, whileresource list cc_skillreturned 151. - I did not confirm the bare-array shape on a live tenant.
Checked, no finding
- Request building (
internal/apicmd,webexops): I generated the golden files from origin/main's generated commands using the PR's test harness. All sixrequests-*.jsonfiles and the API part ofcommands.jsonmatch the PR's, apart from the seven newlist-v1/list-v2commands. The goldens do not exercise--last,--body-file, or pages after the first; I checked those by reading only. - Python codegen: re-running
extract_api_spec.py,generate_cli.pyandgenerate_skills.pyon a copy of 1f99f93 reproduces the committedpkg/webexops/zz_*.goand skill files exactly. The codegen unit tests pass onceapi_spec.jsonexists;make teston a fresh clone fails until it does. - Contact Center host selection, token redaction in debug and dry-run output, read-only and dry-run gating of
resource set/create/delete, guard full-PUT bodies: no defect found.
| if errText != "" { | ||
| return mcplib.NewToolResultText(errText), nil | ||
| } | ||
| args = append(args, "--changeset="+path) |
There was a problem hiding this comment.
[P1] webex_propose sends the write live when the command contains a -- token
(args built at :192)
- The handler splits
commandon whitespace and then appends--changeset=<path>and--output=json. - With command
cc team delete --id=team1 --, cobra treats everything after--as positional arguments. Leaf commands accept extra positionals, so capture mode is never turned on. - Result: the DELETE goes to Webex from a tool described as "Nothing is sent to Webex". Clients may auto-approve that tool.
- A read-only server is not affected, because the child process still refuses writes.
- Reproduced at the CLI level, not through an MCP client: running that exact argument list against the fake committed
DELETE /organization/org1/team/team1and created no change set file.
There was a problem hiding this comment.
Valid. Fixed in 87b5064: the dispatcher refuses a -- token in any tool, so the flags it appends can never become arguments. Regression test: TestProposeRefusesWhatItCannotRecord (cc team delete --id=team1 --).
| id, err := cs.RemoveCreate(handle) | ||
| return map[string]any{"recorded": id, "action": "removed create"}, err | ||
|
|
||
| case method == http.MethodDelete: |
There was a problem hiding this comment.
[P2] Bulk DELETEs are recorded without their body, merged together, and leave an invalid file
Also pkg/changeset/record.go:221.
- Every non-
new:DELETE becomesAddDelete(target), which drops the body. - 21 DELETE operations have routes that do not end in an ID, for example
calling numbers delete-phone-locationandcalling call-queue delete-bulk-supervisors. - Reproduced: two
delete-phone-location --phone-numbers ... --changeset fruns with different numbers both printed{"action":"delete","recorded":"op-1"}. - The file held one delete on
/telephony/config/locations/{locationId}/numberswith no numbers. changeset validatethen failed with "the target route of a delete must end with the object's ID".
There was a problem hiding this comment.
Valid. Fixed in 87b5064: a DELETE to a route that names no object (target.ID() empty) is now recorded as an invoke with its body, so two runs make two operations and the file validates. Regression test: TestCaptureBulkDeleteIsAnInvokeWithItsBody.
| if len(items) == ccPageSize && bytes.Equal(items[0], previousFirst) { | ||
| return fmt.Errorf("%w: %s repeated page %d", ErrIncompleteList, listURL, page) | ||
| } | ||
| if len(items) > 0 { | ||
| if err := visit(items); err != nil { | ||
| return err | ||
| } | ||
| previousFirst = items[0] | ||
| } | ||
| switch { | ||
| case totalPages > 0 && page+1 >= totalPages: | ||
| return nil | ||
| case totalPages == 0 && len(items) < ccPageSize: | ||
| return nil | ||
| } |
There was a problem hiding this comment.
[P2] The SDK pager loops forever when a page holds more than 100 items, and the catalog pages no-paging endpoints with start/max
Also :164-181; callers pkg/webex/webex.go:206, :352.
- The repeated-page check only fires when
len(items) == 100. A response of 150 items is neither short nor a repeat, so the loop never ends. - Reproduced with a stub that always returns 150 items:
ListOffsetandListCCeach made 21 requests and visited 3150 items before the test stopped them. pkg/webexcallsListOffsetwith fixed start/max and ignores the operation's ownPaging. The table marksdectNetworks,cxEssentials/wrapup/reasons,announcements/playlists,announcementLanguagesandlineKeyTemplatesas having no paging parameters, and/peopleand/locationsas Link-only.- Consequence if those endpoints ignore start/max (not checked live):
webex resource list dect_networkhangs with growing memory once an org has more than 100. - Related, reproduced: an endpoint that ignores
start, holds exactly 100 items and sends no Link header fails withincomplete list … repeated a page.
There was a problem hiding this comment.
Valid. Fixed in 87b5064, in two parts. Both pagers now treat any page that starts with the previous page's first item as a repeat, whatever its size, so an endpoint that ignores the paging parameters gives ErrIncompleteList instead of a loop. And pkg/webex lists each type with the paging the operation table records: the five lists with no paging parameters are read in one request (plus Link headers), and /people and /locations page by Link alone. Every other catalog list still uses start/max. Regression tests: TestPagersFailRatherThanLoopOnARepeatedOversizedPage, TestAListWithoutPagingParametersIsReadInOneRequest.
| if !isNew && req.method != http.MethodPost { | ||
| if current, err = fetchCurrent(req); err != nil { | ||
| fmt.Fprintf(os.Stderr, "Warning: could not read %s (%v); recording without preconditions\n", target.Path(), err) | ||
| } else { |
There was a problem hiding this comment.
[P2] resource set cc_work_type … --changeset records the whole object as changes, with no preconditions
Also :106; reached from cmd/resource.go:187-192.
resource setsends the full-object body through capture, which re-reads the item route to diff against.- Work type item reads return 406 (
pkg/catalog/center.go:391), so the diff falls back toLeaves(body). - Reproduced with a 406 stub: setting
description=zz-newrecordedactive,description,id,lastUpdatedTime,nameandworkTypeCode, all withoutbefore. - The same happens for any full-PUT type whenever that re-read fails; the only signal is a stderr warning.
There was a problem hiding this comment.
Valid. Fixed in 87b5064: client.Request.Current gives capture the object the caller already read, and resource set passes the object it read through the SDK, so capture no longer re-reads the item route. Regression tests: TestCaptureDiffsAgainstTheObjectTheCallerRead, TestResourceSetRecordsOnlyItsChangesWhenTheItemRouteCannotBeRead (the second read fails, as for work types). A raw command whose re-read fails still records without preconditions, with the existing stderr warning.
| var org string | ||
| t.Route = req.path | ||
| if t.API == changeset.APIContactCenter { | ||
| m := ccOrgPrefix.FindStringSubmatch(t.Route) | ||
| if m == nil { | ||
| return t, fmt.Errorf("%s is not under /organization/{orgid}, so it cannot be recorded in a change set", req.path) | ||
| } | ||
| org = req.pathParams[m[1]] | ||
| t.Route = t.Route[len(m[0]):] | ||
| if t.Route == "" { | ||
| t.Route = "/" | ||
| } | ||
| } | ||
| for k, v := range req.queryParams { | ||
| if strings.EqualFold(k, "orgId") { | ||
| org = config.DecodeOrgID(v) | ||
| } | ||
| } | ||
| if org != "" && org != config.OrgID() { | ||
| return t, fmt.Errorf("this request is for org %s, but change sets record changes for the --organization org (%s)", org, config.OrgID()) | ||
| } |
There was a problem hiding this comment.
[P2] The change-set org check misses org IDs in Webex paths
- The check covers the Contact Center path org and an
orgIdquery, but not{orgId}/{orgid}in webexapis.com paths. 31 write operations have one, such as/identity/organizations/{orgId}/…and/contacts/organizations/{orgid}/contacts/{contactId}. - Reproduced:
admin org-contacts update --orgid 9999… --organization 1111… --changeset frecorded an update withparams.orgid = 9999…in a file whosescope.orgIdis1111….
There was a problem hiding this comment.
Valid. Fixed in 87b5064: every path or query parameter named orgId (any case) must decode to the --organization org. Regression test: TestCaptureRefusesAnotherOrgInAWebexPath.
| // already in the change set merge into its operation: the latest value wins and | ||
| // the first precondition is kept. It returns the operation's ID. | ||
| func (cs *ChangeSet) AddUpdate(target Target, changes []Change) string { | ||
| if i := cs.index(target.Key()); i >= 0 && cs.Operations[i].Action == Update { |
There was a problem hiding this comment.
[P3] An update recorded after a delete of the same object appends duplicates
(index() at :284)
index()returns the first non-create operation on the target. When that is a delete or an invoke,AddUpdateappends a new update every time and never merges.- Reproduced:
resource delete cc_team team1, then tworesource setruns, gave op-1 delete, op-2 update and op-3 update, each reported as recorded.changeset validatethen failed.
There was a problem hiding this comment.
Valid. Fixed in 87b5064: AddUpdate now returns an error for an object the change set deletes, and index finds only the update or delete of an object, so updates merge past an invoke on the same route. Regression tests: TestAnUpdateToADeletedObjectIsRefused, TestAnUpdateMergesPastAnInvokeOnTheSameObject.
| if err != nil { | ||
| return err | ||
| } | ||
| config.SetToken(newToken) |
There was a problem hiding this comment.
[P3] Data race on the token, and one refresh per goroutine, when parallel reads get a 401
(also client.go:106), from pkg/webex/webex.go:299
resolveruns 10 reads at once; each 401 callsRefresh, which callsconfig.SetTokenunsynchronized while others readconfig.Token().- Reproduced under
go test -race:resource list hunt_groupwith three injected 401s reported a data race inconfig.SetTokenand ran the refresher 3 times.
There was a problem hiding this comment.
Valid. Fixed in 87b5064: config.Token/SetToken are guarded by a lock, and both transports share one refreshToken. It serializes refreshes, and a 401 within 30 seconds of a refresh retries with the new token instead of refreshing again. Regression test: TestConcurrent401sRefreshTheTokenOnce (5 parallel 401s, one refresh, clean under -race).
| return body, status, headers, err | ||
| // Capture runs before read-only checks: it never writes to Webex, so a | ||
| // read-only login can propose changes. | ||
| if path := config.ChangesetPath(); path != "" && !readOnlyAllows(req.method, req.path) { |
There was a problem hiding this comment.
[P3] --dry-run is ignored when --changeset is set
- Capture runs before the dry-run check. Reproduced:
resource set cc_team team1 name=zz-dry --changeset f --dry-runcreated the file with op-1.
There was a problem hiding this comment.
Valid. Fixed in 87b5064: capture is skipped in a dry run, so the request is printed and nothing is recorded. Regression test: TestDryRunWithAChangeSetRecordsNothing. The Change Sets page says so.
| // Proposing never writes to Webex, so it is offered in read-only mode too. | ||
| s.AddTool( | ||
| mcplib.NewTool("webex_propose", | ||
| mcplib.WithDescription( | ||
| "Record a Webex CLI write command (create, update, delete) in a change set file instead of running it. "+ | ||
| "Nothing is sent to Webex. The CLI reads the object, records only the fields the command changes, "+ | ||
| "and keeps the values it saw as preconditions. A create returns a placeholder ID like \"new:sales-team\" "+ | ||
| "that later proposals can use in flags or bodies. Start the file with webex_changeset action \"new\", "+ | ||
| "then give the file to the person, who can review and apply it in Webex Admin Studio.", | ||
| ), | ||
| mcplib.WithString("changeset", | ||
| mcplib.Required(), | ||
| mcplib.Description("Path of the change set file, ending in .json (e.g. /path/to/boulder.changeset.json)"), | ||
| ), | ||
| mcplib.WithString("command", | ||
| mcplib.Required(), | ||
| mcplib.Description("Write CLI command without 'webex' prefix (e.g. 'calling hunt-group update', 'cc team create')"), | ||
| ), | ||
| mcplib.WithString("flags", | ||
| mcplib.Description(`Optional flags as a JSON object string (e.g. {"location-id": "xxx", "hunt-group-id": "yyy", "body": "{\"name\":\"Front Desk\"}"}). Keys are flag names without '--'.`), | ||
| ), | ||
| ), |
There was a problem hiding this comment.
[P3] webex_propose runs any non-blocked command, including on read-only servers
Also :218-227.
- Propose mode has no action check. A read-only server previously exposed only read actions; it now reaches
auth token(prints the access token, as JSON because--output=jsonis appended) andauth set-folder-default/clear-folder-default. - Not executed; from reading the code.
There was a problem hiding this comment.
Valid. Fixed in 87b5064: webex_propose accepts only commands under admin, calling, cc, device, meetings, messaging or resource that resolve to a write action. auth token, auth set-folder-default, reads and unresolved commands are refused. Regression test: TestProposeRefusesWhatItCannotRecord.
| switch { | ||
| case totalPages > 0 && page+1 >= totalPages: | ||
| return nil | ||
| case totalPages == 0 && len(items) < ccPageSize: |
There was a problem hiding this comment.
[P3] Places the SDK list can end short or fail on a valid response
pkg/webexapi/pager.go:90: a Contact Center bare array, or{data}withouttotalPages, shorter than 100 ends the list. Reproduced: a stub capping pages at 50 with 100 items returned 50 and no error.pkg/webex/webex.go:170: a 400, 403 or 404 on one location's list silently drops that location from a per-location type. From reading the code.pkg/webexapi/pager.go:212:nullcounts as an array when probing for the only array. Reproduced:{"objects":[…],"next":null}fails with "response has several arrays". This path serves thelimit/offsetlists, includingBotToken.
There was a problem hiding this comment.
Partly valid. The null case is fixed in 87b5064: a null field no longer counts as an array (TestOffsetPagerIgnoresNullFieldsWhenFindingTheArray). I left the other two. (1) A short Contact Center page ends the list only when the response has no totalPages, and only if the server silently returns fewer items than the pageSize asked for. Nothing in the collections or our live testing shows a Contact Center list doing that, but our live tenants have not had more than 100 objects of a type either, so this is unproven in both directions; I would rather not guess a smaller page size. (2) A 400, 403 or 404 on one location's list means that location cannot have the feature (for example, it is not a Calling location). Skipping it is how per-location types list the org; a 5xx or network failure still fails the list.
Fable review: outcomeFable (high effort) reviewed
README, the skill and the Change Sets page describe the changed rules. Checks
🤖 Generated with Claude Code |
Contact Center answers 412 for a feature the org does not have, such as Generated Summaries, so a snapshot skips that type instead of failing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
A field can now state the text it accepts, as a pattern with the rule in words. A Contact Center global variable's name carries one: Webex takes only ASCII letters, digits and underscores, up to 80 characters. A skill's name is fixed once the skill exists, because Webex refuses a PUT that changes it. Create and update check both rules before any request, as resource create and set do while reading their arguments, so Studio's applies are covered too. A value refused this way is a definite rejection, not an unknown outcome: nothing was written. A field fixed at creation can still be set on an object that exists only in a change set, where it goes into the create. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcXCiUuYGW54Cn7uwC
- A change set target's query is part of its identity, so updates to two users' meeting preferences stay separate operations. - Capture and the live check read the object with the request's query, which can select it. - A POST is recorded as a create only when the API has a route to one object under it; call control and other actions are invokes. - Change set files are created owner-only and keep an existing file's mode. - SDK paging sends the caller's headers on every page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
edumansky
left a comment
There was a problem hiding this comment.
Codex review (gpt-6-astra, reasoning effort: high)
The Go and Python test suites pass, but uncovered cases can merge proposals for different objects, discard intended changes, expose recorded credentials, and break SDK requests requiring headers.
| func (t Target) Key() string { | ||
| parts := []string{t.API, NormalizeRoute(t.Route)} | ||
| for _, name := range Placeholders(t.Route) { | ||
| parts = append(parts, t.Params[name]) | ||
| } | ||
| return strings.Join(parts, "\x00") |
There was a problem hiding this comment.
[P1] Include query selectors in change-set target identity
When two updates select different objects through query parameters, Target.Key() treats them as the same target. For example, recording /meetingPreferences/audio updates for two different userEmail values merges both into one operation, retaining the first user's query but applying the second user's changes. Include identifying query parameters in the target key so proposals cannot overwrite changes intended for another object.
There was a problem hiding this comment.
Valid, fixed in 98c826a. Target.Key() now ends with the query parameters, sorted by name, so two userEmail values are two objects and their updates stay separate operations. orgId never reaches Target.Query (capture checks it against the change set's org instead), so the org does not split targets. Regression test: TestTargetsThatDifferOnlyInTheirQueryAreDifferentObjects.
| for k, v := range req.queryParams { | ||
| if strings.EqualFold(k, "orgId") { | ||
| get.QueryParam(k, v) | ||
| } |
There was a problem hiding this comment.
[P1] Preserve target selectors when reading change-set baselines
For commands such as meetings preferences update-audio-options --user-email ..., dropping every query parameter except orgId reads the authenticated user's preferences instead of the requested user's. If the requested value matches the authenticated user's value, capture incorrectly reports unchanged and records nothing; otherwise it records the wrong precondition. Preserve the target-selecting parameters here and in readTarget, which also omits Target.Query during live validation.
There was a problem hiding this comment.
Valid, fixed in 98c826a. fetchCurrent now sends every query parameter of the write, and readTarget (used by changeset validate --live) sends Target.Query. Both now read the object the query selects. Regression test: TestCaptureAndLiveCheckReadTheObjectTheQuerySelects. It records the selected user's value as before when the caller's own value matches the new one, and it checks that --live finds drift on that object.
| case op.Area == "cc": | ||
| p = webexapi.Paging{CC: true, SizeParam: op.Paging.SizeParam} | ||
| } | ||
| return c.List(ctx, u, p, visit) |
There was a problem hiding this comment.
[P2] Forward operation headers through SDK pagination
webexops.List silently drops args.Header when delegating to the pager. Consequently, listing MeetingsRecordingsListGroup with a hostEmail header and no personId sends neither required selector, while AI Agent lists lose their supplied bot-access-token. Carry the request headers through every page request, as Call already does.
There was a problem hiding this comment.
Valid, fixed in 98c826a. webexapi.Paging has a Header that the CC and offset pagers send on every page request, and webexops.List fills it from args.Header in the same way Call does. Regression test: TestListSendsTheCallersHeadersOnEveryPage. It lists MeetingsRecordingsListGroup across a Link page and checks hostEmail on both requests.
| if err := os.Chmod(tmp.Name(), 0o644); err != nil { | ||
| return err | ||
| } | ||
| return os.Rename(tmp.Name(), path) |
There was a problem hiding this comment.
[P2] Keep recorded credentials in owner-only files
Recording commands such as calling call-routing create-trunk --password ... stores credentials verbatim in the change set. This unconditional chmod makes them readable by other local users in a traversable directory, even with umask 077 or an existing file protected with mode 0600. Keep new files owner-only and avoid broadening existing permissions during replacement.
There was a problem hiding this comment.
Valid, fixed in 98c826a. Save now creates a new change set as 0600 and gives a replaced file its existing mode, so it never widens permissions. The Change Sets page says why. Regression test: TestSaveKeepsChangeSetsOwnerOnly, which checks both cases.
| func isCollection(route string) bool { | ||
| if strings.Contains(route, "/actions/") { | ||
| return false | ||
| } | ||
| segments := strings.Split(strings.Trim(route, "/"), "/") | ||
| last := segments[len(segments)-1] | ||
| return last != "" && !strings.HasPrefix(last, "{") && !actionSegment.MatchString(last) |
There was a problem hiding this comment.
[P2] Record non-creating POST actions as invokes
POST action routes absent from actionSegment, such as /telephony/calls/answer, /hold, and /hangup, pass this collection test. Recording calling call-controls answer --call-id ... therefore produces a create operation and a placeholder ID for an object that will never be created, bypassing the warning provided for invokes. Classify these action endpoints explicitly or default unrecognized POST routes to invoke rather than assuming they create objects.
There was a problem hiding this comment.
Valid, fixed in 98c826a, with a rule based on the operation table instead of more verbs. A POST is a create only when the same API has a route to one object under it (<route>/{id}, or deeper, as with location schedules at /schedules/{type}/{id}). The actionSegment list is gone. I compared the old and new rules over every POST in the table. 90 distinct action routes become invokes, including call control, conference control, meeting participant actions, access-code and number adds, and recording restores. No catalog type's create changes. One route, /attachment/actions, becomes a create, which is right because it has an item route. Regression test: TestCapturePostToARouteWithNoItemsIsAnInvoke (calls/answer is an invoke and a hunt group POST is still a create).
Codex review 3: outcomeCodex (
The Change Sets page describes the new create rule, the query's part in a target, and file permissions. Checks
🤖 Generated with Claude Code |
- The guard checks for a stop again just before each write it sends, so a failure during another write's read stops it. - Capture refuses a body that names another org. - A DELETE with a body is recorded as an invoke that keeps the body. - A list page of null fails instead of reading as no items. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
… page Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
edumansky
left a comment
There was a problem hiding this comment.
Codex review (gpt-6-astra, reasoning effort: high)
The Go and codegen tests pass, but uncovered defects allow mutations after a guard failure, mis-scoped or incomplete change-set records, and malformed list responses to be accepted as complete inventories.
| if d := resource.Changed(fresh.Values, prior, t.FieldNames()); len(d) > 0 { | ||
| return g.fail(&Failure{Conflict, "Conflict: this object changed after the plan was made", strings.Join(d, ", ")}) | ||
| } | ||
| if err := g.API.Delete(ctx, t, key); err != nil { | ||
| return g.fail(writeFailure(err, "The delete", "")) |
There was a problem hiding this comment.
[P1] Recheck the stop flag immediately before sending mutations
When operations share a Guard concurrently, one can pass admit and block in Read while another fails and stops the guard. The first operation then resumes and sends its DELETE despite the failure; this is reproducible with a delayed read. Update and Create's follow-up PUT have the same gap. Synchronize mutation admission with the stop state after blocking reads, preserving the stop-after-failure invariant in AGENTS.md:114.
There was a problem hiding this comment.
Valid, fixed in 2b35081. A new halted() check of the stop flag runs again just before each request that writes: in write (Update and Create's second step) and before API.Delete. A failure that lands while a write is reading now stops that write before it is sent. A stop that lands after the request has gone out cannot recall it, but the read window Codex describes is closed. Regression test: TestAFailureDuringTheReadStopsTheWrite. Its API stops the guard from inside Read, and the test checks that neither Update nor Delete sends anything.
| for _, params := range []map[string]string{req.queryParams, req.pathParams} { | ||
| for k, v := range params { | ||
| if org := config.DecodeOrgID(v); strings.EqualFold(k, "orgId") && org != config.OrgID() { | ||
| return t, fmt.Errorf("this request is for org %s, but change sets record changes for the --organization org (%s)", org, config.OrgID()) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
[P2] Validate organization selectors in request bodies
Requests that select their organization through the JSON body bypass this check. For example, admin people create-person --organization A --body '{"orgId":"B",...}' --changeset proposal.json successfully records a file scoped to A whose create targets B. Validate body-level organization selectors too, normalizing UUID/base64 forms, so a proposal cannot silently target a different organization from its declared scope.
There was a problem hiding this comment.
Valid, fixed in 2b35081. Capture now checks the top-level orgId and organizationId keys of a JSON body. As for path and query parameters, it accepts the UUID or base64 form, and it refuses any org other than the change set's. An empty value is ignored. Regression test: TestCaptureChecksTheOrgABodyNames (another org is refused, the same org in base64 is recorded). The Change Sets page now says the path, query or body may name the org.
| case method == http.MethodDelete && target.ID() != "": | ||
| return map[string]any{"recorded": cs.AddDelete(target), "action": changeset.Delete}, nil |
There was a problem hiding this comment.
[P2] Preserve bodies when recording single-object DELETE requests
A DELETE whose route ends in an ID always becomes a bodyless delete here, silently discarding supplied JSON. Existing commands such as calling converged-recordings delete and meetings transcripts delete accept reason and comment bodies, so their recorded proposals lose the deletion rationale and cannot reproduce the requested call. Record body-bearing deletes as invokes, or extend the delete representation to preserve their bodies.
There was a problem hiding this comment.
Valid, fixed in 2b35081. A DELETE on an item route is a plain delete only when it has no body. With a body, it is recorded as an invoke that keeps the method and body, the same as bulk deletes. Regression test: TestCaptureDeleteWithABodyIsAnInvoke (a converged recording delete with reason and comment). The Change Sets page lists deletes with a body under invoke.
| func ccPage(body []byte) (items []json.RawMessage, totalPages int, err error) { | ||
| if err := json.Unmarshal(body, &items); err == nil { | ||
| return items, 0, nil |
There was a problem hiding this comment.
[P2] Reject null responses instead of treating them as empty lists
If a list request returns HTTP 200 with null, unmarshalling into a slice succeeds and this function reports an empty page. ListCC consequently returns success with an empty or truncated inventory rather than ErrIncompleteList, potentially making existing objects appear deleted. The offset pager similarly accepts bare null or a named array containing null. Require an actual JSON array to preserve the fail-closed paging contract in AGENTS.md:109.
There was a problem hiding this comment.
Valid, fixed in 2b35081. A new array() helper decodes list pages and refuses null, which json.Unmarshal would otherwise accept as a nil slice. The CC pager, the offset pager's bare-array path and namedArray all use it, so a null page gives ErrIncompleteList. ({"data":null} was already refused.) Regression test: TestPagersFailOnANullList. To check that no real list answers null, I listed all 77 catalog types on a live test org with this build, read-only, and every list succeeded.
Codex review 4: outcomeCodex (
Checks
🤖 Generated with Claude Code |
webex resource was never needed for change sets, which ordinary write commands record. Removed with it: - Request.Current and Request.Sets, which existed only for its recording; - the duplicate pacer in pkg/webex (the SDK keeps pkg/webexapi's); - the JSON form of auth status, which nothing reads; - pacing of ordinary CLI Contact Center requests (SDK reads keep it); - the resource MCP guidance. webex_propose now takes only calling and cc commands, the areas with types Studio can apply. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
Terraform addresses, resource names and import IDs are how Webex Admin Studio names objects in its config, not facts about the Webex API, so they move to Studio with the reference-cycle check that guards its Terraform graph. Only Studio used them. Webex Admin Studio's Swift build, which still calls them here, stops building against this branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
Recording guessed what any of the API's routes meant, and Studio refuses a whole change set over one operation it cannot apply. A write is now matched to the create, update or delete operation of a writable catalog type, and anything else is refused with a pointer to the types that can be recorded. - The object is read through the catalog's reader, which knows each type's read route, including work types read by filter. A read that fails refuses the write instead of recording it without preconditions. - An update records only the paths a catalog field owns, the rule Studio imports by, and reports the rest as ignored, so a whole body copied from a read records just what changed. - Query parameters are refused, except the org and a repeated path ID: Studio would drop them. A create or update needs a JSON body, and a delete may not have one. - A new: ID must name a create of the same type and parent. - Operations find their object by catalog type and key, so a queue's delete replaces its pending update though the two use different routes. - validate --live reads the same way; a route of no known type is unreadable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
- changeset drop removes an operation, reverting what it proposes, or some field changes from an update. A create something else refers to waits until that is dropped. - changeset meta changes the title, rationale, author or links after new. - changeset types lists the types a change set can record with their create, update and delete commands, and given a type, its fields: the paths an update records, which a create needs and which are fixed. The MCP webex_changeset tool offers drop, meta and types too. types needs no file, and meta clears a field given empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
Help, the --changeset flag, the MCP tools, the docs page, README and skill all say change sets are in beta, and changeset new says so once. The guidance now matches recording: only types Studio applies can be recorded (changeset types lists them), an update body holds only the fields to change, even for Contact Center, and a proposal is never applied by dropping --changeset. The invoke and query rules are gone, and drop and meta are documented. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
The guard stopped further writes only after reading the object back to describe the failed update, which can take seconds, so a concurrent create or update could still be sent in between. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
edumansky
left a comment
There was a problem hiding this comment.
Codex review (gpt-6-astra, reasoning effort: high)
The Go tests, SDK race tests, and code-generation tests pass. However, a reproduced concurrency defect allows additional writes after a failed update, violating the guard’s safety guarantee.
| if err := g.API.Update(ctx, t, key, raw, delta); err != nil { | ||
| actual := g.observe(t, key, baseline) | ||
| return actual, g.fail(writeFailure(err, "The write", observed(actual, baseline, planned, t))) |
There was a problem hiding this comment.
[P1] Stop further writes before observing a failed update
During a concurrent apply, an update failure leaves the guard open while g.observe performs its recovery read, potentially for eight seconds. Other operations can pass admission and write during this window; a blocked recovery read reproduces a subsequent Create succeeding after the update has failed. Set the stop flag immediately upon receiving the update error, before observing its outcome, to preserve the stop-after-failure invariant in AGENTS.md.
There was a problem hiding this comment.
Valid, fixed in ff8e0be. When API.Update fails, write now sets the stop flag first, with the failure summary, and only then calls observe. A concurrent write that reaches admit during the recovery read is refused. Regression test: TestAFailedUpdateStopsOtherWritesBeforeItIsObserved. Its API fails the update and, from inside the observing read, checks whether admit would still let a write through: before the fix it did, now it does not. The guard tests also pass under -race.
Codex review 5: outcomeCodex (
Checks
🤖 Generated with Claude Code |
The server added --changeset=<path> after the agent's command, so a command ending in a value flag such as --output swallowed it, and a --changeset= in the command switched recording off; either sent the write to Webex. The path now goes in $WEBEX_CHANGESET, which no command token can reach, and a command that names --changeset is refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
- Text that only starts with "new:", such as a description, stays text; only a handle-shaped placeholder is resolved. - A null for a field Webex leaves out is not recorded as a change. - An update whose fields were all ignored no longer reports unchanged. - The docs say field rules are checked by Studio's import, not while recording, and that Studio does not apply clears yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
Fable 5.2 review (high effort) of
|
| Finding | Outcome | Regression test |
|---|---|---|
[P1] webex_propose sends a live write when the command ends in a value flag (cc team delete --output swallowed the added --changeset=) |
Fixed in a18607d. The change set path now reaches the CLI in $WEBEX_CHANGESET, which no command token can reach. A command that names --changeset is refused, which also closes a second way in I found while checking: --changeset= (empty) switched recording off. |
TestProposeAlwaysRecords |
| [P2] Recording does not apply Studio's field checks (fixed fields, clears, required fields, value rules), so Studio's import refuses such a file | Left as designed. The owner chose to keep field-level checks in Studio's import and out of the CLI. The docs and AGENTS.md now say so instead of implying every recorded file imports. The related no-op (a null for a field Webex leaves out) is fixed in 7dc354b. |
TestDiffSkipsNullForAnAbsentField |
[P3] Docs say null clears a value, but Studio refuses clears |
Fixed in 7dc354b: the docs say Studio does not apply clears yet. | — |
[P3] Any body text starting with new: is treated as a placeholder |
Fixed in 7dc354b: only a handle-shaped new:<handle> is resolved, so other text stays text. |
TestTextThatStartsWithNewIsNotAPlaceholder |
[P3] An update whose fields were all ignored reports unchanged: true |
Fixed in 7dc354b: it reports only ignored. |
TestCaptureOnlyIgnoredFieldsIsNotUnchanged |
Checks
gofmt -landgo vet ./...: clean.go test ./..., run one package at a time: passes, and the golden files are unchanged.- The docs site builds.
- The Rust Studio's importer and catalog tests pass against this branch.
🤖 Generated with Claude Code
A create that copies from an existing object, such as a desktop layout, reads before it posts, and the guard checked for a stop only before those reads. The SDK now runs a check given through the context just before it sends a write, and the guard passes its stop flag that way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
Recording writes into change set files through intercepted CLI commands kept turning up new defects in review, so it leaves this branch to be redesigned on its own. Removed: the --changeset flag and $WEBEX_CHANGESET, the recorder and live check, the webex changeset commands, the MCP webex_propose and webex_changeset tools, the catalog matcher and Type.Split that only recording used, and pkg/changeset's recording helpers. pkg/changeset keeps the file format, validation and the helpers Webex Admin Studio's importer and exporter use. Also: webex_help refuses flags in its command, because a trailing value flag swallowed --help and ran the command instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
- The offset pager ended a list on any short page, so a SCIM list whose totalResults said more remained came back short with no error. A stated total now ends the list only when reached, and a page with no items before then is ErrIncompleteList. - A failed AI Agent Studio call redacted credentials in the response but not in the error, whose message repeats the body. APIError.WithBody rebuilds the error from a redacted body, and both the SDK's Call and the CLI's AI Agent commands use it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
edumansky
left a comment
There was a problem hiding this comment.
Codex review (gpt-6-astra, reasoning effort: high)
Go and codegen tests pass, and comparison against baseline golden output confirms existing CLI requests are preserved. However, targeted reproductions demonstrate silent SDK list truncation and credential leakage through SDK errors.
| if next != "" || p.Start == "" || len(items) < offsetPageSize || (total > 0 && seen >= total) { | ||
| return nil | ||
| } | ||
| start += len(items) |
There was a problem hiding this comment.
[P2] Honor totalResults before treating a short page as complete
When a SCIM endpoint returns fewer than 100 resources but totalResults indicates more remain, this condition returns success prematurely. For example, AdminScim2UsersSearch returning one resource with totalResults: 2 produces one item and no error. Continue paging while the declared total remains unmet, or return ErrIncompleteList if progress stops, consistent with the fail-closed paging requirement in AGENTS.md:107.
There was a problem hiding this comment.
Valid, fixed in 6345dfb. When a page states totalResults, the offset pager now ends the list only once that many items have been read. A page with no items before that is ErrIncompleteList; a page that repeats is already caught. Without a total, a short page still ends the list. Regression tests: TestOffsetPagerReadsToAStatedTotal (a SCIM list served one item per page with totalResults: 2 returns both) and TestOffsetPagerFailsWhenItStopsShortOfItsTotal.
| resp, err := webexops.Call(ctx, c, hosts, op, args) | ||
| if resp != nil && op.Redact { | ||
| resp.Body = RedactSecrets(resp.Body) | ||
| } | ||
| return resp, err |
There was a problem hiding this comment.
[P2] Redact credentials from returned errors as well as responses
When a failed Studio response contains credentials, replacing resp.Body leaves the returned APIError.Body and its cached error message unredacted. A 400 response containing {"auth_token":"secret-token"} therefore returns a sanitized response but exposes the token through ordinary error logging. Sanitize both error representations before returning, preserving the credential-redaction responsibility documented in AGENTS.md:114.
There was a problem hiding this comment.
Valid, fixed in 6345dfb. The new APIError.WithBody returns a copy carrying another body and rebuilds the body-derived message; a rate-limit message is kept. aiagent.RedactError uses it to redact credentials in the error. aiagent.Call applies it to a failed redacting call, and so do the CLI's generated AI Agent commands (internal/apicmd), which printed the same error. Regression tests: TestCallRedactsTheErrorOfAFailedCall (a 400 carrying auth_token leaves no token in the error message or body) and TestWithBodyRebuildsTheMessage.
Codex review 6: outcomeCodex (
Checks
🤖 Generated with Claude Code |
- webex_read refuses a generated command whose request is not a GET or a query POST, judged by its operation: 'calling call-controls get', for one, POSTs to retrieve a parked call. - The SDK pager fails instead of ending a list when the next page is on another host, and an empty SCIM list without Resources has no items, not its schemas array. - webexops refuses a header the operation does not declare, as it does a query parameter. - Type.Check refuses a list that is not []any, which a body would write as an empty list. - The guard reads a create or delete back up to three times, as it does an update, so read-after-write lag does not stop a run. - The fake answers 404 for a fault on a route with nothing to commit instead of panicking under its lock, and Delete covers every collection. - The weekly collection refresh runs the tests before opening its PR. - Removed what nothing uses any more: the CLI's Contact Center pacer and CCInterval setting, and stale change set references. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
Fable 5.2 review (high effort) of
|
| Finding | Outcome |
|---|---|
[P2] webex_read lets four POST commands through on their verb (e.g. calling call-controls get); already on main |
Fixed: a generated command is judged by its operation's method. Test: TestReadRefusesWritesAndDoubleDash, TestReadAllowsGETsAndQueryPOSTs |
| [P3] SDK pager ends a list silently on a next link to another host | Fixed: ErrIncompleteList. Test: TestAPagerFailsOnANextPageOnAnotherHost |
| [P3] Guard reads a create/delete back once, so one stale read stops a run | Fixed: up to three reads, as for updates. Test: TestCreateAndDeleteReadBackThroughLag |
[P3] An empty SCIM list without Resources yields its schemas array |
Fixed. Test: TestAnEmptySCIMListWithoutResourcesHasNoItems |
| [P3] Weekly refresh PR opens without tests (PR #16 shows 0 checks) | Fixed: the workflow runs make test before opening the PR |
[P3] webexops.Call does not check header names |
Fixed. Test: TestURLFillsPathAndQueryAndRefusesUnknownArguments |
[P3] A Go-typed list ([]string) is written as an empty list |
Fixed: Type.Check refuses a list that is not []any. Test: TestCheckRefusesAListThatIsNotAnySlice |
| [P3] No token refresh for a 401 after a 429 wait | Not fixed here: the client already behaved this way on main. Left for a separate change |
[P3] Fake API deadlocks on a commit-type fault with no handler; Delete misses generic collections |
Fixed. Tests: TestTheFakeSurvivesAFaultOnAListRoute, TestTheFakeDeletesFromEveryCollection |
| [P3] Leftovers | Removed: the unused CLI Contact Center pacer and CCInterval, stale change set references, the sortedKeys helper, and an inaccurate AGENTS.md line |
Checks
gofmt -landgo vet ./...: clean.go test ./..., run one package at a time: passes, and the golden files are unchanged.- The codegen tests pass, and the site builds.
- Webex Admin Studio's Rust repo passes its Go tests against this branch, as do
cargo xtask toolsandcargo test -p studio-engine --test apply(41 passed).
🤖 Generated with Claude Code
- The guard's stop check moves into the transport: webexapi.WithWriteCheck runs it before every attempt of a write, so a 429 retry after another write failed is not sent. Updates, creates and deletes all use it. - Type.Check looks inside rows, so a list in a row that is not []any is refused instead of written as an empty list. - Unmet normalizes the planned value as Project does the observed one, so an unordered list planned in another order is met. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
edumansky
left a comment
There was a problem hiding this comment.
Codex review (gpt-6-astra, reasoning effort: high)
The Go and codegen tests pass, but targeted reproductions demonstrate writes continuing after the guard stops, nested list values being silently cleared, and unordered lists falsely failing verification.
| if err := g.halted(); err != nil { | ||
| return baseline, err | ||
| } | ||
| if err := g.API.Update(ctx, t, key, raw, delta); err != nil { |
There was a problem hiding this comment.
[P1] Recheck the guard before retrying a write
When concurrent write A receives a 429 and waits, write B can fail and stop the guard, but A still retries and mutates Webex: this check runs only before API.Update, not before each transport attempt. The same gap affects creates and deletes. Propagate the stop check into every write attempt, including retries, to uphold the stop-after-failure protocol.
There was a problem hiding this comment.
Valid, fixed in 0c34dec. The stop check moves into the transport: webexapi.WithWriteCheck makes Client.Send run it before every attempt of a write, retries after a 429 included, and webex.WithBeforeWrite now delegates to it. The guard passes g.halted that way for updates and deletes as well as creates, and returns the Stopped failure unchanged when the check refuses. Regression tests: TestAWriteCheckRunsBeforeEachAttempt (a PUT answered 429 is not retried once the check fails, and a GET is never checked) and TestAStopAtSendStopsAnUpdateAndADelete (no mutation reaches the fake).
| if f.Kind == StringList || f.Kind == ObjectList { | ||
| if _, ok := v.([]any); v != nil && !ok { | ||
| return fmt.Errorf("want a list as []any, not %T", v) | ||
| } | ||
| return nil |
There was a problem hiding this comment.
[P2] Validate nested list fields before constructing write bodies
For an ObjectList, validation returns without checking its row fields. For example, a cc_queue distribution group containing team_ids: []string{"team1"} passes CheckUpdate, but set accepts only []any and serializes this as an empty agentGroups array, clearing the group's teams. Recursively validate row fields so unsupported nested slice types are rejected before sending a destructive write.
There was a problem hiding this comment.
Valid, fixed in 0c34dec. For an ObjectList, Field.Check now requires each row to be a map[string]any and checks each row field with its own Check, so a []string list inside a row is refused before any body is built. Regression test: TestCheckRefusesAListThatIsNotAnySlice (row case added).
| if f.Kind == ObjectList { | ||
| if !rowsMeet(have[f.Name], p) { | ||
| out = append(out, f.Name) | ||
| } | ||
| continue | ||
| } | ||
| if canonical(have[f.Name]) != canonical(p) { |
There was a problem hiding this comment.
[P2] Normalize unordered fields when comparing planned values
When a caller supplies an unordered list in a different order, Project sorts the observed values but Unmet compares them against the unsorted plan. For example, cc_desktop_layout with planned team_ids: ["team2", "team1"] remains unmet even after those exact memberships are saved. Guarded writes consequently issue unnecessary updates and report divergence, stopping the run. Normalize both sides using the field definition before comparison, including ObjectList rows.
There was a problem hiding this comment.
Valid, fixed in 0c34dec. Unmet normalizes the planned value with the field's Normalize, as Project does for the observed value, including ObjectList rows. Regression test: TestUnmetComparesUnorderedListsAsSets.
Codex review 7: outcomeCodex (
Checks
🤖 Generated with Claude Code |
- The write check also runs after BeforeSend, which can wait, as Studio's pacer does, long enough for another write to stop the run. - A write that fails before its request (no route, a URL it cannot build, a copy read that fails, a body it cannot encode) returns webex.NotSent, which Classify reads as nothing changed and the guard reports as not sent, instead of an unknown outcome. - The fake API answers 400 to a write without a JSON body instead of panicking under its lock. - SDK()'s comment no longer claims Contact Center pacing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
Fable 5.2 review (high effort) of
|
| Finding | Outcome | Regression test |
|---|---|---|
[P3] The write check runs before BeforeSend, so a write held by Studio's pacer is sent after a stop |
Fixed: the check also runs after BeforeSend, just before the request is sent |
TestAWriteCheckRunsAfterBeforeSend |
| [P3] Writes that were never sent are reported as "outcome unknown" | Fixed: webex.NotSent marks failures before the request, which Classify reads as nothing changed and the guard reports as "was not sent" |
TestAWriteThatWasNeverSentIsNotAnUnknownOutcome |
| [P3] The fake API deadlocks after a POST with no JSON body | Fixed: it answers 400 before taking its lock | TestTheFakeRefusesAWriteWithoutABody |
[P3] Stale "Contact Center pacing" comment on SDK() |
Fixed | — |
Checks
gofmt -landgo vet ./...: clean.go test ./..., run one package at a time: passes, and the golden files are unchanged.- Webex Admin Studio's Rust repo passes its Go tests and
cargo test -p studio-engine --test apply(41 passed) against this branch.
🤖 Generated with Claude Code
Summary
This PR moves the core Webex API logic into a Go SDK in
pkg/. The CLI, its MCP server and Webex Admin Studio all use it.Go SDK
pkg/webexapi):--cc-region,WEBEX_CC_REGION,webex config set cc-region);pkg/webexops): codegen writes one exported*Operationper API call instead of cobra code.internal/apicmdbuilds the command tree from it at startup.pkg/resource,pkg/catalog,pkg/webex,pkg/guard,pkg/fakeapi): moved from Webex Admin Studio.pkg/changeset): the format Studio imports and exports, with its validation and JSON Schema.pkg/aiagent, for Studio to use too. The CLI's requests are unchanged.testdata/golden/) record every command's flags and help, and the exact request each API command sends. CI runs the Go and codegen tests, and the weekly collection refresh runsmake golden.Other CLI changes
list-v1/list-v2routes, which the catalog lists through.--paginatereads past full bare-array pages, which those routes return.auth listandauth token --output json, andauth token --min-ttl, which Studio uses to get a token.webex_helprefuses flags, because a trailing value flag could swallow--helpand run the command;webex_writedescription warns that a Contact Center update replaces the whole object.Removed during review
--changeset, thewebex changesetcommands, and thewebex_propose/webex_changesetMCP tools) is taken out, to be redesigned in its own PR. Intercepting arbitrary write commands kept producing new review findings. The file format package stays, since Studio uses it.webex resource.auth status --output json, and pacing of ordinary CLI requests.Commit note: the files deleted when recording was removed landed in 64235b9, the guard commit, instead of b140441. The branch's contents are correct.
Compatibility notes
Validation
gofmt,go vet ./...,go test ./...(one package at a time), the golden test, the codegen unit tests and the site build all pass.cargo xtask toolsandcargo test -p studio-engine --test apply(41 passed, with realtofu).🤖 Generated with Claude Code
https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW