Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
{label, platform, graycodeVersion}(strict, bounded in UTF-16 units). Usage sendscapability: "rho"and clampsdurationMsto 86 400 000 plus the other counter and text bounds. Poll treatsexpiredand 409consumedas terminal, with clear messages.https://cloud.graycodeai.com, overridable with--endpoint/RHO_CLOUD_URL. Non-TLS is refused excepthttp://localhost,http://127.0.0.1andhttp://[::1](any port), whether the endpoint comes from a flag, an env var or a savedcloud.json. Redirects are never followed.error/code/statusfields are surfaced after sanitizing for the terminal.rho cloud statusdistinguishes "not connected" from a broken config or keychain.rho cloud contextandgraph syncreport real failures. The automaticrho execusage upload stays fail-open, but it now finishes before exit (bounded to 3 s) and a server rejection shows as a one-line warning.rho cloud connectreads the token from--token-stdinor a hidden prompt.--tokenstill 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.sast_sourceis exempt,tenant_idin any scope is rejected, and attribute bounds are enforced. A shared fixture keeps the cloud client and the daemon mirror in sync.Findings addressed
NormalizeEndpointis applied incloud.New(a single request builder refuses to send), inSaveDeviceConfigand inLoadClient(before the token is read). Redirects are refused because Go forwardsAuthorizationon same-host redirects. (9cb92bb)https://cloud.graycodeai.comandconnecthonorsRHO_CLOUD_URL(07ee975).statusprints real load errors and exits non-zero;contextandgraph syncreturn the actual error (f4e3fd4).rho cloud login --help, in the--endpointusage and in the README (07ee975, e51593f).consumedandexpiredmap toErrDeviceLoginConsumed/ErrDeviceLoginExpired, each with a rerun hint (bd6fe33).rho cloud contextusesSendDeliveryContext. It validates against the schema (status enums, lengths, opaque project ID) and reports 400/401/404. "synced" is printed only after a 2xx (e8d4c30).--token-stdinor a hidden prompt;--tokenis deprecated and hidden (caf28d3).login/connectwarn with the file path, andstatusshows where the token is stored. It is documented in theSecureStoragedoc comment and the README (85f9edd).internal/platform/cloud/testdata/graph_attribute_policy.json, used by both the cloud and daemon tests (0124c42).graycodeVersionand validates the start response and approval URL (7f3ced0).rho execusage upload used to die with the process (go RecordUsagefollowed by exit). It is now awaited with a bound (a5acb4f).Also fixed in passing:
Authorizationheader. The token is now trimmed.rho session share: the hint pointed at a non-existent "Rho Cloud share endpoint".Not reproduced / deferred
Verification
All commands were run in this branch's worktree on darwin/arm64, at HEAD 4e4b3d4:
GOWORK=off go build ./...: exit 0GOWORK=off go vet ./...: exit 0go run mvdan.cc/gofumpt@v0.10.0 -l $(git ls-files '*.go'): no outputGOWORK=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 thegraycode-eco/layout.GOWORK=off go test -race -count=1 -p 2 ./...: exit 0, 152 packagesok, 0 FAIL. This includescmd,internal/platform/cloud,internal/auth,internal/daemonandinternal/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 issuescloudline.FILE_COUNT,Model_Tokens,file_Sha256,evidence_DIGEST).make api-validatewas not needed;api/is untouched.cloud.graycodeai.comdoes not resolve today (curl: (6) Could not resolve host). The README says the service is alpha and still rolling out.Follow-ups
rhoto the capability enum and keepgraycodeas a legacy alias.durationMsmax to 86 400 000.graph_attribute_policy.jsonin the worker tests.docs/design/three-repo-architecture.md:36still says "no default URL".docs/architecture/execution-graph.mdand the ADRs still say "Rho Cloud".internal/mcp/oauth_store.go) share the same plaintext fallback without a warning.rho cloud logout/ revoke command yet.rho session share(RHO_SHARE_URL) posts session exports to any URL, includinghttp://.credentialshelp text claims "Linux secret service"; verify it.🤖 Generated with Claude Code