Skip to content

Commit 28ec3c8

Browse files
authored
fix(cloud): conform rho to the GrayCode Cloud wire contract and harden the client (#342)
* fix(cloud): require TLS for GrayCode Cloud endpoints except loopback 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. * refactor(cloud): build cloud subcommands from constructors 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. * feat(cloud): default to https://cloud.graycodeai.com for new connections `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. * fix(cloud): surface GrayCode Cloud JSON errors and terminal login states 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". * fix(cloud): send graycodeVersion in the device login start request 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. * fix(cloud): report rejected delivery context from rho cloud context `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. * fix(cloud): report broken connections in cloud status and explicit commands `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. * fix(cloud): clamp usage events to the GrayCode Cloud usage schema 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. * fix(cloud): deliver rho exec usage events before the process exits `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. * fix(cloud): read the device token from stdin or a hidden prompt `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. * fix(auth): stop silently storing the device token in plaintext 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. * fix(cloud): align the graph sanitizer with GrayCode Cloud 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. * fix(cloud): name the hosted plane "GrayCode Cloud" in user-facing text 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. * test(cloud): enforce the GrayCode Cloud request schemas with a fake Worker 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. * docs(readme): document GrayCode Cloud endpoint, data and token storage 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. * fix(cloud): give explicit GrayCode Cloud requests a 15-second timeout 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). ---------
1 parent 3922d0a commit 28ec3c8

32 files changed

Lines changed: 3349 additions & 304 deletions

‎README.md‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,7 @@ rho cloud graph sync <session-id>
144144
rho cloud graph sync --mission-dir /path/to/mission
145145
```
146146

147-
Cloud commands require an endpoint. There is no default: pass `--endpoint` or
148-
set `RHO_CLOUD_URL` to your Rho Cloud worker URL before running
149-
`rho cloud login`. `https://api.graycodeai.com` is the browser BFF and
150-
will reject a device token.
147+
Syncing needs a [GrayCode Cloud](#graycode-cloud-optional) connection.
151148

152149
The export contains metadata and hashes, not prompts, tool arguments/results,
153150
policy reasons, verification evidence, or runtime output. Persisted chat
@@ -156,6 +153,44 @@ and `VerifyPlanExecution` summaries for subsequent graph exports. Mission runs
156153
also persist a portable `mission-graph.json`; the mission form is validated and
157154
synchronized explicitly with the `--mission-dir` variants above.
158155

156+
### GrayCode Cloud (optional)
157+
158+
GrayCode Cloud is the optional, opt-in hosted control plane for device login,
159+
usage, delivery context and graph sync. Rho works fully without it and sends
160+
nothing until you connect. It is alpha and still being rolled out: if
161+
`rho cloud login` cannot reach `cloud.graycodeai.com`, the hosted service is
162+
not available to you yet, and nothing else in Rho is affected.
163+
164+
```bash
165+
rho cloud login # browser device login
166+
rho cloud status # endpoint, device, project, token storage
167+
rho cloud context # sync repository, branch, CI and deployment context
168+
rho cloud graph sync <session-id> # upload a privacy-normalized execution graph
169+
```
170+
171+
- **Endpoint.** New connections use `https://cloud.graycodeai.com` unless you
172+
pass `--endpoint` or set `RHO_CLOUD_URL` (for example a local Worker at
173+
`http://127.0.0.1:8787`). Only `https://` is accepted, except plain
174+
`http://` on `localhost`, `127.0.0.1` and `[::1]`. A saved connection keeps
175+
the endpoint that issued its token. `https://api.graycodeai.com` is the
176+
browser API and rejects device tokens.
177+
- **What is sent.** Once connected, each `rho exec` run reports one usage
178+
event (model, token counts, duration, status). This is fail-open: it never
179+
changes the run's result, and rho waits at most 3 seconds for it after the
180+
output is written. `rho cloud context` and `rho cloud graph sync` are
181+
explicit, and any rejection is reported as an error.
182+
- **Device token.** rho keeps the token in the macOS Keychain or the Windows
183+
Credential Manager. Linux and other platforms have no credential-store
184+
integration yet, so the token is written to `.tokens` in the Rho config
185+
directory (`~/.config/rho/.tokens` by default, or `$RHO_CONFIG_DIR/.tokens`)
186+
as plaintext with mode `0600`. `rho cloud login`/`connect` print a warning
187+
when that happens and `rho cloud status` shows where the token lives.
188+
- **Manual connection.**
189+
`rho cloud connect --device-id <id> --project-id <id> --token-stdin < token.txt`
190+
saves an existing device; without `--token-stdin` it prompts for the token
191+
without echo. `--token <value>` still works but is
192+
deprecated because it exposes the token in shell history and `ps`.
193+
159194
### Multi-Agent Mission Mode (optional)
160195

161196
For larger tasks, decompose work into parallel feature branches (power-user / future team workflows):

0 commit comments

Comments
 (0)