Skip to content

feat(sdks): add a Go client for the session API - #4010

Closed
bdchatham wants to merge 5 commits into
omnigent-ai:mainfrom
bdchatham:feat/go-client-sdk
Closed

feat(sdks): add a Go client for the session API#4010
bdchatham wants to merge 5 commits into
omnigent-ai:mainfrom
bdchatham:feat/go-client-sdk

Conversation

@bdchatham

@bdchatham bdchatham commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Related issue

Closes #4009

Summary

sdks/ has a Python client and a TypeScript UI package, so a Go caller hand-rolls an HTTP client and an SSE parser. The SSE half is where the sharp edges are, and this module puts them in one place.

  • Hand-written surface, generated types. The verbs, the stream, the errors and the option type are hand-written (~1,800 lines). The schema types come from the checked-in openapi.json via scripts/gen_go_client.py, so a spec change moves the Go types and the drift test already pinning openapi.json to the live app keeps them honest.
  • The stream is iter.Seq2[Event, error] with no goroutines in the stream path, and Event is sealed by an unexported method so a caller's type switch is exhaustive by construction.
  • The client refuses to carry a credential where it should not go — off-host, https-to-http, method-rewriting and non-terminating redirects are all ErrUnsafeRedirect.
  • The two lookups a program needs before anything else: ListAgents turns an agent name into the id CreateSession wants, and ListSessions finds a session an earlier run created instead of creating a second one. Both page by cursor into a shared Page[T].
  • Approval prompts can be answered, so an unattended caller does not stall the first time an agent asks for a decision.

ELI5

The server can talk to you in a long stream, like a phone call where it keeps saying things until the work is done. Writing the listener for that call is fiddly. There are 52 different things it can say, it says "still here" every 15 seconds which does not mean it finished, and the obvious way to set a time limit in Go accidentally hangs up mid-sentence because the limit covers the whole call rather than just waiting for someone to pick up.

This adds a listener that already knows all of that, so a Go program writes an ordinary for loop and gets the events. It also refuses to shout your password across the street: if the server answers "go ask that other host instead", or "same host but drop down to unencrypted", the client stops instead of taking your credential along.

How it works

  caller                     go-client                        server
    │                            │                               │
    │ for ev, err := range       │                               │
    │   client.Events(ctx, id)   │                               │
    │───────────────────────────►│                               │
    │                            │  GET .../events  (c.stream:   │
    │                            │  no Timeout, idle watchdog)   │
    │                            │──────────────────────────────►│
    │                            │                               │
    │                            │  ◄─── data: {"type": …}  ×N ──│
    │                            │  dispatch on the payload's    │
    │                            │  own discriminator            │
    │  ◄──── ev, nil ────────────│                               │
    │                            │                               │
    │                            │  ◄─── session.heartbeat ──────│  every 15s
    │  ◄──── ev, nil ────────────│  surfaced, NOT progress       │
    │                            │                               │
    │  break ────────────────────►│  ctx cancel, body closed     │
    │                            │  (no goroutine to join)       │

Unary calls use a second http.Client that does set Timeout. That split is the point: Timeout covers reading the body, so on a stream it is a deadline on the entire conversation rather than on getting a response, and a long turn would be cut off mid-flight.

session.heartbeat is doubly-used upstream — the 15s keepalive and a ready signal on a different path share one payload — so it is surfaced rather than swallowed, and both the package doc and the README say a caller must not read it as a turn boundary.

On redirects, Go's own rule strips Authorization, Cookie, Www-Authenticate and Cookie2 on a cross-host hop, which leaves a custom identity header travelling to whatever host the response named; it compares hostnames and not schemes, so an https-to-http hop keeps the credential and loses the encryption; a cross-host 307/308 replays the body, which here is the caller's prompt; and a 302 on a POST becomes a GET, so a dropped write would return 200. Those four plus a chain that never terminates are ErrUnsafeRedirect. A caller supplying its own CheckRedirect keeps it and owns that.

The two lookups, and approvals

CreateSession takes an agent id and there is no lookup-by-name route, so ListAgents is how a caller holding a name finds the id. Worth knowing: that route is named for built-in agents and returns more than those. It filters session_id IS NULL, so operator-installed agents are included too, and AgentObject.Builtin is what distinguishes them.

ListSessions answers the other question. A program that runs repeatedly must not create a second session when it already made one, and create is the one call this package will not retry — a retry after a committed-but-lost response orphans a session. So set a label at create time and filter by AgentID to find it again.

Both listings omit every option that is unset rather than sending a zero value, which is load-bearing rather than tidy: the server validates order against ^(asc|desc)$, sort_by against ^(created_at|updated_at)$, kind against ^(default|sub_agent|any)$ and limit against 1..1000, so forwarding Go's "" or 0 would 422 a caller who simply expressed no preference. The enums are typed so the values are discoverable, but membership is deliberately not checked client-side — the server's pattern is the authority, and a hardcoded copy here would reject a value a newer server accepts. That matches SendInput, which checks its Type is non-empty and leaves the vocabulary to the server.

Page[T] is one type rather than two because the server shares the envelope: both routes return the same four fields around a differently-typed data. AgentObject is typed as the element even though the route's declared response is the shared PaginatedList, whose data is untyped because it is reused across routes returning different things — this route builds its page out of AgentObject, so typing it saves every caller an []any re-decode.

Approvals go on the route this package already writes to, not a new one. There is a dedicated POST /sessions/{id}/elicitations/{id}/resolve and this client does not call it: it is registered include_in_schema=False as an internal flow for prompts published in url mode, and its own docstring names the type == "approval" event as the equivalent path with identical resolution semantics. One write route is enough. Accepting is privileged differently from refusing — it authorises the pending tool to run with the session owner's execution identity, so the server demands approval access for accept specifically and answers 403 to a caller holding only edit access, which can still decline or cancel.

Test Plan

sdks/go-client         255 test cases (62 funcs)   go test -race -count=3   ok
tests/scripts/         57 test cases (29 funcs)    generator unit tests     ok

gofmt -l          clean          go vet            ok
golangci-lint     0 issues       go mod tidy -diff clean
gen_go_client.py --check         exit 0 (bindings match openapi.json)

Covered: the four redirect refusals and that a caller's own CheckRedirect survives; loopback and case-folded loopback accepted over plain http while a remote host with a credential is refused; userinfo in the base URL rejected and no password echoed in the error; Timeout split between the two clients; the idle watchdog firing on real silence and not on a heartbeat; incremental SSE framing including a frame split across reads and a line-length bound; sealed-union dispatch over all 52 variants plus an unknown type; APIError never rendering the body, and the header stripped of Set-Cookie/Set-Cookie2/Authorization; path-segment escaping (GetSession(ctx, "..") cannot walk); and the generator's fail-closed paths (an unhandled JSON-Schema construct, a residual leak, a missing oapi-codegen, and CI being unable to skip the freshness check).

Also covered: query encoding for both listings, including the zero-value and false-boolean omissions and query escaping; page decoding for both element types and for an empty page; the paging loop the Page doc prescribes, executed against a two-page server so the documented recipe runs rather than only being described; the approval payload through JSON, so an unset content is absent rather than null; the three incomplete verdicts refused before a request is sent; and that a verdict reaches the events route rather than the hidden one.

AgentObject and SessionListItem join the pointer-to-collection invariant's roots. Neither was reachable from the previous four, so it did not cover them; both carry collections a caller ranges over. Verified by mutation — making AgentObject.Skills a *[]SkillSummary fails that test with the field named.

The README quickstart and its three lookup-and-approval recipes were compiled verbatim in a fresh module.

Two of those cases exist because an independent review of this increment found defects in the documented recipes, and both are mutation-proven rather than merely present: reverting the paging guard makes TestDocumentedWalkTerminatesOnAnEmptyPage fail, and reverting to a single-page scan makes TestAdoptRecipeFindsASessionBeyondTheFirstPage fail. See the note below.

Demo

N/A — no UI surface.

Type of change

  • Bug fix
  • Feature
  • UI / frontend change
  • Refactor / chore
  • Docs
  • Test / CI
  • Breaking change

Test coverage

  • Unit tests added / updated
  • Integration tests added / updated
  • E2E tests added / updated
  • Manual verification completed
  • Existing tests cover this change
  • Not applicable

Coverage notes

Manual verification was the two README quickstarts, compiled verbatim in a fresh module outside the repo, to confirm the documented entry point actually builds against the published surface.

There are no integration or E2E tests against a live server. Everything is exercised against httptest servers, including the streaming and redirect paths. Wiring this into the existing E2E suite would mean standing up the Go toolchain in that job, which seemed worth a maintainer's call rather than assuming.

Surface a reviewer should weigh in on before a v1 tag

Naming and layout are one-way doors once the module is tagged, so these are called out rather than settled unilaterally. All are cosmetic or additive, none affects the wire contract.

  1. 54 of 67 generated enum types hold exactly one value (99 constants total). They are the per-event discriminators: each event's type is a fixed string, so the generator emits a one-value enum such as SessionChangedFilesInvalidatedEventType. They are harmless and they are noise. A generator rule could drop a single-valued enum in favour of the string, at the cost of the constant a caller might reasonably want to compare against.

  2. Long three-noun type names, up to 39 characters (SessionChangedFilesInvalidatedEventType). Inherited from the schema names; shortening them means diverging from the spec.

  3. SessionResponse is the snapshot type, and it is what CreateSession and GetSession both return. Session or SessionSnapshot reads better in Go, but the name is the spec's and renaming breaks the one-to-one mapping a reader uses to check the binding.

  4. Event exposes no wire-type accessor. The interface is sealed with isEvent() only, so getting the type string means a type switch or reading the struct field. A Type() string method would be additive and convenient; it is left out for now because it duplicates state that already lives on each struct.

  5. Two enum pairs are duplicates by value: ErrorDataSource/ErrorEventSource (execution, llm, tool) and SandboxStatusStage/SessionSandboxStatusEventStage (six values). Two schemas describe the same set. Collapsing them belongs in the spec, not the generator.

  6. ValidationErrorLoc0 = string and ValidationErrorLoc1 = int are generated aliases for the string-or-int union members, and their doc comments read defines model for . because the spec gives the members no description. Cosmetic, fixable in the spec.

  7. Versioning. The module is nested and untagged, so go get needs @main today. A tag would be sdks/go-client/v0.1.0. Worth deciding whether the SDK versions with the server or on its own line.

  8. Five internal module paths survive in ten doc comments. The generator redacts server internals out of descriptions before they become published Go docs, and it recognises them by shape: a .py reference, a private _name, an absolute home directory. A dotted lowercase path is not recognisable that way — omnigent.runtime.pending_inputs is the same shape as the wire-visible label key omnigent.codex_native.collaboration_mode, and telling them apart needs a list of what the server publishes rather than a wider pattern. Three of the five name a server-side schema class that models a payload this package already exposes as a Go type; two name in-process server state a client cannot reach. Extending the pattern was tried and reverted, because deleting these references leaves sentences whose subject was the path. The fix is a substitution table mapping each path to what it denotes, and it deserves its own review. The README enumerates them.

Changelog

A Go client for the session API, with a range-over-func event stream, cursor-paginated agent and session listings, and approval handling

@github-actions github-actions Bot added the size/XL Pull request size: XL label Aug 4, 2026
@github-actions
github-actions Bot requested a review from PattaraS August 4, 2026 02:30
@bdchatham
bdchatham force-pushed the feat/go-client-sdk branch 2 times, most recently from 2ac9303 to 1ba1a15 Compare August 4, 2026 02:36
@bdchatham

Copy link
Copy Markdown
Contributor Author

CI state

Everything that can go green is green, including the three jobs this PR adds:

Go SDK lint  pass    Go SDK tests (1.24.x)  pass    Go SDK tests (stable)  pass
DCO          pass    Windows smoke + unit   pass    hygiene                pass

Two checks are red and both are maintainer gates rather than problems with the change:

Security Scan fails only at the Sensitive-path guard. Secret scan and exfil scan both pass; the guard fires because the PR adds .github/workflows/go-sdk.yml and appends two steps to lint.yml. A PR that adds CI for a new module cannot avoid that, and the waiver label needs Triage permission, so it needs a maintainer's look. The six gate / Security Gate failures are all downstream of this one.

Maintainer Approval is the standard external-contributor gate.

One thing worth flagging from the scan

The exfil scan initially blocked this PR on a false positive, which I fixed in the change rather than by asking for a policy change. Recording the mechanism because it will recur:

  • _SECRET matches [A-Z0-9]+_SECRET case-insensitively, so the dummy strings tok_secret and sess_secret in redirect_test.go read as a secret-named source.
  • _SINK_OTHER includes http\.client for Python's stdlib module, and it is compiled IGNORECASE, so Go's http.Client reads as a network sink.

Paired in one file, that is the blocking shape. The fix here was to rename the literals to bearer-must-not-travel / cookie-must-not-travel, which says what the test asserts better than the old names did.

The http.Client half is not specific to this PR though: with Go in the tree, any future Go file that mentions http.Client and also contains something matching *_secret will trip the same pairing. If that becomes noisy, the narrow fix is to anchor the Python sink to a lowercase http.client rather than matching case-insensitively, since Go's type is capitalised and Python's module is not. Happy to send that separately if you want it; I did not want to bundle a scanner change into an SDK PR.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

@bdchatham This PR is a Bug fix, Feature, or UI / frontend change but the Demo section is missing or only contains a placeholder.

These change types require a screenshot or screen recording so reviewers can see the new behaviour without checking out the branch. Please update the Demo section with:

  • A screenshot or screen recording of the change, or
  • A link to a hosted video or GIF showing the new behaviour.

Use N/A only when the change has no user-visible effect whatsoever (e.g. a pure refactor or test-only change). If that's the case, uncheck the relevant type box and check Refactor / chore or Test / CI instead.

@github-actions github-actions Bot added the needs-demo PR needs a demo screenshot or recording label Aug 4, 2026
@bdchatham
bdchatham force-pushed the feat/go-client-sdk branch from 1ba1a15 to 194e5f4 Compare August 4, 2026 15:07
@bdchatham

Copy link
Copy Markdown
Contributor Author

Scope update: the two lookups and approvals are now in

Pushed before any review started, so nothing here invalidates a read in progress. The description is updated in full; summarising what moved:

The client could drive a session it was handed the id of, and little else. Three things were missing and each is load-bearing for an unattended Go caller:

  • ListAgentsCreateSession takes an agent id and there is no lookup-by-name route, so a caller holding a name had nowhere to go. Note the route is named for built-in agents and returns more than those: it filters session_id IS NULL, so operator-installed agents are included and AgentObject.Builtin distinguishes them.
  • ListSessions — a program that runs repeatedly needs to find the session it created earlier rather than create a second one, which matters because create is the one call this package will not retry (a retry after a committed-but-lost response orphans a session).
  • Approvals — an agent that needs a decision parks its turn, so a caller that cannot answer stalls until the server times the prompt out.

Two decisions worth a reviewer's attention, both taken conservatively:

Approvals ride the existing write route, not the dedicated one. POST /sessions/{id}/elicitations/{id}/resolve exists and this client does not call it, because it is registered include_in_schema=False as an internal flow for url-mode prompts and its own docstring names the type == "approval" event as the equivalent path with identical resolution semantics. Sending the verdict as an input keeps the SDK on one write route. Happy to add the direct route instead if you would rather it were public.

The listing enums are typed but not validated client-side. SortOrder, SessionSortBy and SessionKind make the values discoverable, but membership is not checked here — the server's ^(asc|desc)$-style patterns are the authority, and a hardcoded copy in the SDK would reject a value a newer server accepts. Unset options are omitted rather than sent as Go zero values, since "" and 0 would 422 a caller who expressed no preference.

ElicitationResult joins SessionEventInput and EventAccepted as hand-written, for the same stated reason: same undocumented route, so no schema to generate from and no drift gate coverage. The README and doc.go both carry the count.

CI is where it was: everything green except the Sensitive-path guard (this PR adds .github/workflows/go-sdk.yml, and the waiver label needs Triage permission) and Maintainer Approval. Secret scan and exfil scan both pass.

sdks/ has a Python client and a TypeScript UI package. A Go caller has no
supported way to talk to the session API and has to hand-roll an HTTP client and
an SSE parser, and the SSE half is where the sharp edges are: 52 event types
behind one `type` discriminator, a 15s keepalive that is not progress, and a
stream whose framing has to be read incrementally.

This adds sdks/go-client, a module with a hand-written surface over generated
types.

What is hand-written and what is generated
------------------------------------------

The verbs, the stream, the errors and the option type are hand-written, about
2,100 lines: a Go caller expects a Go-shaped API, and a fully generated client
would expose FastAPI's path-mangled operationIds as method names. The schema
types are generated from the checked-in openapi.json by
scripts/gen_go_client.py, so a spec change moves the Go types and the drift test
already pinning openapi.json to the live app keeps them honest.

Generated code is checked in because `go get` fetches a module as source with no
build step, so an ungenerated module does not compile for a consumer.

The stream is a range-over-func iterator
----------------------------------------

Events(ctx, id) returns iter.Seq2[Event, error], so a caller writes

	for ev, err := range client.Events(ctx, sessionID) { ... }

and `break` cleans up. There are no goroutines in the stream path: an iterator
that owns no background work cannot leak one, and the caller's `break` is the
only teardown signal needed. The Event interface is sealed by an unexported
method, so the type switch over a server event is exhaustive by construction and
adding a variant upstream cannot silently fall through a caller's switch.

Events carries two http.Clients. Timeout on an http.Client covers reading the
body, which for a stream means it is a deadline on the whole conversation rather
than on getting a response, so the streaming client sets no Timeout and relies on
an idle watchdog instead. Unary calls keep the timeout.

A heartbeat is not progress. The server emits session.heartbeat every 15s to hold
the connection open, and it is also the ready signal on a different path, so the
doc and the README both say a caller must not treat it as a turn boundary.

The two lookups a program needs first
-------------------------------------

CreateSession takes an agent id and there is no lookup-by-name route, so
ListAgents is how a caller holding a name finds the id. The route is named for
built-in agents and returns more than those: it filters session_id IS NULL, so
operator-installed agents are included and AgentObject.Builtin distinguishes
them.

ListSessions is how a program that runs repeatedly finds a session it created
earlier instead of creating a second one. That matters because create is the one
call this package will not retry: a retry after a committed-but-lost response
orphans a session. Set a label at create time and filter by AgentID to find it
again.

Both page by opaque cursor into a shared Page[T], because the server shares the
envelope. Every option is omitted when unset rather than sent as a zero value,
and that is load-bearing: the server validates order, sort_by and kind against
patterns and limit against 1..1000, so forwarding Go's "" or 0 would 422 a caller
who expressed no preference. The enums are typed so the values are discoverable,
but membership is not checked client-side -- the server's pattern is the
authority, and a hardcoded copy would reject a value a newer server accepts.

Answering an approval prompt
----------------------------

An agent that needs a decision parks its turn and publishes an elicitation.
Nothing advances until a verdict arrives, so an unattended caller has to answer
or the session stalls until the server times the prompt out.

The verdict is an input on the route this package already writes to, not a new
endpoint. The server does have a dedicated resolve route and this client does not
call it: that route is registered include_in_schema=False as an internal flow for
prompts published in url mode, and its own documentation names the
type == "approval" event as the equivalent path with identical semantics. One
write route is enough.

Accepting is privileged differently from refusing: it authorises the pending tool
to run with the session owner's execution identity, so the server demands
approval access for accept specifically and answers 403 to a caller holding only
edit access, which can still decline or cancel.

Credentials and redirects
-------------------------

The client refuses to carry a credential where it should not go. Go's own
redirect rule strips Authorization, Cookie, Www-Authenticate and Cookie2 on a
cross-host hop, which leaves a custom identity header travelling to whatever host
the response named; it compares hostnames and not schemes, so an https-to-http
hop keeps the credential and loses the encryption; a cross-host 307 or 308
replays the body, which here is the caller's prompt; and a 302 on a POST becomes
a GET, so a dropped write would return 200. All of those, plus a chain that never
terminates, are ErrUnsafeRedirect. A caller supplying its own CheckRedirect keeps
it and owns that.

Plain http with a credential is rejected unless the host is loopback or the
caller passes WithInsecureCredentialTransport, and a base URL carrying userinfo
is rejected rather than quietly becoming Basic auth on every request. No error
from New echoes the base URL's password.

APIError.Error renders the status, code, message and request id, never the body:
a non-2xx body need not come from this API at all, and an auth proxy can put a
CSRF token or an echoed request header in it. Body stays on the struct for the
caller who wants it.

Four types are hand-written because the server documents neither their routes nor
their schemas, so nothing exists to generate them from and no drift gate covers
them: SessionCreateRequest, SessionEventInput, EventAccepted and
ElicitationResult. The README and doc.go both say so.

CI
--

.github/workflows/go-sdk.yml builds, vets, lints and race-tests the module.
lint.yml gains a go-client-fresh check that regenerates the bindings and
compares, so editing openapi.json without regenerating fails rather than shipping
stale types. It fails closed when oapi-codegen is absent. dependabot watches the
nested module for security updates only.

255 test cases, race-tested. The README's three lookup-and-approval recipes were
compiled verbatim in a fresh module, as its quickstart was.

The listings went through an independent four-lens review before this landed --
systems, security, idiom and prose, briefed blinded with one assigned to argue
the design was wrong. It found two defects in the documented recipes, both since
fixed and both now covered by a regression test: the paging loop could not
terminate independently of the server's good behaviour, and the session-reconcile
recipe read only the first page, so a program with more sessions than one page
would create the duplicate that recipe exists to prevent. Neither was a defect in
the code the recipes drive. Nothing outside
sdks/, scripts/, tests/scripts/ and those CI files changes; sdks/README.md
loses one line that said the directory holds Python packages.

Signed-off-by: bdchatham <bdchatham@gmail.com>
@bdchatham
bdchatham force-pushed the feat/go-client-sdk branch from 194e5f4 to 0417b51 Compare August 4, 2026 15:41
@bdchatham

Copy link
Copy Markdown
Contributor Author

Correction: two defects in the documented recipes, found by review and fixed

I put the listings through an independent multi-lens review after pushing them, which was the wrong order — the two defects below were live on this PR for a few hours. No review had started, so nothing is wasted, but I'd rather say that plainly than quietly force-push.

Four reviewers, briefed independently on the same diff, one of them explicitly assigned to argue the change was wrong. Two blocking findings, both in prose rather than code:

1. The paging loop in Page's doc comment could not terminate independently of the server. A server answering {"data":[],"has_more":true} yields an empty LastID, so After = page.LastID re-requests the first page forever. Reproduced: 20 iterations, cursor never advancing.

The root cause was a sentence I had written on Page.HasMore"an empty page does not by itself imply the end" — which described a server behaviour that does not exist. The store computes has_more = len(rows) > limit, so has_more: true with zero rows is impossible. I had written the recipe to be safe under my own invention instead of under the real contract. Both halves are fixed: the sentence now states the actual invariant, and the loop breaks on !page.HasMore || len(page.Data) == 0 so termination is the caller's property rather than a bet on the server.

2. The session-reconcile recipe in the README read only the first page. limit defaults to 20 and there is no server-side label filter, so the label is matched client-side. A program on its tenth run would not find its own earlier session, conclude none existed, and create the duplicate — which is the specific harm that recipe exists to prevent, and the reason CreateSession is deliberately never retried. Confirmed against a 21-session server: it scanned 20 and missed the target. It is now a paging adopt(...) function using the same idiom the agentID example above it already used.

Both fixes carry a regression test, and both tests are mutation-proven rather than merely green: reverting the guard makes the first fail, reverting to a single-page scan makes the second fail.

Smaller items from the same review, all fixed:

  • The approval example accepted every prompt. In an unattended loop that grants the agent the session owner's execution identity for whatever it thought to ask, so it now decides via an explicit policy and defaults to declining.
  • ApprovalVerdict's doc now says it validates nothing and points at ResolveElicitation, which does.
  • ListSessionsOptions.SearchQuery now notes it travels as a query parameter and lands in access logs.
  • The include_in_schema=False route description no longer leans on the unexplained term "url mode"; it describes the mechanism instead.
  • gen_go_client.py's comments referenced AgentPage/SessionPage, which don't exist — leftovers from before I chose Page[T]. They now name the real shapes.
  • The adopt contract comment claimed "" meant "first run". It doesn't: archived sessions are excluded by default, so a prior run whose session was archived also returns "". Reworded. This one was introduced by the fix and caught in the same pass.

Four other findings were raised and I did not act on them, because each turned out to be wrong when checked against the server rather than reasoned about — recorded here so the checks are visible: has_more is direction-aware under before (the cursor comparison flips with is_desc); the "identical resolution semantics" claim is accurate (both routes call the same _resolve_elicitation, with the ownership check inside it); agent_id + agent_name do combine as two conditions on one column; and the godoc links resolve.

Two items are deferred rather than dropped, both pre-existing or generated:

  • The success-path JSON decode has no size cap while the error path caps at 64 KiB. Not introduced here — the listings only add consumers — and the cap is a public behavioural decision that deserves its own change.
  • Some generated descriptions publish internal references to pkg.go.dev, including a designs/… path that does not ship inside the nested module zip and so renders dead. Same class as the five omnigent.* module paths already disclosed above, and the same fix shape: a substitution table, not a wider pattern.

Verification on the pushed state: gofmt clean, go vet ok, golangci-lint 0 issues, go mod tidy -diff clean, go test -race -count=3 green at 255 cases, generator --check byte-exact, 57 generator unit tests passing, and all three README recipes recompiled verbatim in a fresh module after the rewrite.

…/robustness defects

A parity audit against sdks/python-client, with the server source and the spec
as ground truth, found six things. Every fix below was justified by reading the
server rather than by reasoning from the SDK's own description of it — because
three of the six findings were that the SDK's description is wrong.

The documentation defects are first because they are the consequential ones. A
consumer followed this SDK's guidance into misdiagnosing a deliberate
server-side filter as a platform defect, and lost days to it.

- The documented turn lifecycle began with response.created. That event never
  reaches a subscriber: it is filtered at the publish chokepoint in
  runner/app.py. The lifecycle now describes what a subscriber actually sees.
- The replay-prologue description was wrong for a terminal-pane harness, which
  carries deltas rather than an envelope.
- The canonical event loop in doc.go and the README returned on the terminal
  event. For a terminal-pane harness the authoritative transcript item is
  posted by a poller and can arrive afterwards, so that loop gets nothing. The
  example now works for both harness families, and the relationship between an
  agent's harness and whether it streams at all is documented where a reader
  meets it before writing a loop.

Also documented: the item-data union is an anyOf of eleven variants with no
discriminator, so a generated AsXxxData accessor succeeds on the wrong variant
and returns a zero value. Callers must gate on the sibling type field. That is
a spec-level defect every generated client inherits; documenting it is the only
fix available here.

The behavioural defects:

- The default whole-exchange timeout was 30s against a server that legitimately
  takes up to ~65s on a message forward and ~40s on a create. Now 90s, with an
  exported option. Two things about this were not obvious: the audit's own
  estimate of ~35s was low, because the generic runner forward carries a 60s
  read budget the audit had not cited; and raising the whole-exchange bound
  alone would have been a no-op, since the transport's response-header timeout
  was a fixed 30s and binds first on exactly these routes.
- Elicitation resolution ignored the target session id, so a mirrored
  sub-agent prompt was accepted and never resolved. It redirects now. Stated
  honestly as "can silently fail" rather than "always fails", because the
  server still forwards to the bound runner on a mismatch.
- The stream idle watchdog reset per complete line, contradicting its own doc.
  Since one frame is one line, that bounded a whole frame's transfer by the
  idle timeout, so a healthy stream delivering a large snapshot frame slowly
  could be torn down. It now measures silence, which is what the doc claimed.
- A single undecodable frame ended the subscription. It is skippable now, with
  the raw payload surfaced. Reachability is nil against this server and the
  comment says so rather than implying a live bug — every event is validated
  through the same union before serialization. A guard keeps this from
  reintroducing the reference client's silent-empty-stream mode: skipped frames
  with no delivered event is an error, not a clean end.

New capability: the paginated items route. The session snapshot is capped at
the newest 100 items server-side, and that cap truncates the recovery path this
package's own documentation prescribes.

Deliberately unchanged: the strict 200-only stream gate, 2xx-only unary gate,
[DONE] handling, multi-line data rejoin, frame size bounds, credential
redaction, plaintext-credential refusal and the redirect policy. The audit
found several of these stricter than the reference, and one of them is why a
proxy answering 302 cannot give this client the silent zero-event stream it
would give the reference.

Signed-off-by: bdchatham <bdchatham@gmail.com>

@ghoshp83 ghoshp83 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read through the hand-written surface (client.go, stream.go, event.go) at 04c9e71 — not the generated files. This is unusually careful work: the monotonic-clock idle watchdog that re-reads a timestamp instead of trusting Timer.Reset, bounding the frame and not just the line so a server that never closes a frame can't grow the heap, dispatching on the payload's own type rather than the event: line, and the UnknownEvent forward-compat path are all the right calls. A few things I'd raise, one of them security-relevant.

  1. checkRedirect doesn't reject userinfo in the redirect target, though New carefully rejects it in the base URL. New refuses a base URL with userinfo precisely because "net/http turns userinfo into an Authorization: Basic header on every request" — but on a redirect, req.URL is parsed from Location, and a same-host Location: https://evil:pw@host/… passes all three checkRedirect gates (sameEndpoint compares only hostname/port; scheme unchanged; method unchanged). net/http then synthesizes Authorization: Basic … from that userinfo on the replayed request — but only when no Authorization header is already set, so WithBearerToken callers are safe while WithAuthHeader (trusted-proxy) and WithSessionCookie callers would have an attacker-chosen Basic credential attached. It's the same silent-Basic-auth vector New exists to close, reintroduced one layer down, and it fits this PR's own threat model (an untrusted Location) exactly as much as the off-host and downgrade cases you do guard. redirect_test.go doesn't seem to cover it. Suggest a case req.URL.User != nil: arm in checkRedirect returning ErrUnsafeRedirect, mirroring New.

  2. The method-rewrite gate also rejects a spec-legitimate 303. case req.Method != origin.Method treats any method change as unsafe — correct for a 307/308 that would silently drop the body (nicely tested), but a 303 See Other defines a POST→GET change and isn't a dropped-write bug. If the server never legitimately answers a POST /events or session-create with a 303, this is the right fail-closed default and worth a one-line comment saying so; if it might, this would refuse a valid response. Which is it?

  3. Minor: sameEndpoint hostname comparison is case-sensitive. origin.Hostname() != next.Hostname() would treat a same-host redirect that differs only in DNS case (Host vs host) as off-host and block it. Fail-closed, so not a risk — just a possible surprise on an otherwise-legitimate redirect; a strings.EqualFold would match the case-insensitivity you already apply in isLoopback.

Nothing here is a blocker — the first comment is the only one I'd call load-bearing. Happy to send a PR against your branch for the userinfo case with a test if that's useful.

@github-actions github-actions Bot added the P2-medium Priority: bug with workaround, important feature request label Aug 5, 2026
Review feedback from @ghoshp83 on omnigent-ai#4010. All three points taken.

The load-bearing one: checkRedirect did not apply the rule New applies. New
refuses a base URL carrying userinfo because net/http turns it into an
Authorization: Basic header — and a server-controlled Location was not held to
the same standard. A same-host https://user:pw@host/… cleared every existing
gate, since sameEndpoint compares hostname and port, the scheme was unchanged
and the method was unchanged. net/http then synthesizes Basic from that userinfo
onto the replayed request, but only when no Authorization header is already set:
so a bearer-token caller was incidentally safe while WithAuthHeader and
WithSessionCookie callers would have carried an attacker-chosen credential to
the real server. Same vector New exists to close, one layer down, and squarely
inside this package's stated threat model of an untrusted Location.

Rejected rather than stripped, on the same principle as the off-host case: a
response is not entitled to name a credential any more than it is entitled to
name a different host.

The test uses WithAuthHeader deliberately. A bearer caller is safe by accident
here, so a test written that way would pass with the fix reverted and prove
nothing.

On the 303 question: this server never answers 303. There is no See Other
anywhere in it, and its only redirects are 302s on the browser login routes,
which an API client does not call. So the method gate stays fail-closed and now
says why, rather than leaving a reader to wonder whether a legitimate POST→GET
was being refused.

sameEndpoint now folds case when comparing host names, matching what isLoopback
already did. DNS is case-insensitive, so the old comparison would have refused a
legitimate same-host redirect differing only in case — fail-closed, so a refused
redirect rather than a leak, but still wrong.

Both new guards are mutation-proven: reverting each fails its own test and
nothing else.

Signed-off-by: bdchatham <bdchatham@gmail.com>
@bdchatham

Copy link
Copy Markdown
Contributor Author

Thanks — all three taken, pushed as 8324a5ec (rebased onto the main merge that landed while I was writing this).

1. userinfo in the redirect target — fixed, and you're right that it was the load-bearing one

Your read is exactly right, including the part that makes it subtle: net/http only synthesizes Authorization: Basic when no Authorization header is already set, so a WithBearerToken caller is safe by accident while WithAuthHeader and WithSessionCookie callers would carry an attacker-chosen credential to the real server. I'd guarded the host, the scheme and the method and left the one vector New exists to close reachable one layer down.

Added the case req.URL.User != nil: arm you suggested, rejecting rather than stripping — same principle as the off-host case, that a response isn't entitled to name a credential any more than it's entitled to name a different host.

The test is worth a look because the obvious version of it is useless: it uses WithAuthHeader, not WithBearerToken. A bearer caller passes this test with the fix reverted, so writing it that way would have proved nothing. Mutation-checked — reverting the arm fails that test and nothing else.

2. The 303 question — the server never answers one

Checked rather than assumed. There is no 303, HTTP_303 or SeeOther anywhere in the server; the only redirects it issues are 302s in routes/auth.py and routes/accounts_auth.py, i.e. the browser login flow, which an API client never calls. So fail-closed is the right default and I've said so in a comment rather than leaving the next reader to wonder whether a legitimate POSTGET was being refused.

Worth noting the gate earns its keep beyond 307/308: a 302 on a POST is rewritten to GET by every client there is, and the resulting 200 decodes into a perfectly plausible acknowledgement for a write the server never received. That's what TestSendInputCannotReportADroppedWriteAsSuccess covers.

3. Host case — fixed

strings.EqualFold, matching the folding isLoopback already applied. Agreed it was fail-closed and therefore a refused-legitimate-redirect bug rather than a leak, but it was still wrong. Table test over same-case, differing-case and genuinely-different-host; mutation-checked the same way.

On your offer

Appreciated, and please do send PRs against the branch whenever it's faster for you than a comment — you clearly have context on this code that I don't. This one was small enough that a round trip seemed worse than just doing it.

One thing you may want to know, since it came out of the same audit that produced this PR's other commit: I compared this client against sdks/python-client with the server as ground truth, and a few of the differences run the other way. Python's [DONE] check is unreachable — it's nested under a branch requiring a preceding event: line, and the server emits a bare data: [DONE] — and it accepts any status below 400 with follow_redirects=False, so a proxy answering 302 on the stream GET gives a Python caller a silent, error-free, zero-event stream. Its _CODE_MAP also looks like dead code. Happy to write those up separately if useful; they're not this PR's business.

@bdchatham
bdchatham requested a review from ghoshp83 August 5, 2026 14:48

@ghoshp83 ghoshp83 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the fix at 8324a5e, not just the writeup — all three are in and correct.

The userinfo arm lands where it should (after the scheme-downgrade gate, before the method check), and the test is the right version of the test, which is the part I care about most: WithAuthHeader, not WithBearerToken, and it asserts the moved endpoint was never reached — so it fails if the arm is reverted and would prove nothing written the naive way. That's the whole finding, closed properly.

On the 303: agreed, and the comment is the right resolution — the value was never in changing behaviour, only in a future reader not having to re-derive whether a legitimate POST→GET was being refused. Same for the EqualFold host-case fix.

Approving. Scope: I read the hand-written surface (client.go, stream.go, event.go) and this fix; I haven't audited the generated types or the e2e harness, so this is an approval of the credential-redirect policy and the SSE/dispatch path, not a line-by-line of all 34 files.

On the python-client differences you found — yes, worth writing up, and I'm happy to take them since they're out of scope here. The unreachable [DONE] check, and the follow_redirects=False + status<400 path that turns a proxy's 302 into a silent zero-event stream, are both real silent-failure bugs; I'll open them separately against main with tests so this PR stays focused.

@fanzeyi

fanzeyi commented Aug 5, 2026

Copy link
Copy Markdown
Member

Hi @bdchatham,

Thanks for the contribution but I think we probably don't want to take this at the moment. We do understand the need of having a Go SDK but the team lacks expertises in Go so we can't be maintaining this code effectively to ensure its quality.

Do you think you can start an omnigent-go-sdk project maintained by you? This way you won't get blocked by our code review bandwidth and you can make changes more freely to accommodate your projects faster. We would be happy to add a link to your project in our docs as well.

Thank you!

@bdchatham

Copy link
Copy Markdown
Contributor Author

Thanks for the feedback @fanzeyi - here is the repo where I'll be managing our Go SDK implementation: https://github.com/sei-protocol/omnigent-go-sdk

@bdchatham

Copy link
Copy Markdown
Contributor Author

Closing this in favour of https://github.com/sei-protocol/omnigent-go-sdk, per the suggestion that it live in our organisation rather than here. The code is seeded there at v0.1.0, with your review feedback included.

@ghoshp83 — thank you for that review; it was the most useful one this code got, and all three points landed:

  1. The userinfo hole in checkRedirect was real and is fixed. Your read of the mechanism was exactly right, including the part that makes it subtle: net/http only synthesises Authorization: Basic when no Authorization header is already set, so a bearer caller was safe by accident while WithAuthHeader and WithSessionCookie callers would have carried an attacker-chosen credential to the real server. The test uses WithAuthHeader deliberately — written with a bearer it passes with the fix reverted and proves nothing.
  2. The 303 question: checked rather than assumed. The server never answers 303 — no See Other anywhere in it, and its only redirects are 302s on the browser login routes, which an API client never calls. Fail-closed is deliberate and now says so in a comment.
  3. Host case: strings.EqualFold, matching the folding isLoopback already did.

One thing you may want regardless of this PR closing, since it is your code and not ours. Comparing this client against sdks/python-client with the server as ground truth, a few differences run the other way:

  • Python's [DONE] sentinel check is unreachable — it is nested under a branch requiring a preceding event: line, and the server emits a bare data: [DONE]. Its docstring says it fires.
  • It accepts any status below 400 with follow_redirects=False, so a proxy answering 302 on the stream GET gives a Python caller a silent, error-free, zero-event stream.
  • _CODE_MAP in _errors.py looks like dead code.

Happy to write those up as an issue here if useful — they are not this PR's business, and they are worth someone's attention independently of where the Go client lives.

@ghoshp83

ghoshp83 commented Aug 5, 2026

Copy link
Copy Markdown

Congrats on the new home for it — nice that it landed at sei-protocol/omnigent-go-sdk, and thanks for the kind words on the review; it was a pleasure to read code that careful.

On the python-client differences you flagged: since they're independent of where the Go client lives, I'll take the redirect one — opening a PR against omnigent shortly that makes the stream open fail loud on a 3xx instead of decoding it as an empty stream, with a regression test. _CODE_MAP looks like a clean separate cleanup.

ghoshp83 added a commit to ghoshp83/omnigent that referenced this pull request Aug 5, 2026
The SDK's httpx.AsyncClient uses httpx's default follow_redirects=False, and the
stream-open guard only rejected status >= 400. A proxy or gateway answering a
3xx (e.g. 302) on the stream request therefore fell through to the SSE parser,
which found no data: frames in the redirect body and yielded nothing — handing
the caller a silent, error-free, zero-event stream instead of surfacing the
connection that never reached the server. _stream_session_events already
documents ":raises OmnigentError: If the server returns a non-2xx status", so
this is the redirect half of a contract the rest of the sessions namespace keeps.

Reject a 3xx on the stream open at both sites — SessionsNamespace.stream (via
_stream_session_events) and the deprecated /v1/responses stream — raising
OmnigentError rather than decoding an empty stream. Adds a regression test that
returns a 302 from an httpx.MockTransport and asserts the open raises instead of
completing with zero events; it fails against the old >= 400 guard.

Spotted by @bdchatham while comparing this client against the Go client during
review of omnigent-ai#4010.

Signed-off-by: ghoshp83 <pralay.ghosh@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-demo PR needs a demo screenshot or recording P2-medium Priority: bug with workaround, important feature request size/XL Pull request size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] No Go client for the session API, so every Go caller hand-rolls the SSE and redirect handling

4 participants