Skip to content

fix(cloud): conform rho to the GrayCode Cloud wire contract and harden the client - #342

Open
Patel230 wants to merge 16 commits into
mainfrom
fix/cloud-client-contract
Open

Patel230 wants to merge 16 commits into
mainfrom
fix/cloud-client-contract

Conversation

@Patel230

Copy link
Copy Markdown
Contributor

Summary

This PR aligns rho's GrayCode Cloud client with the platform wire contract (campaign spec §3; the platform contract is canonical) and hardens it:

  • Contract: device start sends {label, platform, graycodeVersion} (strict, bounded in UTF-16 units). Usage sends capability: "rho" and clamps durationMs to 86 400 000 plus the other counter and text bounds. Poll treats expired and 409 consumed as terminal, with clear messages.
  • Endpoint: new connections default to https://cloud.graycodeai.com, overridable with --endpoint / RHO_CLOUD_URL. Non-TLS is refused except http://localhost, http://127.0.0.1 and http://[::1] (any port), whether the endpoint comes from a flag, an env var or a saved cloud.json. Redirects are never followed.
  • Errors: every non-2xx body is read with a 16 KiB bound, and its JSON error/code/status fields are surfaced after sanitizing for the terminal. rho cloud status distinguishes "not connected" from a broken config or keychain. rho cloud context and graph sync report real failures. The automatic rho exec usage upload stays fail-open, but it now finishes before exit (bounded to 3 s) and a server rejection shows as a one-line warning.
  • Secrets: rho cloud connect reads the token from --token-stdin or a hidden prompt. --token still works but is deprecated with a warning. On Linux and other platforms without an OS store, the 0600 plaintext token file is used with a warning, never silently.
  • Graph parity: the safe-suffix check is case-insensitive, the exact key sast_source is exempt, tenant_id in any scope is rejected, and attribute bounds are enforced. A shared fixture keeps the cloud client and the daemon mirror in sync.
  • Branding: user-facing text says "GrayCode Cloud". The README cloud section is rewritten.
  • Tests: a new httptest "contract Worker" enforces the Worker's zod schemas (strict unknown keys, enums, lengths in UTF-16 units, integer bounds, opaqueID regex and min 16, portable-graph shape) and drives every real client path through it.

Findings addressed

  • F226: rho no longer accepts non-TLS endpoints. NormalizeEndpoint is applied in cloud.New (a single request builder refuses to send), in SaveDeviceConfig and in LoadClient (before the token is read). Redirects are refused because Go forwards Authorization on same-host redirects. (9cb92bb)
  • F012: new connections default to https://cloud.graycodeai.com and connect honors RHO_CLOUD_URL (07ee975). status prints real load errors and exits non-zero; context and graph sync return the actual error (f4e3fd4).
  • F191 (rho side): the default endpoint is named in rho cloud login --help, in the --endpoint usage and in the README (07ee975, e51593f).
  • F194: device start, poll and graph sync surface the Worker's JSON error through a bounded read. 409 consumed and expired map to ErrDeviceLoginConsumed / ErrDeviceLoginExpired, each with a rerun hint (bd6fe33).
  • F187: rho cloud context uses SendDeliveryContext. It validates against the schema (status enums, lengths, opaque project ID) and reports 400/401/404. "synced" is printed only after a 2xx (e8d4c30).
  • F244: device token input is --token-stdin or a hidden prompt; --token is deprecated and hidden (caf28d3).
  • F245: chosen policy is to keep the 0600, symlink-refusing, atomic plaintext fallback but never use it silently. login/connect warn with the file path, and status shows where the token is stored. It is documented in the SecureStorage doc comment and the README (85f9edd).
  • F246 and the rho half of F188: graph sanitizer parity plus a shared fixture internal/platform/cloud/testdata/graph_attribute_policy.json, used by both the cloud and daemon tests (0124c42).
  • Rho half of F184: sends graycodeVersion and validates the start response and approval URL (7f3ced0).
  • Rho half of F200: usage events are clamped to the schema (b9db958).
  • Rho half of F185: the rho exec usage upload used to die with the process (go RecordUsage followed by exit). It is now awaited with a bound (a5acb4f).
  • §3.6 "GrayCode Cloud" in all user-facing text (fda96de).
  • The contract conformance suite is in c0f9a9b. A 15 s default request timeout for explicit commands is in 4e4b3d4.

Also fixed in passing:

  • Windows: the credential reader returns PowerShell output with a trailing CRLF, which produced an invalid Authorization header. The token is now trimmed.
  • rho session share: the hint pointed at a non-existent "Rho Cloud share endpoint".

Not reproduced / deferred

  • None of the assigned findings were deferred. Secret Service (libsecret) integration for Linux is a follow-up; the spec allows either "explicit opt-in" or "0600 with a clear warning", and this PR takes the second.
  • The platform halves of F184, F185, F188 and F200 belong to PL-contract (graycode-platform).

Verification

All commands were run in this branch's worktree on darwin/arm64, at HEAD 4e4b3d4:

  • GOWORK=off go build ./...: exit 0
  • GOWORK=off go vet ./...: exit 0
  • go run mvdan.cc/gofumpt@v0.10.0 -l $(git ls-files '*.go'): no output
  • GOWORK=off golangci-lint run ./... (v2.1.0): 0 issues.
  • GOWORK=off make boundaries: all guards passed. The manifest guard prints "workspace repository is not checked out; skipping filesystem checks" for rho and flux because the worktree is outside the graycode-eco/ layout.
  • GOWORK=off go test -race -count=1 -p 2 ./...: exit 0, 152 packages ok, 0 FAIL. This includes cmd, internal/platform/cloud, internal/auth, internal/daemon and internal/mcp. A first run at default parallelism was invalid because the machine's disk filled (other workloads); every failure in it was "no space left on device". It was re-run with -p 2.
  • markdownlint-cli2 README.md: 0 issues
  • The root-help golden was regenerated; the only diff is the cloud line.
  • The daemon parity test was confirmed to fail on the old case-sensitive regex (FILE_COUNT, Model_Tokens, file_Sha256, evidence_DIGEST).
  • make api-validate was not needed; api/ is untouched.
  • cloud.graycodeai.com does not resolve today (curl: (6) Could not resolve host). The README says the service is alpha and still rolling out.

Follow-ups

  • graycode-platform (PL-contract):
    • Make the worker safe-suffix regex case-insensitive.
    • Add rho to the capability enum and keep graycode as a legacy alias.
    • Raise durationMs max to 86 400 000.
    • Adopt graph_attribute_policy.json in the worker tests.
  • RH-docs:
    • docs/design/three-repo-architecture.md:36 still says "no default URL".
    • README lines outside the cloud section (Architecture / Ecosystem table), docs/architecture/execution-graph.md and the ADRs still say "Rho Cloud".
  • Security follow-ups:
    • Secret Service (libsecret) storage on Linux.
    • MCP OAuth tokens (internal/mcp/oauth_store.go) share the same plaintext fallback without a warning.
    • There is no rho cloud logout / revoke command yet.
  • rho session share (RHO_SHARE_URL) posts session exports to any URL, including http://.
  • The credentials help text claims "Linux secret service"; verify it.

🤖 Generated with Claude Code

Patel230 and others added 16 commits September 27, 2026 03:31
The cloud client accepted any endpoint string and attached the long-lived
device token as a Bearer header to whatever URL was configured, including
plain http:// hosts, so a mistyped or poisoned RHO_CLOUD_URL leaked the
token to any on-path observer (F226).

NormalizeEndpoint now accepts https:// URLs, and http:// only for the
loopback hosts localhost, 127.0.0.1 and [::1] (any port) so a local
development Worker still works. URLs with credentials, a query or a
fragment are rejected. The check runs in cloud.New (every request is now
built by one helper that refuses to send when the endpoint was refused),
in SaveDeviceConfig, and in LoadClient before the token is read, so an
older or tampered cloud.json cannot route the token over plaintext.

The HTTP client also refuses redirects: Go forwards Authorization on a
same-host redirect, so a 307 to http:// would have replayed the token and
body in clear text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cloud login, connect, status and context were package-level cobra
commands whose flags were registered in init, so a test could only drive
them through the shared rootCmd and would leak flag values into later
tests. Build each subcommand in a constructor (as cloud graph already
does) and register the instances in init. No behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`rho cloud login` failed on first run with "endpoint is required" and no
hint of the real URL, and nothing in rho named the hosted API (F012,
F191). `rho cloud connect` also ignored RHO_CLOUD_URL.

Both commands now resolve the endpoint as --endpoint, then RHO_CLOUD_URL,
then https://cloud.graycodeai.com, and validate it with the TLS policy.
The default applies only to creating a connection: an existing
connection keeps using the endpoint saved in cloud.json, because its
device token is bound to the issuer. The login help and --endpoint usage
name the default and the override.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Device login start and poll returned only resp.Status ("400 Bad
Request") and discarded the Worker's JSON body, and a poll that raced a
prior successful poll surfaced HTTP 409 {"status":"consumed"} as a bare
"409 Conflict" (F194).

A shared readAPIError now reads at most 16 KiB of any non-2xx body and
returns an *APIError carrying the JSON error/code/status fields,
sanitized for the terminal (control characters and escape sequences
dropped, length capped); a non-JSON body falls back to the HTTP status.
Successful responses are decoded through a 64 KiB bound. Device start,
poll and graph sync use it, and a 401 adds a reconnect hint.

PollDeviceLogin treats "expired" and "consumed" (HTTP 409 or 200) as
terminal and returns ErrDeviceLoginExpired / ErrDeviceLoginConsumed,
each telling the user to rerun `rho cloud login`; an unknown status is
an error. The login loop only waits on "pending".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The GrayCode Cloud device-start schema is strict
{label 1-100, platform 1-50, graycodeVersion 1-50}, but rho sent
rhoVersion, so every `rho cloud login` got HTTP 400 (rho half of F184;
the platform contract is canonical).

StartDeviceLogin now marshals a typed deviceStartRequest with exactly
label, platform and graycodeVersion (rho's own version string). Each
field drops non-printable characters and is cut to the contract bound in
UTF-16 code units, as zod counts them, with a fallback when empty (for
example an empty hostname becomes "rho").

The start response is validated before rho opens a browser: device code,
user code (letters, digits and '-') and verification URI are required,
and the URI must pass the same TLS policy as the API endpoint. The
approval URL is built with url.Values instead of string concatenation.
An "approved" poll without token, device ID or project ID, or with
whitespace in the token, is rejected in the client. The poll interval is
clamped to 1-30 seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`rho cloud context` called the fail-open RecordDeliveryContext, which
ignored the response, and then always printed "Repository context queued
for Rho Cloud." A 400 (invalid status or field), 401 (revoked token) or
404 (project mismatch) therefore looked like success to users and CI
jobs (F187).

The explicit command now calls SendDeliveryContext, which trims fields
like the Worker, validates the event against the delivery-context schema
(opaque project ID, required and maximum lengths, CI and deployment
status enums) before sending, and returns transport errors and any
non-2xx response as an error with the Worker's message. The command
validates --ci-status and --deployment-status up front, lists the
allowed values in their help, and prints "synced" only after GrayCode
Cloud accepted the context. The unused fail-open RecordDeliveryContext
is removed; there is no automatic delivery-context caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mmands

`rho cloud status` printed "Rho Cloud is not connected." for every
LoadClient error, and `cloud context` / `cloud graph sync` replaced the
real error with "rho cloud is not connected", so a corrupt cloud.json or
a keychain failure looked like a device that was never connected (F012).

LoadClient now returns ErrNotConnected only when no connection was saved.
A corrupt or incomplete cloud.json, a missing device token and a
credential-store failure each return a descriptive error. status prints
"not connected" (exit 0) only for ErrNotConnected and otherwise returns
the error (non-zero exit); context and graph sync return the load error
as is.

LoadClient also trims the stored token: the Windows credential reader
returns PowerShell output with a trailing CRLF, which would have made an
invalid Authorization header. The token store is behind an interface so
tests never touch the real OS keychain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The /v1/usage schema is strict and bounded (durationMs up to 86400000
after the platform raises it to 24 h, token counters up to 10M, text
fields up to 100), but rho sent `rho exec` durations and counters
unbounded, so a long mission or best-of-N run was rejected with HTTP 400
and, because RecordUsage discarded every response, silently lost (rho
half of F200).

RecordUsage now clamps the event before sending: durationMs to
0..86400000, token counters to 0..10M, estimated cost to 0..10^10,
provider/model/errorCode to 100 UTF-16 units; an unknown status is
dropped, a missing capability defaults to "rho" (exported as
CapabilityRho) and a missing timestamp is set. It returns an error
(ErrNotConnected, transport error, or *APIError for a non-2xx) so the
automatic caller can surface a rejection; `rho exec` still ignores it,
which keeps usage upload fail-open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`rho exec` started the usage upload with `go client.RecordUsage(...)`
and returned. main then returned or called os.Exit, which kills the
goroutine, so the upload (a TLS handshake plus a POST) almost never
finished and connected devices recorded no usage even with a correct
payload (rho half of F185).

startCloudUsage now runs the upload in the background while exec
finishes its work and returns a wait function that exec defers. The
wait is bounded by a 3-second context on the request, so a slow or
unreachable GrayCode Cloud cannot hold the process open, and the result
of exec never changes. A server rejection (*APIError) is printed as a
one-line warning on stderr unless --quiet, so a contract break is no
longer invisible; transport errors (for example offline) stay silent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`rho cloud connect --token <token>` put the long-lived device token on
the command line, where it lands in shell history and is visible to
every local user via ps for the life of the process (F244).

connect now reads the token from --token-stdin (bounded to 4 KiB) or,
when stdin is a terminal, from a hidden prompt (term.ReadPassword on
stdin, prompt text on stderr). --token keeps working for existing
scripts but is marked deprecated and hidden, so cobra prints a warning
pointing at --token-stdin. The token must be a single line without
spaces. --device-id and --project-id are checked against the opaque-ID
schema before any prompt, and `rho cloud login` stays the recommended
path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Linux (every platform except macOS and Windows) SecureStorage writes
secrets to ~/.rho/.tokens as plaintext JSON, and nothing told the user:
the code comment even claimed "keyring handled by flux layer", which is
not true for this package (F245).

Chosen policy (documented in the SecureStorage doc comment and the
README): keep the 0600, symlink-refusing, atomically written fallback
file, but never use it silently. The auth package now exposes
UsesPlaintextTokenFile, TokenFilePath and CredentialStoreName.
`rho cloud login` and `rho cloud connect` print a warning naming the
file every time they save a device token there, and `rho cloud status`
shows where the token is stored and flags the plaintext file. An
explicit opt-in gate was rejected because it would break every headless
Linux login by default; Secret Service integration is a follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rho's PrepareGraph and the Worker disagreed on what a portable graph
may carry (F246, rho half of F188): rho never checked scope.tenant_id,
which the Worker rejects; rho hashed `sast_source`, which the Worker
exempts; and rho's own daemon mirror of the cloud policy matched the
safe suffix case-sensitively. Such graphs passed rho's pre-flight and
then failed with an opaque 400 "Graph contains non-portable or sensitive
metadata".

The policy is now the same on every side (the platform makes its safe
regex case-insensitive in its own PR):
- the safe-suffix check (_sha256|_digest|_count|_tokens?|token_count) is
  case-insensitive in PrepareGraph and in the daemon mirror;
- the exact key "sast_source" is exempt from hashing (it marks
  whether a finding came from static analysis, not source text);
- PrepareGraph rejects a tenant_id in the document scope or any
  node/edge/event scope, with a message that says why;
- attribute keys (1-64 after the _sha256 suffix), values (up to 512)
  and the count (up to 64) are checked with the same bounds as the
  portable-graph schema.

testdata/graph_attribute_policy.json pins the shared expectations and
is consumed by both the cloud client and the daemon tests, so the two
copies cannot drift again; the worker can adopt the same fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hosted control plane is GrayCode Cloud; "Rho Cloud" is a retired
name. Rename it in every rho cloud command description, flag help,
progress step and status line, the graph-sync bound errors, and the
package doc, and regenerate the root help golden (one line).

The `rho session share` hint told users to point RHO_SHARE_URL at "a
Rho Cloud share endpoint", but GrayCode Cloud serves no /v1/shares
route, so it now describes the share service it actually needs.
Command names (`rho cloud ...`) are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…orker

Nothing in rho pinned the wire contract, which is how rhoVersion vs
graycodeVersion and the capability/duration mismatches shipped. Add an
httptest stand-in for the Worker that validates every request the way
its zod schemas do: strict objects (unknown keys rejected), enums
(capability rho|graycode, usage/CI/deployment statuses, graph kinds),
string lengths in UTF-16 code units (trimmed where the schema trims),
integer bounds (durationMs up to 86400000, counters up to 10M), RFC 3339
timestamps, the opaqueID pattern and 16..128 length, the portable-graph
shape, attribute bounds and the sensitive-attribute and tenant rules.

Conformance tests drive the real client paths through it: device start
and poll (including oversized and empty label/version), `rho exec`
usage events (including a 30-hour run), delivery context with CI and a
rolled_back deployment, and a prepared graph with sensitive, upper-case
count and sast_source attributes. TestContractWorkerIsStrict proves the
stand-in rejects rhoVersion, retired capabilities, >24 h durations,
short IDs, fractional counters, unknown keys and tenant scopes, so a
passing suite means the payloads are valid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README said cloud commands had no default endpoint and never named
the hosted API (F191). Replace that paragraph with a GrayCode Cloud
section: the default https://cloud.graycodeai.com and the
--endpoint/RHO_CLOUD_URL override, the TLS rule (loopback http only),
what is sent and that usage upload is fail-open and bounded, where the
device token is stored, including the Linux 0600 plaintext fallback and
its warning (F245), and the stdin/prompt token input for `rho cloud
connect` (F244). It also states plainly that the service is alpha and
still rolling out, since cloud.graycodeai.com does not resolve yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every client used a 3-second whole-request timeout, sized for the
background usage upload. Explicit commands inherited it, so a 1 MiB
`rho cloud graph sync` upload or a first TLS handshake on a slow link
could fail spuriously.

The default per-request timeout is now 15 seconds (DefaultRequestTimeout).
The automatic usage upload is unaffected: startCloudUsage passes its own
3-second context deadline. A caller-supplied http.Client is copied, not
mutated, when the redirect policy is applied (now covered by a test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant