Repository navigation
Commit 28ec3c8
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
File tree
- cmd
- internal
- auth
- daemon
- platform/cloud
- testdata
- testdata/golden
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
144 | 144 | | |
145 | 145 | | |
146 | 146 | | |
147 | | - | |
148 | | - | |
149 | | - | |
150 | | - | |
| 147 | + | |
151 | 148 | | |
152 | 149 | | |
153 | 150 | | |
| |||
156 | 153 | | |
157 | 154 | | |
158 | 155 | | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
159 | 194 | | |
160 | 195 | | |
161 | 196 | | |
| |||
0 commit comments