From 53caea93a984b50751eae8339abc065d830a44d9 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:31:55 +0530 Subject: [PATCH 01/16] 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. Co-Authored-By: Claude Opus 5.5 --- internal/platform/cloud/client.go | 81 +++++++++--- internal/platform/cloud/config.go | 13 ++ internal/platform/cloud/endpoint.go | 51 ++++++++ internal/platform/cloud/endpoint_test.go | 158 +++++++++++++++++++++++ internal/platform/cloud/graph.go | 10 +- 5 files changed, 284 insertions(+), 29 deletions(-) create mode 100644 internal/platform/cloud/endpoint.go create mode 100644 internal/platform/cloud/endpoint_test.go diff --git a/internal/platform/cloud/client.go b/internal/platform/cloud/client.go index 84d1f2d6..88140e3a 100644 --- a/internal/platform/cloud/client.go +++ b/internal/platform/cloud/client.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/http" "strings" @@ -87,18 +88,64 @@ type DeviceLoginPoll struct { type Client struct { endpoint, token string - http *http.Client + // endpointErr records why Config.Endpoint was refused (for example a + // plaintext http:// URL to a remote host). A client with an endpoint + // error never sends a request, so the device token cannot leak. + endpointErr error + http *http.Client } +// errRedirect is returned by the client's CheckRedirect hook. GrayCode Cloud +// API calls never redirect; following one could replay the device token and +// request body to another origin or downgrade the connection to plain HTTP. +var errRedirect = errors.New("GrayCode Cloud responded with a redirect; refusing to follow it") + func New(cfg Config) *Client { - client := cfg.HTTPClient - if client == nil { - client = &http.Client{Timeout: 3 * time.Second} + var client http.Client + if cfg.HTTPClient != nil { + client = *cfg.HTTPClient + } else { + client.Timeout = 3 * time.Second + } + client.CheckRedirect = func(*http.Request, []*http.Request) error { return errRedirect } + c := &Client{token: cfg.DeviceToken, http: &client} + if strings.TrimSpace(cfg.Endpoint) != "" { + c.endpoint, c.endpointErr = NormalizeEndpoint(cfg.Endpoint) + } + return c +} + +func (c *Client) Enabled() bool { return c.endpoint != "" && c.endpointErr == nil && c.token != "" } + +// checkEndpoint reports whether the client may send requests at all. +func (c *Client) checkEndpoint() error { + if c.endpointErr != nil { + return c.endpointErr } - return &Client{endpoint: strings.TrimRight(cfg.Endpoint, "/"), token: cfg.DeviceToken, http: client} + if c.endpoint == "" { + return fmt.Errorf("rho cloud endpoint is not configured") + } + return nil } -func (c *Client) Enabled() bool { return c.endpoint != "" && c.token != "" } +// newJSONRequest builds a POST to path on the validated endpoint. It is the +// single place requests are created, so the TLS policy in NormalizeEndpoint +// applies to every call, and the device token is attached only when +// authenticated is true. +func (c *Client) newJSONRequest(ctx context.Context, path string, body []byte, authenticated bool) (*http.Request, error) { + if err := c.checkEndpoint(); err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+path, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if authenticated { + req.Header.Set("Authorization", "Bearer "+c.token) + } + return req, nil +} func (c *Client) startDeviceLogin(ctx context.Context, label, platform, rhoVersion string) (DeviceLoginStart, error) { var result DeviceLoginStart @@ -106,11 +153,10 @@ func (c *Client) startDeviceLogin(ctx context.Context, label, platform, rhoVersi if err != nil { return result, err } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/v1/auth/device/start", bytes.NewReader(body)) + req, err := c.newJSONRequest(ctx, "/v1/auth/device/start", body, false) if err != nil { return result, err } - req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { return result, err @@ -128,11 +174,10 @@ func (c *Client) pollDeviceLogin(ctx context.Context, deviceCode string) (Device if err != nil { return result, err } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/v1/auth/device/poll", bytes.NewReader(body)) + req, err := c.newJSONRequest(ctx, "/v1/auth/device/poll", body, false) if err != nil { return result, err } - req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { return result, err @@ -145,15 +190,15 @@ func (c *Client) pollDeviceLogin(ctx context.Context, deviceCode string) (Device } func (c *Client) StartDeviceLogin(ctx context.Context, label, platform, rhoVersion string) (DeviceLoginStart, error) { - if c.endpoint == "" { - return DeviceLoginStart{}, fmt.Errorf("rho cloud endpoint is not configured") + if err := c.checkEndpoint(); err != nil { + return DeviceLoginStart{}, err } return c.startDeviceLogin(ctx, label, platform, rhoVersion) } func (c *Client) PollDeviceLogin(ctx context.Context, deviceCode string) (DeviceLoginPoll, error) { - if c.endpoint == "" { - return DeviceLoginPoll{}, fmt.Errorf("rho cloud endpoint is not configured") + if err := c.checkEndpoint(); err != nil { + return DeviceLoginPoll{}, err } return c.pollDeviceLogin(ctx, deviceCode) } @@ -167,12 +212,10 @@ func (c *Client) RecordUsage(ctx context.Context, event UsageEvent) { if err != nil { return } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/v1/usage", bytes.NewReader(body)) + req, err := c.newJSONRequest(ctx, "/v1/usage", body, true) if err != nil { return } - req.Header.Set("Authorization", "Bearer "+c.token) - req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err == nil && resp != nil { _ = resp.Body.Close() @@ -188,12 +231,10 @@ func (c *Client) RecordDeliveryContext(ctx context.Context, event DeliveryContex if err != nil { return } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/v1/delivery-context", bytes.NewReader(body)) + req, err := c.newJSONRequest(ctx, "/v1/delivery-context", body, true) if err != nil { return } - req.Header.Set("Authorization", "Bearer "+c.token) - req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err == nil && resp != nil { _ = resp.Body.Close() diff --git a/internal/platform/cloud/config.go b/internal/platform/cloud/config.go index f90a3561..49b3cf49 100644 --- a/internal/platform/cloud/config.go +++ b/internal/platform/cloud/config.go @@ -2,6 +2,7 @@ package cloud import ( "encoding/json" + "fmt" "os" "path/filepath" @@ -32,6 +33,11 @@ func LoadDeviceConfig() (DeviceConfig, error) { } func SaveDeviceConfig(cfg DeviceConfig, token string) error { + endpoint, err := NormalizeEndpoint(cfg.Endpoint) + if err != nil { + return err + } + cfg.Endpoint = endpoint if err := auth.NewSecureStorage(tokenService).Set(tokenAccount, token); err != nil { return err } @@ -50,6 +56,13 @@ func LoadClient() (*Client, DeviceConfig, error) { if err != nil { return nil, cfg, err } + // Refuse a saved plaintext endpoint before the token is even read, so a + // tampered or pre-TLS-check cloud.json cannot route the token over HTTP. + if cfg.Endpoint != "" { + if _, err := NormalizeEndpoint(cfg.Endpoint); err != nil { + return nil, cfg, fmt.Errorf("saved GrayCode Cloud endpoint in %s is not allowed: %w; run `rho cloud login` again", configPath(), err) + } + } token, err := auth.NewSecureStorage(tokenService).Get(tokenAccount) if err != nil { return nil, cfg, err diff --git a/internal/platform/cloud/endpoint.go b/internal/platform/cloud/endpoint.go new file mode 100644 index 00000000..f9850359 --- /dev/null +++ b/internal/platform/cloud/endpoint.go @@ -0,0 +1,51 @@ +package cloud + +import ( + "errors" + "fmt" + "net/url" + "strings" +) + +// ErrInsecureEndpoint reports a GrayCode Cloud URL that would send the device +// token or telemetry over plaintext HTTP to a non-loopback host. +var ErrInsecureEndpoint = errors.New("GrayCode Cloud endpoints must use https:// (plain http:// is allowed only for localhost, 127.0.0.1 and [::1])") + +// NormalizeEndpoint validates a GrayCode Cloud API base URL and returns it +// without a trailing slash. It accepts https:// URLs, and http:// URLs 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 because the client appends API paths to the base URL. +func NormalizeEndpoint(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", errors.New("GrayCode Cloud endpoint is empty") + } + u, err := url.Parse(raw) + if err != nil || u.Opaque != "" || u.Hostname() == "" { + return "", errors.New("GrayCode Cloud endpoint must be an absolute URL such as https://cloud.graycodeai.com") + } + if u.User != nil { + return "", errors.New("GrayCode Cloud endpoint must not contain credentials") + } + if u.RawQuery != "" || u.ForceQuery || u.Fragment != "" { + return "", errors.New("GrayCode Cloud endpoint must not contain a query or fragment") + } + switch u.Scheme { + case "https": + case "http": + if !isLoopbackHost(u.Hostname()) { + return "", fmt.Errorf("%w: got http://%s", ErrInsecureEndpoint, u.Host) + } + default: + return "", fmt.Errorf("%w: got scheme %q", ErrInsecureEndpoint, u.Scheme) + } + return strings.TrimRight(u.String(), "/"), nil +} + +// isLoopbackHost reports whether host is one of the loopback names for which +// plaintext HTTP is acceptable. The list is deliberately explicit rather than +// resolving names, so a DNS entry cannot make a remote host look local. +func isLoopbackHost(host string) bool { + return strings.EqualFold(host, "localhost") || host == "127.0.0.1" || host == "::1" +} diff --git a/internal/platform/cloud/endpoint_test.go b/internal/platform/cloud/endpoint_test.go new file mode 100644 index 00000000..5a86ce2f --- /dev/null +++ b/internal/platform/cloud/endpoint_test.go @@ -0,0 +1,158 @@ +package cloud + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" +) + +func TestNormalizeEndpointAcceptsTLSAndLoopbackHTTP(t *testing.T) { + cases := map[string]string{ + "https://cloud.graycodeai.com": "https://cloud.graycodeai.com", + "https://cloud.graycodeai.com/": "https://cloud.graycodeai.com", + " https://cloud.graycodeai.com/api/ ": "https://cloud.graycodeai.com/api", + "HTTPS://cloud.graycodeai.com": "https://cloud.graycodeai.com", + "http://localhost:8787": "http://localhost:8787", + "http://LOCALHOST:8787/": "http://LOCALHOST:8787", + "http://127.0.0.1:1": "http://127.0.0.1:1", + "http://127.0.0.1": "http://127.0.0.1", + "http://[::1]:8787": "http://[::1]:8787", + "https://cloud.graycodeai.com:8443/v1": "https://cloud.graycodeai.com:8443/v1", + } + for raw, want := range cases { + got, err := NormalizeEndpoint(raw) + if err != nil { + t.Errorf("NormalizeEndpoint(%q) error = %v", raw, err) + continue + } + if got != want { + t.Errorf("NormalizeEndpoint(%q) = %q, want %q", raw, got, want) + } + } +} + +func TestNormalizeEndpointRejectsPlaintextRemoteAndMalformedURLs(t *testing.T) { + insecure := []string{ + "http://cloud.graycodeai.com", + "http://127.0.0.2:8787", + "http://localhost.attacker.example", + "http://0.0.0.0:8787", + "http://[::2]:8787", + "ftp://cloud.graycodeai.com", + "ws://localhost:8787", + } + for _, raw := range insecure { + if _, err := NormalizeEndpoint(raw); !errors.Is(err, ErrInsecureEndpoint) { + t.Errorf("NormalizeEndpoint(%q) error = %v, want ErrInsecureEndpoint", raw, err) + } + } + malformed := []string{ + "", + " ", + "cloud.graycodeai.com", + "https://", + "https://:8443", + "https://user:secret@cloud.graycodeai.com", + "https://cloud.graycodeai.com?token=x", + "https://cloud.graycodeai.com?", + "https://cloud.graycodeai.com#frag", + "mailto:ops@graycodeai.com", + } + for _, raw := range malformed { + if got, err := NormalizeEndpoint(raw); err == nil { + t.Errorf("NormalizeEndpoint(%q) = %q, want error", raw, got) + } + } + if _, err := NormalizeEndpoint("https://user:secret@cloud.graycodeai.com"); err != nil && strings.Contains(err.Error(), "secret") { + t.Fatalf("error leaks URL credentials: %v", err) + } +} + +// countingTransport records every request so tests can prove none was sent. +type countingTransport struct{ calls atomic.Int32 } + +func (c *countingTransport) RoundTrip(*http.Request) (*http.Response, error) { + c.calls.Add(1) + return nil, errors.New("unexpected request") +} + +func TestInsecureEndpointNeverSendsTheDeviceToken(t *testing.T) { + transport := &countingTransport{} + client := New(Config{ + Endpoint: "http://cloud.graycodeai.com", + DeviceToken: "hwc_secret", + HTTPClient: &http.Client{Transport: transport}, + }) + if client.Enabled() { + t.Fatal("client with a plaintext remote endpoint must not be enabled") + } + ctx := context.Background() + client.RecordUsage(ctx, UsageEvent{EventID: "event_0123456789", Capability: "rho"}) + if _, err := client.SyncGraph(ctx, GraphSyncRequest{SyncID: "graph_0123456789abcdef", Graph: json.RawMessage(`{}`)}); err == nil { + t.Fatal("SyncGraph succeeded against a plaintext endpoint") + } + if _, err := client.StartDeviceLogin(ctx, "laptop", "darwin", "0.3.0"); !errors.Is(err, ErrInsecureEndpoint) { + t.Fatalf("StartDeviceLogin error = %v, want ErrInsecureEndpoint", err) + } + if _, err := client.PollDeviceLogin(ctx, "device-code-0123456789"); !errors.Is(err, ErrInsecureEndpoint) { + t.Fatalf("PollDeviceLogin error = %v, want ErrInsecureEndpoint", err) + } + if n := transport.calls.Load(); n != 0 { + t.Fatalf("transport saw %d requests, want 0", n) + } +} + +func TestClientRefusesRedirects(t *testing.T) { + var leaked atomic.Value + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + leaked.Store(r.Header.Get("Authorization")) + w.WriteHeader(http.StatusAccepted) + })) + defer target.Close() + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL+r.URL.Path, http.StatusTemporaryRedirect) + })) + defer origin.Close() + + _, err := New(Config{Endpoint: origin.URL, DeviceToken: "hwc_secret"}).SyncGraph( + context.Background(), + GraphSyncRequest{SyncID: "graph_0123456789abcdef", ProjectID: "project_0123456789", Graph: json.RawMessage(`{}`)}, + ) + if err == nil || !strings.Contains(err.Error(), "redirect") { + t.Fatalf("SyncGraph error = %v, want redirect refusal", err) + } + if got, _ := leaked.Load().(string); got != "" { + t.Fatalf("redirect target received Authorization %q", got) + } +} + +func TestSaveDeviceConfigRefusesPlaintextEndpoint(t *testing.T) { + dir := t.TempDir() + t.Setenv("RHO_CONFIG_DIR", dir) + err := SaveDeviceConfig(DeviceConfig{Endpoint: "http://cloud.graycodeai.com", DeviceID: "device_0123456789", ProjectID: "project_0123456789"}, "hwc_secret") + if !errors.Is(err, ErrInsecureEndpoint) { + t.Fatalf("SaveDeviceConfig error = %v, want ErrInsecureEndpoint", err) + } + if _, statErr := os.Stat(filepath.Join(dir, "cloud.json")); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("cloud.json was written for a refused endpoint (stat err %v)", statErr) + } +} + +func TestLoadClientRefusesSavedPlaintextEndpoint(t *testing.T) { + dir := t.TempDir() + t.Setenv("RHO_CONFIG_DIR", dir) + saved := `{"endpoint":"http://cloud.graycodeai.com","device_id":"device_0123456789","project_id":"project_0123456789"}` + if err := os.WriteFile(filepath.Join(dir, "cloud.json"), []byte(saved), 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := LoadClient(); !errors.Is(err, ErrInsecureEndpoint) { + t.Fatalf("LoadClient error = %v, want ErrInsecureEndpoint", err) + } +} diff --git a/internal/platform/cloud/graph.go b/internal/platform/cloud/graph.go index 76eec330..7aaef93b 100644 --- a/internal/platform/cloud/graph.go +++ b/internal/platform/cloud/graph.go @@ -8,7 +8,6 @@ import ( "encoding/json" "fmt" "io" - "net/http" "regexp" "strings" ) @@ -166,17 +165,10 @@ func (c *Client) SyncGraph(ctx context.Context, request GraphSyncRequest) (Graph if len(body) > MaxGraphSyncBodySize { return result, fmt.Errorf("graph sync body is %d bytes; Rho Cloud accepts at most %d", len(body), MaxGraphSyncBodySize) } - req, err := http.NewRequestWithContext( - ctx, - http.MethodPost, - c.endpoint+"/v1/graph/sync", - bytes.NewReader(body), - ) + req, err := c.newJSONRequest(ctx, "/v1/graph/sync", body, true) if err != nil { return result, fmt.Errorf("create graph sync request: %w", err) } - req.Header.Set("Authorization", "Bearer "+c.token) - req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { return result, fmt.Errorf("sync graph: %w", err) From 3d7fa537f8a3bd9c8e5215113f19cf164289fa64 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:33:09 +0530 Subject: [PATCH 02/16] 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. Co-Authored-By: Claude Opus 5.5 --- cmd/cloud.go | 363 ++++++++++++++++++++++++++------------------------- 1 file changed, 187 insertions(+), 176 deletions(-) diff --git a/cmd/cloud.go b/cmd/cloud.go index 0b09bbd3..6f04bc28 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -13,178 +13,206 @@ import ( var cloudCmd = &cobra.Command{Use: "cloud", Short: "Manage optional Rho Cloud synchronization"} -var cloudConnectCmd = &cobra.Command{ - Use: "connect", - Short: "Connect this Rho device to Rho Cloud", - RunE: func(cmd *cobra.Command, _ []string) error { - endpoint, _ := cmd.Flags().GetString("endpoint") - deviceID, _ := cmd.Flags().GetString("device-id") - projectID, _ := cmd.Flags().GetString("project-id") - token, _ := cmd.Flags().GetString("token") - if endpoint == "" || deviceID == "" || projectID == "" || token == "" { - return fmt.Errorf("endpoint, device-id, project-id, and token are required") - } - if err := cloud.SaveDeviceConfig(cloud.DeviceConfig{Endpoint: endpoint, DeviceID: deviceID, ProjectID: projectID}, token); err != nil { - return err - } - cmd.Println(auditTint("Rho Cloud connected. Usage synchronization is opt-in and fail-open.", doneGreen)) - return nil - }, +func newCloudConnectCmd() *cobra.Command { + command := &cobra.Command{ + Use: "connect", + Short: "Connect this Rho device to Rho Cloud", + RunE: func(cmd *cobra.Command, _ []string) error { + endpoint, _ := cmd.Flags().GetString("endpoint") + deviceID, _ := cmd.Flags().GetString("device-id") + projectID, _ := cmd.Flags().GetString("project-id") + token, _ := cmd.Flags().GetString("token") + if endpoint == "" || deviceID == "" || projectID == "" || token == "" { + return fmt.Errorf("endpoint, device-id, project-id, and token are required") + } + if err := cloud.SaveDeviceConfig(cloud.DeviceConfig{Endpoint: endpoint, DeviceID: deviceID, ProjectID: projectID}, token); err != nil { + return err + } + cmd.Println(auditTint("Rho Cloud connected. Usage synchronization is opt-in and fail-open.", doneGreen)) + return nil + }, + } + command.Flags().String("endpoint", "", "Rho Cloud endpoint") + command.Flags().String("device-id", "", "Rho Cloud device ID") + command.Flags().String("project-id", "", "Rho Cloud project ID") + command.Flags().String("token", "", "Rho Cloud device token") + return command } -var cloudLoginCmd = &cobra.Command{ - Use: "login", - Short: "Sign in to Rho Cloud in a browser", - RunE: func(cmd *cobra.Command, _ []string) error { - endpoint, _ := cmd.Flags().GetString("endpoint") - label, _ := cmd.Flags().GetString("label") - if endpoint == "" { - endpoint = os.Getenv("RHO_CLOUD_URL") - } - if endpoint == "" { - return fmt.Errorf("rho cloud endpoint is required (use --endpoint or RHO_CLOUD_URL)") - } - if label == "" { - label, _ = os.Hostname() - } - client := cloud.New(cloud.Config{Endpoint: endpoint}) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) - defer cancel() - start, err := client.StartDeviceLogin(ctx, label, runtime.GOOS, version) - if err != nil { - return err - } - cmd.Printf("%s\n", auditTint("Open ", textPrimary)+auditTint(start.VerificationURI, infoSky)+auditTint(" and enter code ", textPrimary)+auditTint(start.UserCode, rhoColor)) - if err := openBrowser(start.VerificationURI + "?code=" + start.UserCode); err != nil { - cmd.Printf("%s\n", auditTint(fmt.Sprintf("Could not open the browser automatically: %v", err), textMuted)) - } - interval := time.Duration(start.Interval) * time.Second - if interval < time.Second { - interval = 5 * time.Second - } - prog := NewCLIProgress("Cloud", []string{"Waiting for browser approval"}) - defer prog.Abort() - prog.StartStep(0) - for { - poll, pollErr := client.PollDeviceLogin(ctx, start.DeviceCode) - if pollErr != nil { - prog.FailStep(0, pollErr.Error()) - return pollErr - } - switch poll.Status { - case "pending": - select { - case <-ctx.Done(): - prog.FailStep(0, ctx.Err().Error()) - return fmt.Errorf("waiting for browser approval: %w", ctx.Err()) - case <-time.After(interval): - } - case "approved": - if poll.Token == "" || poll.DeviceID == "" || poll.ProjectID == "" { - prog.FailStep(0, "incomplete device authorization") - return fmt.Errorf("rho cloud returned an incomplete device authorization") +func newCloudLoginCmd() *cobra.Command { + command := &cobra.Command{ + Use: "login", + Short: "Sign in to Rho Cloud in a browser", + RunE: func(cmd *cobra.Command, _ []string) error { + endpoint, _ := cmd.Flags().GetString("endpoint") + label, _ := cmd.Flags().GetString("label") + if endpoint == "" { + endpoint = os.Getenv("RHO_CLOUD_URL") + } + if endpoint == "" { + return fmt.Errorf("rho cloud endpoint is required (use --endpoint or RHO_CLOUD_URL)") + } + if label == "" { + label, _ = os.Hostname() + } + client := cloud.New(cloud.Config{Endpoint: endpoint}) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + start, err := client.StartDeviceLogin(ctx, label, runtime.GOOS, version) + if err != nil { + return err + } + cmd.Printf("%s\n", auditTint("Open ", textPrimary)+auditTint(start.VerificationURI, infoSky)+auditTint(" and enter code ", textPrimary)+auditTint(start.UserCode, rhoColor)) + if err := openBrowser(start.VerificationURI + "?code=" + start.UserCode); err != nil { + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Could not open the browser automatically: %v", err), textMuted)) + } + interval := time.Duration(start.Interval) * time.Second + if interval < time.Second { + interval = 5 * time.Second + } + prog := NewCLIProgress("Cloud", []string{"Waiting for browser approval"}) + defer prog.Abort() + prog.StartStep(0) + for { + poll, pollErr := client.PollDeviceLogin(ctx, start.DeviceCode) + if pollErr != nil { + prog.FailStep(0, pollErr.Error()) + return pollErr } - if err := cloud.SaveDeviceConfig(cloud.DeviceConfig{Endpoint: endpoint, DeviceID: poll.DeviceID, ProjectID: poll.ProjectID}, poll.Token); err != nil { - prog.FailStep(0, err.Error()) - return err + switch poll.Status { + case "pending": + select { + case <-ctx.Done(): + prog.FailStep(0, ctx.Err().Error()) + return fmt.Errorf("waiting for browser approval: %w", ctx.Err()) + case <-time.After(interval): + } + case "approved": + if poll.Token == "" || poll.DeviceID == "" || poll.ProjectID == "" { + prog.FailStep(0, "incomplete device authorization") + return fmt.Errorf("rho cloud returned an incomplete device authorization") + } + if err := cloud.SaveDeviceConfig(cloud.DeviceConfig{Endpoint: endpoint, DeviceID: poll.DeviceID, ProjectID: poll.ProjectID}, poll.Token); err != nil { + prog.FailStep(0, err.Error()) + return err + } + prog.CompleteStep(0) + prog.Done() + cmd.Println(auditTint("Rho Cloud connected for project ", doneGreen) + auditTint(poll.ProjectID, textPrimary) + auditTint(".", doneGreen)) + return nil + case "expired": + prog.FailStep(0, "device authorization expired") + return fmt.Errorf("rho cloud device authorization expired") + default: + prog.FailStep(0, fmt.Sprintf("unknown status %q", poll.Status)) + return fmt.Errorf("rho cloud returned unknown device authorization status %q", poll.Status) } - prog.CompleteStep(0) - prog.Done() - cmd.Println(auditTint("Rho Cloud connected for project ", doneGreen) + auditTint(poll.ProjectID, textPrimary) + auditTint(".", doneGreen)) - return nil - case "expired": - prog.FailStep(0, "device authorization expired") - return fmt.Errorf("rho cloud device authorization expired") - default: - prog.FailStep(0, fmt.Sprintf("unknown status %q", poll.Status)) - return fmt.Errorf("rho cloud returned unknown device authorization status %q", poll.Status) } - } - }, + }, + } + command.Flags().String("endpoint", "", "Rho Cloud endpoint (or RHO_CLOUD_URL)") + command.Flags().String("label", "", "Name for this Rho device") + return command } -var cloudStatusCmd = &cobra.Command{ - Use: "status", Short: "Show Rho Cloud connection status", - RunE: func(cmd *cobra.Command, _ []string) error { - client, cfg, err := cloud.LoadClient() - if err != nil || !client.Enabled() { - cmd.Println(auditTint("Rho Cloud is not connected.", textMuted)) +func newCloudStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", Short: "Show Rho Cloud connection status", + RunE: func(cmd *cobra.Command, _ []string) error { + client, cfg, err := cloud.LoadClient() + if err != nil || !client.Enabled() { + cmd.Println(auditTint("Rho Cloud is not connected.", textMuted)) + return nil + } + cmd.Println(auditTint("Rho Cloud connected: ", doneGreen) + auditTint(cfg.Endpoint, textPrimary) + auditTint(fmt.Sprintf(" (device %s, project %s)", cfg.DeviceID, cfg.ProjectID), textMuted)) return nil - } - cmd.Println(auditTint("Rho Cloud connected: ", doneGreen) + auditTint(cfg.Endpoint, textPrimary) + auditTint(fmt.Sprintf(" (device %s, project %s)", cfg.DeviceID, cfg.ProjectID), textMuted)) - return nil - }, + }, + } } -var cloudContextCmd = &cobra.Command{ - Use: "context", - Short: "Sync repository context to Rho Cloud", - RunE: func(cmd *cobra.Command, _ []string) error { - client, cfg, err := cloud.LoadClient() - if err != nil || !client.Enabled() { - return fmt.Errorf("rho cloud is not connected") - } - detected, detectErr := detectGitContext(cmd.Context()) - repository, _ := cmd.Flags().GetString("repository") - if repository == "" { - repository = detected.Repository - } - if repository == "" { - return detectErr - } - contextProvider, _ := cmd.Flags().GetString("provider") - externalID, _ := cmd.Flags().GetString("external-id") - branch, _ := cmd.Flags().GetString("branch") - commit, _ := cmd.Flags().GetString("commit") - ciRunID, _ := cmd.Flags().GetString("ci-run") - ciStatus, _ := cmd.Flags().GetString("ci-status") - ciWorkflow, _ := cmd.Flags().GetString("ci-workflow") - deploymentID, _ := cmd.Flags().GetString("deployment") - deploymentStatus, _ := cmd.Flags().GetString("deployment-status") - deploymentEnvironment, _ := cmd.Flags().GetString("deployment-environment") - if contextProvider == "" { - contextProvider = detected.Provider - } - if contextProvider == "" { - contextProvider = "git" - } - if branch == "" { - branch = detected.Branch - } - if commit == "" { - commit = detected.Commit - } - if externalID == "" { - externalID = repository - } - event := cloud.DeliveryContext{ProjectID: cfg.ProjectID, Branch: branch, CommitSHA: commit} - event.Repository.Provider, event.Repository.ExternalID, event.Repository.Name = contextProvider, externalID, repository - if ciRunID == "" { - ciRunID, ciWorkflow = os.Getenv("GITHUB_RUN_ID"), firstValue(ciWorkflow, os.Getenv("GITHUB_WORKFLOW")) - } - if ciRunID != "" { - if ciStatus == "" { - ciStatus = "running" +func newCloudContextCmd() *cobra.Command { + command := &cobra.Command{ + Use: "context", + Short: "Sync repository context to Rho Cloud", + RunE: func(cmd *cobra.Command, _ []string) error { + client, cfg, err := cloud.LoadClient() + if err != nil || !client.Enabled() { + return fmt.Errorf("rho cloud is not connected") } - ciProvider := contextProvider - if os.Getenv("GITHUB_RUN_ID") != "" && ciProvider == "git" { - ciProvider = "github" + detected, detectErr := detectGitContext(cmd.Context()) + repository, _ := cmd.Flags().GetString("repository") + if repository == "" { + repository = detected.Repository } - event.CIRun = &cloud.CIRunContext{Provider: ciProvider, ExternalID: ciRunID, Workflow: ciWorkflow, Status: ciStatus} - } - if deploymentID != "" { - if deploymentStatus == "" { - deploymentStatus = "running" + if repository == "" { + return detectErr } - if deploymentEnvironment == "" { - return fmt.Errorf("deployment-environment is required with --deployment") + contextProvider, _ := cmd.Flags().GetString("provider") + externalID, _ := cmd.Flags().GetString("external-id") + branch, _ := cmd.Flags().GetString("branch") + commit, _ := cmd.Flags().GetString("commit") + ciRunID, _ := cmd.Flags().GetString("ci-run") + ciStatus, _ := cmd.Flags().GetString("ci-status") + ciWorkflow, _ := cmd.Flags().GetString("ci-workflow") + deploymentID, _ := cmd.Flags().GetString("deployment") + deploymentStatus, _ := cmd.Flags().GetString("deployment-status") + deploymentEnvironment, _ := cmd.Flags().GetString("deployment-environment") + if contextProvider == "" { + contextProvider = detected.Provider } - event.Deployment = &cloud.DeploymentContext{Provider: contextProvider, ExternalID: deploymentID, Environment: deploymentEnvironment, Status: deploymentStatus} - } - client.RecordDeliveryContext(cmd.Context(), event) - cmd.Println(auditTint("Repository context queued for Rho Cloud.", doneGreen)) - return nil - }, + if contextProvider == "" { + contextProvider = "git" + } + if branch == "" { + branch = detected.Branch + } + if commit == "" { + commit = detected.Commit + } + if externalID == "" { + externalID = repository + } + event := cloud.DeliveryContext{ProjectID: cfg.ProjectID, Branch: branch, CommitSHA: commit} + event.Repository.Provider, event.Repository.ExternalID, event.Repository.Name = contextProvider, externalID, repository + if ciRunID == "" { + ciRunID, ciWorkflow = os.Getenv("GITHUB_RUN_ID"), firstValue(ciWorkflow, os.Getenv("GITHUB_WORKFLOW")) + } + if ciRunID != "" { + if ciStatus == "" { + ciStatus = "running" + } + ciProvider := contextProvider + if os.Getenv("GITHUB_RUN_ID") != "" && ciProvider == "git" { + ciProvider = "github" + } + event.CIRun = &cloud.CIRunContext{Provider: ciProvider, ExternalID: ciRunID, Workflow: ciWorkflow, Status: ciStatus} + } + if deploymentID != "" { + if deploymentStatus == "" { + deploymentStatus = "running" + } + if deploymentEnvironment == "" { + return fmt.Errorf("deployment-environment is required with --deployment") + } + event.Deployment = &cloud.DeploymentContext{Provider: contextProvider, ExternalID: deploymentID, Environment: deploymentEnvironment, Status: deploymentStatus} + } + client.RecordDeliveryContext(cmd.Context(), event) + cmd.Println(auditTint("Repository context queued for Rho Cloud.", doneGreen)) + return nil + }, + } + command.Flags().String("repository", "", "Repository name (auto-detected from Git when omitted)") + command.Flags().String("provider", "", "Repository provider (auto-detected when omitted)") + command.Flags().String("external-id", "", "Provider repository identifier (defaults to repository)") + command.Flags().String("branch", "", "Current branch (auto-detected when omitted)") + command.Flags().String("commit", "", "Current commit SHA (auto-detected when omitted)") + command.Flags().String("ci-run", "", "CI run identifier (defaults to GITHUB_RUN_ID)") + command.Flags().String("ci-status", "", "CI status: queued, running, succeeded, failed, or cancelled") + command.Flags().String("ci-workflow", "", "CI workflow name (defaults to GITHUB_WORKFLOW)") + command.Flags().String("deployment", "", "Deployment identifier") + command.Flags().String("deployment-status", "", "Deployment status") + command.Flags().String("deployment-environment", "", "Deployment environment, for example production") + return command } func firstValue(values ...string) string { @@ -197,23 +225,6 @@ func firstValue(values ...string) string { } func init() { - cloudLoginCmd.Flags().String("endpoint", "", "Rho Cloud endpoint (or RHO_CLOUD_URL)") - cloudLoginCmd.Flags().String("label", "", "Name for this Rho device") - cloudConnectCmd.Flags().String("endpoint", "", "Rho Cloud endpoint") - cloudConnectCmd.Flags().String("device-id", "", "Rho Cloud device ID") - cloudConnectCmd.Flags().String("project-id", "", "Rho Cloud project ID") - cloudConnectCmd.Flags().String("token", "", "Rho Cloud device token") - cloudContextCmd.Flags().String("repository", "", "Repository name (auto-detected from Git when omitted)") - cloudContextCmd.Flags().String("provider", "", "Repository provider (auto-detected when omitted)") - cloudContextCmd.Flags().String("external-id", "", "Provider repository identifier (defaults to repository)") - cloudContextCmd.Flags().String("branch", "", "Current branch (auto-detected when omitted)") - cloudContextCmd.Flags().String("commit", "", "Current commit SHA (auto-detected when omitted)") - cloudContextCmd.Flags().String("ci-run", "", "CI run identifier (defaults to GITHUB_RUN_ID)") - cloudContextCmd.Flags().String("ci-status", "", "CI status: queued, running, succeeded, failed, or cancelled") - cloudContextCmd.Flags().String("ci-workflow", "", "CI workflow name (defaults to GITHUB_WORKFLOW)") - cloudContextCmd.Flags().String("deployment", "", "Deployment identifier") - cloudContextCmd.Flags().String("deployment-status", "", "Deployment status") - cloudContextCmd.Flags().String("deployment-environment", "", "Deployment environment, for example production") - cloudCmd.AddCommand(cloudLoginCmd, cloudConnectCmd, cloudStatusCmd, cloudContextCmd) + cloudCmd.AddCommand(newCloudLoginCmd(), newCloudConnectCmd(), newCloudStatusCmd(), newCloudContextCmd()) rootCmd.AddCommand(cloudCmd) } From b1ed54f026bedd3332b2cee7cd953dcc870ac4c6 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:34:45 +0530 Subject: [PATCH 03/16] 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. Co-Authored-By: Claude Opus 5.5 --- cmd/cloud.go | 37 +++++++---- cmd/cloud_test.go | 79 ++++++++++++++++++++++++ internal/platform/cloud/endpoint.go | 30 ++++++++- internal/platform/cloud/endpoint_test.go | 22 +++++++ 4 files changed, 155 insertions(+), 13 deletions(-) create mode 100644 cmd/cloud_test.go diff --git a/cmd/cloud.go b/cmd/cloud.go index 6f04bc28..74bf656d 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -11,6 +11,10 @@ import ( "github.com/spf13/cobra" ) +// cloudEndpointFlagHelp documents the endpoint precedence shared by the +// commands that create a new GrayCode Cloud connection. +const cloudEndpointFlagHelp = "GrayCode Cloud API endpoint (default: $" + cloud.EndpointEnv + ", else " + cloud.DefaultEndpoint + ")" + var cloudCmd = &cobra.Command{Use: "cloud", Short: "Manage optional Rho Cloud synchronization"} func newCloudConnectCmd() *cobra.Command { @@ -18,12 +22,16 @@ func newCloudConnectCmd() *cobra.Command { Use: "connect", Short: "Connect this Rho device to Rho Cloud", RunE: func(cmd *cobra.Command, _ []string) error { - endpoint, _ := cmd.Flags().GetString("endpoint") + endpointFlag, _ := cmd.Flags().GetString("endpoint") + endpoint, err := cloud.ResolveEndpoint(endpointFlag) + if err != nil { + return err + } deviceID, _ := cmd.Flags().GetString("device-id") projectID, _ := cmd.Flags().GetString("project-id") token, _ := cmd.Flags().GetString("token") - if endpoint == "" || deviceID == "" || projectID == "" || token == "" { - return fmt.Errorf("endpoint, device-id, project-id, and token are required") + if deviceID == "" || projectID == "" || token == "" { + return fmt.Errorf("device-id, project-id, and token are required") } if err := cloud.SaveDeviceConfig(cloud.DeviceConfig{Endpoint: endpoint, DeviceID: deviceID, ProjectID: projectID}, token); err != nil { return err @@ -32,7 +40,7 @@ func newCloudConnectCmd() *cobra.Command { return nil }, } - command.Flags().String("endpoint", "", "Rho Cloud endpoint") + command.Flags().String("endpoint", "", cloudEndpointFlagHelp) command.Flags().String("device-id", "", "Rho Cloud device ID") command.Flags().String("project-id", "", "Rho Cloud project ID") command.Flags().String("token", "", "Rho Cloud device token") @@ -43,15 +51,20 @@ func newCloudLoginCmd() *cobra.Command { command := &cobra.Command{ Use: "login", Short: "Sign in to Rho Cloud in a browser", + Long: `Start a browser device login against GrayCode Cloud and store the issued +device token in the OS credential store. + +The endpoint defaults to ` + cloud.DefaultEndpoint + `. Override it with +--endpoint or ` + cloud.EndpointEnv + ` (for example a local Worker at +http://127.0.0.1:8787). Only https:// endpoints are accepted, except plain +http:// on localhost, 127.0.0.1 and [::1].`, RunE: func(cmd *cobra.Command, _ []string) error { - endpoint, _ := cmd.Flags().GetString("endpoint") - label, _ := cmd.Flags().GetString("label") - if endpoint == "" { - endpoint = os.Getenv("RHO_CLOUD_URL") - } - if endpoint == "" { - return fmt.Errorf("rho cloud endpoint is required (use --endpoint or RHO_CLOUD_URL)") + endpointFlag, _ := cmd.Flags().GetString("endpoint") + endpoint, err := cloud.ResolveEndpoint(endpointFlag) + if err != nil { + return err } + label, _ := cmd.Flags().GetString("label") if label == "" { label, _ = os.Hostname() } @@ -110,7 +123,7 @@ func newCloudLoginCmd() *cobra.Command { } }, } - command.Flags().String("endpoint", "", "Rho Cloud endpoint (or RHO_CLOUD_URL)") + command.Flags().String("endpoint", "", cloudEndpointFlagHelp) command.Flags().String("label", "", "Name for this Rho device") return command } diff --git a/cmd/cloud_test.go b/cmd/cloud_test.go new file mode 100644 index 00000000..60b4b950 --- /dev/null +++ b/cmd/cloud_test.go @@ -0,0 +1,79 @@ +package cmd + +import ( + "bytes" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + cloud "github.com/GrayCodeAI/rho/internal/platform/cloud" + "github.com/spf13/cobra" +) + +// runCloudCommand executes a freshly constructed cloud subcommand with args +// and returns its combined output. +func runCloudCommand(t *testing.T, command *cobra.Command, args ...string) (string, error) { + t.Helper() + var out bytes.Buffer + command.SetOut(&out) + command.SetErr(&out) + command.SetArgs(args) + command.SilenceUsage = true + command.SilenceErrors = true + err := command.Execute() + return out.String(), err +} + +func TestCloudLoginHelpNamesDefaultEndpoint(t *testing.T) { + command := newCloudLoginCmd() + if !strings.Contains(command.Long, cloud.DefaultEndpoint) { + t.Fatalf("login help does not name %s:\n%s", cloud.DefaultEndpoint, command.Long) + } + usage := command.Flags().Lookup("endpoint").Usage + if !strings.Contains(usage, cloud.DefaultEndpoint) || !strings.Contains(usage, cloud.EndpointEnv) { + t.Fatalf("--endpoint usage = %q, want default and %s", usage, cloud.EndpointEnv) + } +} + +func TestCloudLoginUsesRHOCloudURL(t *testing.T) { + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/auth/device/start" { + hits.Add(1) + } + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer server.Close() + t.Setenv(cloud.EndpointEnv, server.URL) + if _, err := runCloudCommand(t, newCloudLoginCmd(), "--label", "ci"); err == nil { + t.Fatal("login succeeded against a failing server") + } + if hits.Load() != 1 { + t.Fatalf("device start hits = %d, want 1 (RHO_CLOUD_URL was not used)", hits.Load()) + } +} + +func TestCloudLoginRejectsPlaintextEndpoint(t *testing.T) { + t.Setenv(cloud.EndpointEnv, "") + _, err := runCloudCommand(t, newCloudLoginCmd(), "--endpoint", "http://cloud.graycodeai.com") + if !errors.Is(err, cloud.ErrInsecureEndpoint) { + t.Fatalf("error = %v, want ErrInsecureEndpoint", err) + } +} + +func TestCloudConnectResolvesEndpointFromEnvironment(t *testing.T) { + t.Setenv(cloud.EndpointEnv, "http://cloud.graycodeai.com") + _, err := runCloudCommand(t, newCloudConnectCmd(), "--device-id", "device_0123456789", "--project-id", "project_0123456789") + if !errors.Is(err, cloud.ErrInsecureEndpoint) || !strings.Contains(err.Error(), cloud.EndpointEnv) { + t.Fatalf("error = %v, want the insecure %s to be refused", err, cloud.EndpointEnv) + } + + t.Setenv(cloud.EndpointEnv, "") + _, err = runCloudCommand(t, newCloudConnectCmd()) + if err == nil || strings.Contains(err.Error(), "endpoint") || !strings.Contains(err.Error(), "device-id") { + t.Fatalf("error = %v, want only the missing device-id/project-id/token (endpoint has a default)", err) + } +} diff --git a/internal/platform/cloud/endpoint.go b/internal/platform/cloud/endpoint.go index f9850359..34181c9d 100644 --- a/internal/platform/cloud/endpoint.go +++ b/internal/platform/cloud/endpoint.go @@ -4,13 +4,41 @@ import ( "errors" "fmt" "net/url" + "os" "strings" ) +const ( + // DefaultEndpoint is the hosted GrayCode Cloud API. `rho cloud login` and + // `rho cloud connect` use it when neither --endpoint nor EndpointEnv is + // set. It is never applied to an existing connection: a saved device + // token stays bound to the endpoint that issued it. + DefaultEndpoint = "https://cloud.graycodeai.com" + // EndpointEnv overrides DefaultEndpoint for new connections. + EndpointEnv = "RHO_CLOUD_URL" +) + // ErrInsecureEndpoint reports a GrayCode Cloud URL that would send the device // token or telemetry over plaintext HTTP to a non-loopback host. var ErrInsecureEndpoint = errors.New("GrayCode Cloud endpoints must use https:// (plain http:// is allowed only for localhost, 127.0.0.1 and [::1])") +// ResolveEndpoint returns the validated endpoint for a new connection: the +// --endpoint flag value when set, else $RHO_CLOUD_URL, else DefaultEndpoint. +func ResolveEndpoint(flagValue string) (string, error) { + raw, source := strings.TrimSpace(flagValue), "--endpoint" + if raw == "" { + raw, source = strings.TrimSpace(os.Getenv(EndpointEnv)), EndpointEnv + } + if raw == "" { + return DefaultEndpoint, nil + } + endpoint, err := NormalizeEndpoint(raw) + if err != nil { + return "", fmt.Errorf("%s: %w", source, err) + } + return endpoint, nil +} + // NormalizeEndpoint validates a GrayCode Cloud API base URL and returns it // without a trailing slash. It accepts https:// URLs, and http:// URLs only // for the loopback hosts localhost, 127.0.0.1 and [::1] (any port) so a local @@ -23,7 +51,7 @@ func NormalizeEndpoint(raw string) (string, error) { } u, err := url.Parse(raw) if err != nil || u.Opaque != "" || u.Hostname() == "" { - return "", errors.New("GrayCode Cloud endpoint must be an absolute URL such as https://cloud.graycodeai.com") + return "", fmt.Errorf("GrayCode Cloud endpoint must be an absolute URL such as %s", DefaultEndpoint) } if u.User != nil { return "", errors.New("GrayCode Cloud endpoint must not contain credentials") diff --git a/internal/platform/cloud/endpoint_test.go b/internal/platform/cloud/endpoint_test.go index 5a86ce2f..33304094 100644 --- a/internal/platform/cloud/endpoint_test.go +++ b/internal/platform/cloud/endpoint_test.go @@ -156,3 +156,25 @@ func TestLoadClientRefusesSavedPlaintextEndpoint(t *testing.T) { t.Fatalf("LoadClient error = %v, want ErrInsecureEndpoint", err) } } + +func TestResolveEndpointPrecedence(t *testing.T) { + t.Setenv(EndpointEnv, "") + if got, err := ResolveEndpoint(""); err != nil || got != DefaultEndpoint { + t.Fatalf("default = %q, %v; want %q", got, err, DefaultEndpoint) + } + t.Setenv(EndpointEnv, "http://127.0.0.1:8787/") + if got, err := ResolveEndpoint(""); err != nil || got != "http://127.0.0.1:8787" { + t.Fatalf("env = %q, %v", got, err) + } + if got, err := ResolveEndpoint("https://staging.cloud.graycodeai.com"); err != nil || got != "https://staging.cloud.graycodeai.com" { + t.Fatalf("flag = %q, %v; the flag must win over the environment", got, err) + } + t.Setenv(EndpointEnv, "http://cloud.graycodeai.com") + _, err := ResolveEndpoint("") + if !errors.Is(err, ErrInsecureEndpoint) || !strings.Contains(err.Error(), EndpointEnv) { + t.Fatalf("insecure env error = %v, want ErrInsecureEndpoint naming %s", err, EndpointEnv) + } + if _, err := ResolveEndpoint("http://cloud.graycodeai.com"); err == nil || !strings.Contains(err.Error(), "--endpoint") { + t.Fatalf("insecure flag error = %v, want it to name --endpoint", err) + } +} From dc07f682df5d38d5f2ce39844c457d3005c159c9 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:39:05 +0530 Subject: [PATCH 04/16] 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". Co-Authored-By: Claude Opus 5.5 --- cmd/cloud.go | 37 +++---- internal/platform/cloud/apierror.go | 106 +++++++++++++++++++ internal/platform/cloud/apierror_test.go | 124 +++++++++++++++++++++++ internal/platform/cloud/client.go | 41 +++++++- internal/platform/cloud/graph.go | 22 +--- 5 files changed, 287 insertions(+), 43 deletions(-) create mode 100644 internal/platform/cloud/apierror.go create mode 100644 internal/platform/cloud/apierror_test.go diff --git a/cmd/cloud.go b/cmd/cloud.go index 74bf656d..860e1ce0 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -92,34 +92,29 @@ http:// on localhost, 127.0.0.1 and [::1].`, prog.FailStep(0, pollErr.Error()) return pollErr } - switch poll.Status { - case "pending": + if poll.Status == cloud.DeviceLoginPending { select { case <-ctx.Done(): prog.FailStep(0, ctx.Err().Error()) return fmt.Errorf("waiting for browser approval: %w", ctx.Err()) case <-time.After(interval): } - case "approved": - if poll.Token == "" || poll.DeviceID == "" || poll.ProjectID == "" { - prog.FailStep(0, "incomplete device authorization") - return fmt.Errorf("rho cloud returned an incomplete device authorization") - } - if err := cloud.SaveDeviceConfig(cloud.DeviceConfig{Endpoint: endpoint, DeviceID: poll.DeviceID, ProjectID: poll.ProjectID}, poll.Token); err != nil { - prog.FailStep(0, err.Error()) - return err - } - prog.CompleteStep(0) - prog.Done() - cmd.Println(auditTint("Rho Cloud connected for project ", doneGreen) + auditTint(poll.ProjectID, textPrimary) + auditTint(".", doneGreen)) - return nil - case "expired": - prog.FailStep(0, "device authorization expired") - return fmt.Errorf("rho cloud device authorization expired") - default: - prog.FailStep(0, fmt.Sprintf("unknown status %q", poll.Status)) - return fmt.Errorf("rho cloud returned unknown device authorization status %q", poll.Status) + continue + } + // PollDeviceLogin reports expired, consumed and unknown states + // as errors, so the only remaining state is approved. + if poll.Token == "" || poll.DeviceID == "" || poll.ProjectID == "" { + prog.FailStep(0, "incomplete device authorization") + return fmt.Errorf("rho cloud returned an incomplete device authorization") } + if err := cloud.SaveDeviceConfig(cloud.DeviceConfig{Endpoint: endpoint, DeviceID: poll.DeviceID, ProjectID: poll.ProjectID}, poll.Token); err != nil { + prog.FailStep(0, err.Error()) + return err + } + prog.CompleteStep(0) + prog.Done() + cmd.Println(auditTint("Rho Cloud connected for project ", doneGreen) + auditTint(poll.ProjectID, textPrimary) + auditTint(".", doneGreen)) + return nil } }, } diff --git a/internal/platform/cloud/apierror.go b/internal/platform/cloud/apierror.go new file mode 100644 index 00000000..6ed75cbd --- /dev/null +++ b/internal/platform/cloud/apierror.go @@ -0,0 +1,106 @@ +package cloud + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "unicode" +) + +const ( + // maxErrorBodyBytes bounds how much of a non-2xx response body is read. + maxErrorBodyBytes = 16 << 10 + // maxResponseBytes bounds how much of a successful response is decoded. + maxResponseBytes = 64 << 10 + // maxServerTextRunes bounds server-provided text shown to the user. + maxServerTextRunes = 300 +) + +// APIError is a non-2xx response from GrayCode Cloud. Message, Code and State +// carry the JSON "error", "code" and "status" fields of the response body +// when the server sent them, already sanitized for terminal output. +type APIError struct { + Operation string + StatusCode int + Message string + Code string + State string +} + +func (e *APIError) Error() string { + status := fmt.Sprintf("HTTP %d %s", e.StatusCode, http.StatusText(e.StatusCode)) + var msg string + if e.Message != "" { + msg = fmt.Sprintf("GrayCode Cloud %s failed: %s (%s)", e.Operation, e.Message, status) + } else { + msg = fmt.Sprintf("GrayCode Cloud %s failed: %s", e.Operation, status) + } + if e.StatusCode == http.StatusUnauthorized { + msg += "; the device token was rejected or revoked, run `rho cloud login` to reconnect" + } + return msg +} + +// readAPIError reads at most maxErrorBodyBytes of a non-2xx response and +// returns it as an *APIError. A body that is not JSON (for example an HTML +// error page from a proxy) leaves the message empty so the HTTP status is +// reported instead. +func readAPIError(operation string, resp *http.Response) *APIError { + body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes)) + var payload struct { + Error string `json:"error"` + Code string `json:"code"` + Status string `json:"status"` + } + _ = json.Unmarshal(body, &payload) + return &APIError{ + Operation: operation, + StatusCode: resp.StatusCode, + Message: sanitizeServerText(payload.Error), + Code: sanitizeServerText(payload.Code), + State: sanitizeServerText(payload.Status), + } +} + +// decodeResponse decodes at most maxResponseBytes of a successful response. +func decodeResponse(operation string, resp *http.Response, dst any) error { + if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBytes)).Decode(dst); err != nil { + return fmt.Errorf("decode GrayCode Cloud %s response: %w", operation, err) + } + return nil +} + +// sanitizeServerText makes server-controlled text safe to print: control and +// other non-printable characters (including terminal escape sequences) are +// dropped, whitespace is collapsed, and the result is capped. +func sanitizeServerText(text string) string { + var b strings.Builder + runes := 0 + space := false + for _, r := range text { + if unicode.IsSpace(r) { + space = b.Len() > 0 + continue + } + if !unicode.IsPrint(r) { + continue + } + width := 1 + if space { + width = 2 + } + if runes+width > maxServerTextRunes { + b.WriteString("…") + break + } + if space { + b.WriteByte(' ') + space = false + } + b.WriteRune(r) + runes += width + } + return b.String() +} diff --git a/internal/platform/cloud/apierror_test.go b/internal/platform/cloud/apierror_test.go new file mode 100644 index 00000000..6d0774f1 --- /dev/null +++ b/internal/platform/cloud/apierror_test.go @@ -0,0 +1,124 @@ +package cloud + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "unicode/utf8" +) + +func respondWith(status int, body string) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + } +} + +func TestDeviceLoginStartSurfacesJSONError(t *testing.T) { + server := httptest.NewServer(respondWith(http.StatusBadRequest, `{"error":"Invalid device authorization request"}`)) + defer server.Close() + _, err := New(Config{Endpoint: server.URL}).StartDeviceLogin(context.Background(), "laptop", "darwin", "0.3.0") + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusBadRequest { + t.Fatalf("error = %#v, want *APIError with status 400", err) + } + if !strings.Contains(err.Error(), "Invalid device authorization request") || !strings.Contains(err.Error(), "HTTP 400") { + t.Fatalf("error = %q, want the server message and status", err) + } +} + +func TestAPIErrorFallsBackToStatusForNonJSONBody(t *testing.T) { + server := httptest.NewServer(respondWith(http.StatusBadGateway, `bad gateway`)) + defer server.Close() + _, err := New(Config{Endpoint: server.URL}).StartDeviceLogin(context.Background(), "laptop", "darwin", "0.3.0") + if err == nil || !strings.Contains(err.Error(), "HTTP 502 Bad Gateway") || strings.Contains(err.Error(), "") { + t.Fatalf("error = %v, want the HTTP status without the HTML body", err) + } +} + +func TestAPIErrorReadIsBounded(t *testing.T) { + huge := `{"error":"` + strings.Repeat("A", 4*maxErrorBodyBytes) + `"}` + server := httptest.NewServer(respondWith(http.StatusBadRequest, huge)) + defer server.Close() + _, err := New(Config{Endpoint: server.URL}).StartDeviceLogin(context.Background(), "laptop", "darwin", "0.3.0") + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error = %v, want *APIError", err) + } + // Only maxErrorBodyBytes are read, so the truncated JSON is not decoded. + if apiErr.Message != "" || len(err.Error()) > 200 { + t.Fatalf("unbounded error body leaked into the message (%d bytes)", len(err.Error())) + } +} + +func TestSanitizeServerTextStripsControlsAndCaps(t *testing.T) { + got := sanitizeServerText(" \x1b[31mred\x1b[0m\n\tline​ two ") + if strings.ContainsAny(got, "\x1b\n\t​") || got != "[31mred[0m line two" { + t.Fatalf("sanitized = %q", got) + } + long := sanitizeServerText(strings.Repeat("é", 2*maxServerTextRunes)) + if n := utf8.RuneCountInString(long); n != maxServerTextRunes+1 || !strings.HasSuffix(long, "…") { + t.Fatalf("capped length = %d runes (%q...), want %d plus an ellipsis", n, long[:10], maxServerTextRunes) + } +} + +func TestPollDeviceLoginTerminalStates(t *testing.T) { + cases := []struct { + name string + status int + body string + want error + }{ + {"consumed 409", http.StatusConflict, `{"status":"consumed"}`, ErrDeviceLoginConsumed}, + {"consumed 200", http.StatusOK, `{"status":"consumed"}`, ErrDeviceLoginConsumed}, + {"expired", http.StatusOK, `{"status":"expired"}`, ErrDeviceLoginExpired}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(respondWith(tc.status, tc.body)) + defer server.Close() + _, err := New(Config{Endpoint: server.URL}).PollDeviceLogin(context.Background(), "device-code-0123456789") + if !errors.Is(err, tc.want) { + t.Fatalf("error = %v, want %v", err, tc.want) + } + if !strings.Contains(err.Error(), "rho cloud login") { + t.Fatalf("error = %q, want a hint to rerun rho cloud login", err) + } + }) + } +} + +func TestPollDeviceLoginNonTerminalAndUnknownStates(t *testing.T) { + server := httptest.NewServer(respondWith(http.StatusOK, `{"status":"pending"}`)) + defer server.Close() + poll, err := New(Config{Endpoint: server.URL}).PollDeviceLogin(context.Background(), "device-code-0123456789") + if err != nil || poll.Status != DeviceLoginPending { + t.Fatalf("pending poll = %+v, %v", poll, err) + } + + unknown := httptest.NewServer(respondWith(http.StatusOK, "{\"status\":\"sl\\u001bow\"}")) + defer unknown.Close() + _, err = New(Config{Endpoint: unknown.URL}).PollDeviceLogin(context.Background(), "device-code-0123456789") + if err == nil || strings.Contains(err.Error(), "\x1b") || !strings.Contains(err.Error(), "unknown device login status") { + t.Fatalf("unknown status error = %q", err) + } + + conflict := httptest.NewServer(respondWith(http.StatusConflict, `{"error":"Something else"}`)) + defer conflict.Close() + _, err = New(Config{Endpoint: conflict.URL}).PollDeviceLogin(context.Background(), "device-code-0123456789") + var apiErr *APIError + if errors.Is(err, ErrDeviceLoginConsumed) || !errors.As(err, &apiErr) || apiErr.Message != "Something else" { + t.Fatalf("non-consumed 409 error = %v, want a plain *APIError", err) + } +} + +func TestAPIErrorUnauthorizedHint(t *testing.T) { + err := (&APIError{Operation: "usage upload", StatusCode: http.StatusUnauthorized, Message: "Unauthorized"}).Error() + if !strings.Contains(err, "rho cloud login") { + t.Fatalf("401 error = %q, want a reconnect hint", err) + } +} diff --git a/internal/platform/cloud/client.go b/internal/platform/cloud/client.go index 88140e3a..a5ed7387 100644 --- a/internal/platform/cloud/client.go +++ b/internal/platform/cloud/client.go @@ -78,6 +78,23 @@ type DeviceLoginStart struct { Interval int `json:"interval"` } +// Device login states returned by POST /v1/auth/device/poll. +const ( + DeviceLoginPending = "pending" + DeviceLoginApproved = "approved" + DeviceLoginExpired = "expired" + DeviceLoginConsumed = "consumed" +) + +var ( + // ErrDeviceLoginExpired is the terminal "expired" poll state: the code was + // not approved in time (or is unknown to the server). + ErrDeviceLoginExpired = errors.New("the GrayCode Cloud device login expired before it was approved; run `rho cloud login` again") + // ErrDeviceLoginConsumed is the terminal "consumed" poll state (HTTP 409): + // the token for this login was already issued to an earlier poll. + ErrDeviceLoginConsumed = errors.New("this GrayCode Cloud device login was already completed and its token was issued to an earlier request; run `rho cloud login` again") +) + type DeviceLoginPoll struct { Status string `json:"status"` Token string `json:"token"` @@ -163,9 +180,9 @@ func (c *Client) startDeviceLogin(ctx context.Context, label, platform, rhoVersi } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return result, fmt.Errorf("rho cloud device login start: %s", resp.Status) + return result, readAPIError("device login start", resp) } - return result, json.NewDecoder(resp.Body).Decode(&result) + return result, decodeResponse("device login start", resp, &result) } func (c *Client) pollDeviceLogin(ctx context.Context, deviceCode string) (DeviceLoginPoll, error) { @@ -184,9 +201,25 @@ func (c *Client) pollDeviceLogin(ctx context.Context, deviceCode string) (Device } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return result, fmt.Errorf("rho cloud device login poll: %s", resp.Status) + apiErr := readAPIError("device login poll", resp) + if resp.StatusCode == http.StatusConflict && apiErr.State == DeviceLoginConsumed { + return DeviceLoginPoll{Status: DeviceLoginConsumed}, ErrDeviceLoginConsumed + } + return result, apiErr + } + if err := decodeResponse("device login poll", resp, &result); err != nil { + return result, err + } + switch result.Status { + case DeviceLoginPending, DeviceLoginApproved: + return result, nil + case DeviceLoginExpired: + return result, ErrDeviceLoginExpired + case DeviceLoginConsumed: + return result, ErrDeviceLoginConsumed + default: + return result, fmt.Errorf("GrayCode Cloud returned an unknown device login status %q", sanitizeServerText(result.Status)) } - return result, json.NewDecoder(resp.Body).Decode(&result) } func (c *Client) StartDeviceLogin(ctx context.Context, label, platform, rhoVersion string) (DeviceLoginStart, error) { diff --git a/internal/platform/cloud/graph.go b/internal/platform/cloud/graph.go index 7aaef93b..bd769ea6 100644 --- a/internal/platform/cloud/graph.go +++ b/internal/platform/cloud/graph.go @@ -7,15 +7,10 @@ import ( "encoding/hex" "encoding/json" "fmt" - "io" "regexp" - "strings" ) -const ( - MaxGraphSyncBodySize = 1 << 20 - maxGraphResponseSize = 64 << 10 -) +const MaxGraphSyncBodySize = 1 << 20 var ( sensitiveGraphAttribute = regexp.MustCompile(`(?i)(content|prompt|secret|credential|password|api[_-]?key|query|reason|url|path|command|provider|model|repository|branch|commit|source|target|message|evidence|element|file|fix)`) @@ -175,20 +170,11 @@ func (c *Client) SyncGraph(ctx context.Context, request GraphSyncRequest) (Graph } defer func() { _ = resp.Body.Close() }() - limited := io.LimitReader(resp.Body, maxGraphResponseSize) if resp.StatusCode < 200 || resp.StatusCode >= 300 { - var failure struct { - Error string `json:"error"` - } - _ = json.NewDecoder(limited).Decode(&failure) - message := strings.TrimSpace(failure.Error) - if message == "" { - message = resp.Status - } - return result, fmt.Errorf("rho cloud graph sync: %s", message) + return result, readAPIError("graph sync", resp) } - if err := json.NewDecoder(limited).Decode(&result); err != nil { - return result, fmt.Errorf("decode graph sync response: %w", err) + if err := decodeResponse("graph sync", resp, &result); err != nil { + return result, err } if !result.Accepted { return result, fmt.Errorf("rho cloud did not accept graph sync") From 1d7ead47e912db86fdb8f318cac5752889dffa72 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:41:20 +0530 Subject: [PATCH 05/16] 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. Co-Authored-By: Claude Opus 5.5 --- cmd/cloud.go | 16 +- internal/platform/cloud/client.go | 108 ++++++++++++- internal/platform/cloud/device_login_test.go | 150 +++++++++++++++++++ internal/platform/cloud/endpoint.go | 18 ++- 4 files changed, 274 insertions(+), 18 deletions(-) create mode 100644 internal/platform/cloud/device_login_test.go diff --git a/cmd/cloud.go b/cmd/cloud.go index 860e1ce0..fef74306 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -75,12 +75,16 @@ http:// on localhost, 127.0.0.1 and [::1].`, if err != nil { return err } + approvalURL, err := start.ApprovalURL() + if err != nil { + return err + } cmd.Printf("%s\n", auditTint("Open ", textPrimary)+auditTint(start.VerificationURI, infoSky)+auditTint(" and enter code ", textPrimary)+auditTint(start.UserCode, rhoColor)) - if err := openBrowser(start.VerificationURI + "?code=" + start.UserCode); err != nil { + if err := openBrowser(approvalURL); err != nil { cmd.Printf("%s\n", auditTint(fmt.Sprintf("Could not open the browser automatically: %v", err), textMuted)) } interval := time.Duration(start.Interval) * time.Second - if interval < time.Second { + if interval < time.Second || interval > 30*time.Second { interval = 5 * time.Second } prog := NewCLIProgress("Cloud", []string{"Waiting for browser approval"}) @@ -101,12 +105,8 @@ http:// on localhost, 127.0.0.1 and [::1].`, } continue } - // PollDeviceLogin reports expired, consumed and unknown states - // as errors, so the only remaining state is approved. - if poll.Token == "" || poll.DeviceID == "" || poll.ProjectID == "" { - prog.FailStep(0, "incomplete device authorization") - return fmt.Errorf("rho cloud returned an incomplete device authorization") - } + // PollDeviceLogin reports expired, consumed, unknown and + // incomplete states as errors, so this is a complete approval. if err := cloud.SaveDeviceConfig(cloud.DeviceConfig{Endpoint: endpoint, DeviceID: poll.DeviceID, ProjectID: poll.ProjectID}, poll.Token); err != nil { prog.FailStep(0, err.Error()) return err diff --git a/internal/platform/cloud/client.go b/internal/platform/cloud/client.go index a5ed7387..e18bce6a 100644 --- a/internal/platform/cloud/client.go +++ b/internal/platform/cloud/client.go @@ -8,8 +8,11 @@ import ( "errors" "fmt" "net/http" + "net/url" "strings" "time" + "unicode" + "unicode/utf16" ) type Config struct { @@ -70,6 +73,23 @@ type DeploymentContext struct { Status string `json:"status"` } +// Bounds of POST /v1/auth/device/start (strict schema; lengths are counted in +// UTF-16 code units, as the Worker's zod schema does). +const ( + maxDeviceLabel = 100 + maxDevicePlatform = 50 + maxGraycodeVersion = 50 + maxUserCode = 32 +) + +// deviceStartRequest is the exact body of POST /v1/auth/device/start. The +// schema is strict, so no other field may be sent. +type deviceStartRequest struct { + Label string `json:"label"` + Platform string `json:"platform"` + GraycodeVersion string `json:"graycodeVersion"` +} + type DeviceLoginStart struct { DeviceCode string `json:"deviceCode"` UserCode string `json:"userCode"` @@ -103,6 +123,66 @@ type DeviceLoginPoll struct { PrincipalID string `json:"principalId"` } +// ApprovalURL returns the browser URL for approving this login: the +// verification URI with the user code as the "code" query parameter. The +// URI must satisfy the same TLS policy as the API endpoint, so a server +// response can never make rho open a non-web or plaintext remote URL. +func (s DeviceLoginStart) ApprovalURL() (string, error) { + u, err := url.Parse(s.VerificationURI) + if err != nil || u.Opaque != "" || u.Hostname() == "" || u.User != nil { + return "", fmt.Errorf("GrayCode Cloud returned an invalid verification URI") + } + if err := requireSecureTransport(u); err != nil { + return "", fmt.Errorf("GrayCode Cloud returned an unsafe verification URI: %w", err) + } + query := u.Query() + query.Set("code", s.UserCode) + u.RawQuery = query.Encode() + u.Fragment = "" + return u.String(), nil +} + +func (s DeviceLoginStart) validate() error { + if s.DeviceCode == "" || s.VerificationURI == "" { + return fmt.Errorf("GrayCode Cloud returned an incomplete device login response") + } + if s.UserCode == "" || len(s.UserCode) > maxUserCode || + strings.ContainsFunc(s.UserCode, func(r rune) bool { return !strings.ContainsRune(userCodeAlphabet, r) }) { + return fmt.Errorf("GrayCode Cloud returned an invalid device login user code") + } + _, err := s.ApprovalURL() + return err +} + +const userCodeAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-" + +// boundedField prepares a free-text request field for a strict length check: +// non-printable characters are dropped, surrounding space is trimmed, the +// value is cut to at most maxUnits UTF-16 code units, and fallback is used +// when nothing remains. +func boundedField(value string, maxUnits int, fallback string) string { + var b strings.Builder + units := 0 + for _, r := range strings.TrimSpace(value) { + if !unicode.IsPrint(r) { + continue + } + width := utf16.RuneLen(r) + if width < 0 { + continue + } + if units+width > maxUnits { + break + } + b.WriteRune(r) + units += width + } + if out := strings.TrimSpace(b.String()); out != "" { + return out + } + return fallback +} + type Client struct { endpoint, token string // endpointErr records why Config.Endpoint was refused (for example a @@ -164,9 +244,13 @@ func (c *Client) newJSONRequest(ctx context.Context, path string, body []byte, a return req, nil } -func (c *Client) startDeviceLogin(ctx context.Context, label, platform, rhoVersion string) (DeviceLoginStart, error) { +func (c *Client) startDeviceLogin(ctx context.Context, label, platform, version string) (DeviceLoginStart, error) { var result DeviceLoginStart - body, err := json.Marshal(map[string]string{"label": label, "platform": platform, "rhoVersion": rhoVersion}) + body, err := json.Marshal(deviceStartRequest{ + Label: boundedField(label, maxDeviceLabel, "rho"), + Platform: boundedField(platform, maxDevicePlatform, "unknown"), + GraycodeVersion: boundedField(version, maxGraycodeVersion, "dev"), + }) if err != nil { return result, err } @@ -182,7 +266,10 @@ func (c *Client) startDeviceLogin(ctx context.Context, label, platform, rhoVersi if resp.StatusCode < 200 || resp.StatusCode >= 300 { return result, readAPIError("device login start", resp) } - return result, decodeResponse("device login start", resp, &result) + if err := decodeResponse("device login start", resp, &result); err != nil { + return result, err + } + return result, result.validate() } func (c *Client) pollDeviceLogin(ctx context.Context, deviceCode string) (DeviceLoginPoll, error) { @@ -211,7 +298,13 @@ func (c *Client) pollDeviceLogin(ctx context.Context, deviceCode string) (Device return result, err } switch result.Status { - case DeviceLoginPending, DeviceLoginApproved: + case DeviceLoginPending: + return result, nil + case DeviceLoginApproved: + if result.Token == "" || result.DeviceID == "" || result.ProjectID == "" || + strings.ContainsFunc(result.Token, func(r rune) bool { return !unicode.IsGraphic(r) || unicode.IsSpace(r) }) { + return result, fmt.Errorf("GrayCode Cloud returned an incomplete device authorization") + } return result, nil case DeviceLoginExpired: return result, ErrDeviceLoginExpired @@ -222,11 +315,14 @@ func (c *Client) pollDeviceLogin(ctx context.Context, deviceCode string) (Device } } -func (c *Client) StartDeviceLogin(ctx context.Context, label, platform, rhoVersion string) (DeviceLoginStart, error) { +// StartDeviceLogin begins a browser device login. version is rho's own version +// string, sent as the contract's graycodeVersion field; label, platform and +// version are cut to the contract's bounds. +func (c *Client) StartDeviceLogin(ctx context.Context, label, platform, version string) (DeviceLoginStart, error) { if err := c.checkEndpoint(); err != nil { return DeviceLoginStart{}, err } - return c.startDeviceLogin(ctx, label, platform, rhoVersion) + return c.startDeviceLogin(ctx, label, platform, version) } func (c *Client) PollDeviceLogin(ctx context.Context, deviceCode string) (DeviceLoginPoll, error) { diff --git a/internal/platform/cloud/device_login_test.go b/internal/platform/cloud/device_login_test.go new file mode 100644 index 00000000..5c007a90 --- /dev/null +++ b/internal/platform/cloud/device_login_test.go @@ -0,0 +1,150 @@ +package cloud + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "unicode/utf16" +) + +const validStartResponse = `{"deviceCode":"dc_0123456789abcdefghij","userCode":"ABCD-EFGH","verificationUri":"https://graycodeai.com/cli/approve","expiresIn":600,"interval":5}` + +// captureStart returns a server that records the raw device-start body. +func captureStart(t *testing.T, response string, body *[]byte) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/auth/device/start" || r.Method != http.MethodPost { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("Authorization") != "" { + t.Error("device start must not carry a device token") + } + raw, _ := io.ReadAll(r.Body) + *body = raw + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(response)) + })) + t.Cleanup(server.Close) + return server +} + +func TestStartDeviceLoginSendsGraycodeVersion(t *testing.T) { + var raw []byte + server := captureStart(t, validStartResponse, &raw) + start, err := New(Config{Endpoint: server.URL}).StartDeviceLogin(context.Background(), "build-host", "linux", "0.3.0") + if err != nil { + t.Fatal(err) + } + var body struct { + Label string `json:"label"` + Platform string `json:"platform"` + GraycodeVersion string `json:"graycodeVersion"` + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&body); err != nil { + t.Fatalf("device start body %s violates the strict schema: %v", raw, err) + } + if body.Label != "build-host" || body.Platform != "linux" || body.GraycodeVersion != "0.3.0" { + t.Fatalf("body = %+v", body) + } + if bytes.Contains(raw, []byte("rhoVersion")) { + t.Fatalf("body still sends rhoVersion: %s", raw) + } + if start.UserCode != "ABCD-EFGH" || start.Interval != 5 { + t.Fatalf("start = %+v", start) + } +} + +func TestStartDeviceLoginBoundsFields(t *testing.T) { + var raw []byte + server := captureStart(t, validStartResponse, &raw) + longLabel := strings.Repeat("\U0001F600", 60) // 120 UTF-16 code units + if _, err := New(Config{Endpoint: server.URL}).StartDeviceLogin(context.Background(), longLabel, "", strings.Repeat("9", 80)); err != nil { + t.Fatal(err) + } + var body map[string]string + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatal(err) + } + if n := len(utf16.Encode([]rune(body["label"]))); n == 0 || n > maxDeviceLabel { + t.Fatalf("label is %d UTF-16 units, want 1..%d", n, maxDeviceLabel) + } + if body["platform"] != "unknown" { + t.Fatalf("empty platform = %q, want fallback", body["platform"]) + } + if n := len(body["graycodeVersion"]); n != maxGraycodeVersion { + t.Fatalf("graycodeVersion length = %d, want %d", n, maxGraycodeVersion) + } + + if _, err := New(Config{Endpoint: server.URL}).StartDeviceLogin(context.Background(), " \t\x00 ", "darwin", ""); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatal(err) + } + if body["label"] != "rho" || body["graycodeVersion"] != "dev" { + t.Fatalf("blank fields = %+v, want fallbacks", body) + } +} + +func TestStartDeviceLoginRejectsUnsafeResponses(t *testing.T) { + cases := map[string]string{ + "plaintext remote uri": `{"deviceCode":"dc_0123456789abcdefghij","userCode":"ABCD-EFGH","verificationUri":"http://graycodeai.com/cli/approve","expiresIn":600,"interval":5}`, + "non-web uri": `{"deviceCode":"dc_0123456789abcdefghij","userCode":"ABCD-EFGH","verificationUri":"file:///etc/passwd","expiresIn":600,"interval":5}`, + "user code injection": `{"deviceCode":"dc_0123456789abcdefghij","userCode":"AB&x=1","verificationUri":"https://graycodeai.com/cli/approve","expiresIn":600,"interval":5}`, + "missing device code": `{"userCode":"ABCD-EFGH","verificationUri":"https://graycodeai.com/cli/approve","expiresIn":600,"interval":5}`, + } + for name, response := range cases { + t.Run(name, func(t *testing.T) { + var raw []byte + server := captureStart(t, response, &raw) + if _, err := New(Config{Endpoint: server.URL}).StartDeviceLogin(context.Background(), "host", "linux", "0.3.0"); err == nil { + t.Fatal("unsafe device start response was accepted") + } + }) + } +} + +func TestApprovalURLEscapesCodeAndKeepsPath(t *testing.T) { + got, err := DeviceLoginStart{UserCode: "ABCD-EFGH", VerificationURI: "https://graycodeai.com/cli/approve"}.ApprovalURL() + if err != nil || got != "https://graycodeai.com/cli/approve?code=ABCD-EFGH" { + t.Fatalf("ApprovalURL = %q, %v", got, err) + } + got, err = DeviceLoginStart{UserCode: "WXYZ-2345", VerificationURI: "http://localhost:3000/cli/approve?ref=cli#x"}.ApprovalURL() + if err != nil || got != "http://localhost:3000/cli/approve?code=WXYZ-2345&ref=cli" { + t.Fatalf("loopback ApprovalURL = %q, %v", got, err) + } + if _, err := (DeviceLoginStart{UserCode: "ABCD-EFGH", VerificationURI: "http://graycodeai.com/cli/approve"}).ApprovalURL(); !errors.Is(err, ErrInsecureEndpoint) { + t.Fatalf("plaintext ApprovalURL error = %v", err) + } +} + +func TestPollDeviceLoginRejectsIncompleteApproval(t *testing.T) { + for name, body := range map[string]string{ + "no token": `{"status":"approved","deviceId":"d_0123456789abcdef","projectId":"p_0123456789abcdef","principalId":"u1"}`, + "no device": `{"status":"approved","token":"hwc_abc","projectId":"p_0123456789abcdef","principalId":"u1"}`, + "token whitespace": `{"status":"approved","token":"hwc_abc\n","deviceId":"d_0123456789abcdef","projectId":"p_0123456789abcdef","principalId":"u1"}`, + } { + t.Run(name, func(t *testing.T) { + server := httptest.NewServer(respondWith(http.StatusOK, body)) + defer server.Close() + if _, err := New(Config{Endpoint: server.URL}).PollDeviceLogin(context.Background(), "dc_0123456789abcdefghij"); err == nil { + t.Fatal("incomplete approval accepted") + } + }) + } + server := httptest.NewServer(respondWith(http.StatusOK, `{"status":"approved","token":"hwc_abc","deviceId":"d_0123456789abcdef","projectId":"p_0123456789abcdef","principalId":"u1"}`)) + defer server.Close() + poll, err := New(Config{Endpoint: server.URL}).PollDeviceLogin(context.Background(), "dc_0123456789abcdefghij") + if err != nil || poll.Token != "hwc_abc" || poll.ProjectID != "p_0123456789abcdef" { + t.Fatalf("approved poll = %+v, %v", poll, err) + } +} diff --git a/internal/platform/cloud/endpoint.go b/internal/platform/cloud/endpoint.go index 34181c9d..5b1b0708 100644 --- a/internal/platform/cloud/endpoint.go +++ b/internal/platform/cloud/endpoint.go @@ -59,16 +59,26 @@ func NormalizeEndpoint(raw string) (string, error) { if u.RawQuery != "" || u.ForceQuery || u.Fragment != "" { return "", errors.New("GrayCode Cloud endpoint must not contain a query or fragment") } + if err := requireSecureTransport(u); err != nil { + return "", err + } + return strings.TrimRight(u.String(), "/"), nil +} + +// requireSecureTransport enforces the TLS policy on a parsed absolute URL: +// https://, or http:// to a loopback host. +func requireSecureTransport(u *url.URL) error { switch u.Scheme { case "https": + return nil case "http": - if !isLoopbackHost(u.Hostname()) { - return "", fmt.Errorf("%w: got http://%s", ErrInsecureEndpoint, u.Host) + if isLoopbackHost(u.Hostname()) { + return nil } + return fmt.Errorf("%w: got http://%s", ErrInsecureEndpoint, u.Host) default: - return "", fmt.Errorf("%w: got scheme %q", ErrInsecureEndpoint, u.Scheme) + return fmt.Errorf("%w: got scheme %q", ErrInsecureEndpoint, u.Scheme) } - return strings.TrimRight(u.String(), "/"), nil } // isLoopbackHost reports whether host is one of the loopback names for which From 2cab6ab79f47b7dd8740f981bdf4f6334fd0d701 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:43:39 +0530 Subject: [PATCH 06/16] 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. Co-Authored-By: Claude Opus 5.5 --- cmd/cloud.go | 32 +++++- cmd/cloud_test.go | 76 ++++++++++++++ internal/platform/cloud/client.go | 19 ---- internal/platform/cloud/client_test.go | 12 ++- internal/platform/cloud/contract.go | 63 ++++++++++++ internal/platform/cloud/delivery.go | 121 +++++++++++++++++++++++ internal/platform/cloud/delivery_test.go | 107 ++++++++++++++++++++ 7 files changed, 402 insertions(+), 28 deletions(-) create mode 100644 internal/platform/cloud/contract.go create mode 100644 internal/platform/cloud/delivery.go create mode 100644 internal/platform/cloud/delivery_test.go diff --git a/cmd/cloud.go b/cmd/cloud.go index fef74306..9f5e943b 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -5,6 +5,8 @@ import ( "fmt" "os" "runtime" + "slices" + "strings" "time" cloud "github.com/GrayCodeAI/rho/internal/platform/cloud" @@ -15,6 +17,9 @@ import ( // commands that create a new GrayCode Cloud connection. const cloudEndpointFlagHelp = "GrayCode Cloud API endpoint (default: $" + cloud.EndpointEnv + ", else " + cloud.DefaultEndpoint + ")" +// loadCloudClient loads the saved GrayCode Cloud connection; tests replace it. +var loadCloudClient = cloud.LoadClient + var cloudCmd = &cobra.Command{Use: "cloud", Short: "Manage optional Rho Cloud synchronization"} func newCloudConnectCmd() *cobra.Command { @@ -143,7 +148,7 @@ func newCloudContextCmd() *cobra.Command { Use: "context", Short: "Sync repository context to Rho Cloud", RunE: func(cmd *cobra.Command, _ []string) error { - client, cfg, err := cloud.LoadClient() + client, cfg, err := loadCloudClient() if err != nil || !client.Enabled() { return fmt.Errorf("rho cloud is not connected") } @@ -165,6 +170,12 @@ func newCloudContextCmd() *cobra.Command { deploymentID, _ := cmd.Flags().GetString("deployment") deploymentStatus, _ := cmd.Flags().GetString("deployment-status") deploymentEnvironment, _ := cmd.Flags().GetString("deployment-environment") + if err := checkFlagChoice("--ci-status", ciStatus, cloud.CIRunStatuses); err != nil { + return err + } + if err := checkFlagChoice("--deployment-status", deploymentStatus, cloud.DeploymentStatuses); err != nil { + return err + } if contextProvider == "" { contextProvider = detected.Provider } @@ -204,8 +215,10 @@ func newCloudContextCmd() *cobra.Command { } event.Deployment = &cloud.DeploymentContext{Provider: contextProvider, ExternalID: deploymentID, Environment: deploymentEnvironment, Status: deploymentStatus} } - client.RecordDeliveryContext(cmd.Context(), event) - cmd.Println(auditTint("Repository context queued for Rho Cloud.", doneGreen)) + if err := client.SendDeliveryContext(cmd.Context(), event); err != nil { + return err + } + cmd.Println(auditTint("Repository context synced to GrayCode Cloud.", doneGreen)) return nil }, } @@ -215,14 +228,23 @@ func newCloudContextCmd() *cobra.Command { command.Flags().String("branch", "", "Current branch (auto-detected when omitted)") command.Flags().String("commit", "", "Current commit SHA (auto-detected when omitted)") command.Flags().String("ci-run", "", "CI run identifier (defaults to GITHUB_RUN_ID)") - command.Flags().String("ci-status", "", "CI status: queued, running, succeeded, failed, or cancelled") + command.Flags().String("ci-status", "", "CI status: "+strings.Join(cloud.CIRunStatuses, ", ")+" (default running)") command.Flags().String("ci-workflow", "", "CI workflow name (defaults to GITHUB_WORKFLOW)") command.Flags().String("deployment", "", "Deployment identifier") - command.Flags().String("deployment-status", "", "Deployment status") + command.Flags().String("deployment-status", "", "Deployment status: "+strings.Join(cloud.DeploymentStatuses, ", ")+" (default running)") command.Flags().String("deployment-environment", "", "Deployment environment, for example production") return command } +// checkFlagChoice rejects a non-empty flag value outside allowed, naming the +// flag so the user can fix the invocation before anything is sent. +func checkFlagChoice(flag, value string, allowed []string) error { + if value == "" || slices.Contains(allowed, value) { + return nil + } + return fmt.Errorf("%s %q must be one of: %s", flag, value, strings.Join(allowed, ", ")) +} + func firstValue(values ...string) string { for _, value := range values { if value != "" { diff --git a/cmd/cloud_test.go b/cmd/cloud_test.go index 60b4b950..a7d61307 100644 --- a/cmd/cloud_test.go +++ b/cmd/cloud_test.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "context" "errors" "net/http" "net/http/httptest" @@ -77,3 +78,78 @@ func TestCloudConnectResolvesEndpointFromEnvironment(t *testing.T) { t.Fatalf("error = %v, want only the missing device-id/project-id/token (endpoint has a default)", err) } } + +// useCloudClient points the cloud commands at client/cfg for one test. +func useCloudClient(t *testing.T, client *cloud.Client, cfg cloud.DeviceConfig, err error) { + t.Helper() + original := loadCloudClient + t.Cleanup(func() { loadCloudClient = original }) + loadCloudClient = func() (*cloud.Client, cloud.DeviceConfig, error) { return client, cfg, err } +} + +// noGitRemote makes git context detection fail so tests pass --repository. +func noGitRemote(t *testing.T) { + t.Helper() + original := runCloudGit + t.Cleanup(func() { runCloudGit = original }) + runCloudGit = func(context.Context, ...string) (string, error) { return "", errors.New("no git") } + t.Setenv("GITHUB_RUN_ID", "") + t.Setenv("GITHUB_WORKFLOW", "") +} + +func TestCloudContextReportsWorkerRejection(t *testing.T) { + noGitRemote(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"Device not found"}`)) + })) + defer server.Close() + useCloudClient(t, cloud.New(cloud.Config{Endpoint: server.URL, DeviceToken: "hwc_test"}), cloud.DeviceConfig{ProjectID: "project_0123456789"}, nil) + + out, err := runCloudCommand(t, newCloudContextCmd(), "--repository", "GrayCodeAI/rho") + if err == nil || !strings.Contains(err.Error(), "Device not found") { + t.Fatalf("error = %v, want the Worker's rejection", err) + } + if strings.Contains(out, "synced") || strings.Contains(out, "queued") { + t.Fatalf("output claims success after a rejection: %q", out) + } +} + +func TestCloudContextReportsSuccessOnlyWhenAccepted(t *testing.T) { + noGitRemote(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"accepted":true}`)) + })) + defer server.Close() + useCloudClient(t, cloud.New(cloud.Config{Endpoint: server.URL, DeviceToken: "hwc_test"}), cloud.DeviceConfig{ProjectID: "project_0123456789"}, nil) + + out, err := runCloudCommand(t, newCloudContextCmd(), "--repository", "GrayCodeAI/rho", "--ci-run", "42", "--ci-status", "succeeded") + if err != nil || !strings.Contains(out, "synced to GrayCode Cloud") { + t.Fatalf("out = %q, err = %v", out, err) + } +} + +func TestCloudContextRejectsUnknownStatusFlags(t *testing.T) { + noGitRemote(t) + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + useCloudClient(t, cloud.New(cloud.Config{Endpoint: server.URL, DeviceToken: "hwc_test"}), cloud.DeviceConfig{ProjectID: "project_0123456789"}, nil) + + _, err := runCloudCommand(t, newCloudContextCmd(), "--repository", "GrayCodeAI/rho", "--ci-run", "42", "--ci-status", "passed") + if err == nil || !strings.Contains(err.Error(), "--ci-status") { + t.Fatalf("error = %v, want a --ci-status error", err) + } + _, err = runCloudCommand(t, newCloudContextCmd(), "--repository", "GrayCodeAI/rho", "--deployment", "d1", "--deployment-environment", "prod", "--deployment-status", "done") + if err == nil || !strings.Contains(err.Error(), "--deployment-status") || !strings.Contains(err.Error(), "rolled_back") { + t.Fatalf("error = %v, want a --deployment-status error listing rolled_back", err) + } + if hits.Load() != 0 { + t.Fatalf("server received %d requests for invalid flags", hits.Load()) + } +} diff --git a/internal/platform/cloud/client.go b/internal/platform/cloud/client.go index e18bce6a..80723e2d 100644 --- a/internal/platform/cloud/client.go +++ b/internal/platform/cloud/client.go @@ -350,22 +350,3 @@ func (c *Client) RecordUsage(ctx context.Context, event UsageEvent) { _ = resp.Body.Close() } } - -// RecordDeliveryContext is fail-open: delivery metadata must not affect local execution. -func (c *Client) RecordDeliveryContext(ctx context.Context, event DeliveryContext) { - if !c.Enabled() { - return - } - body, err := json.Marshal(event) - if err != nil { - return - } - req, err := c.newJSONRequest(ctx, "/v1/delivery-context", body, true) - if err != nil { - return - } - resp, err := c.http.Do(req) - if err == nil && resp != nil { - _ = resp.Body.Close() - } -} diff --git a/internal/platform/cloud/client_test.go b/internal/platform/cloud/client_test.go index 9c1a22cc..12e7323c 100644 --- a/internal/platform/cloud/client_test.go +++ b/internal/platform/cloud/client_test.go @@ -29,7 +29,7 @@ func TestDisabledClientDoesNotSend(t *testing.T) { New(Config{}).RecordUsage(context.Background(), UsageEvent{}) } -func TestRecordDeliveryContextUsesDeviceScopedEndpoint(t *testing.T) { +func TestSendDeliveryContextUsesDeviceScopedEndpoint(t *testing.T) { var gotAuth, gotPath string s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth, gotPath = r.Header.Get("Authorization"), r.URL.Path @@ -38,13 +38,15 @@ func TestRecordDeliveryContextUsesDeviceScopedEndpoint(t *testing.T) { defer s.Close() event := DeliveryContext{ProjectID: "project_0123456789", Branch: "main", CommitSHA: "abc123"} event.Repository.Provider, event.Repository.ExternalID, event.Repository.Name = "git", "graycode-eco", "GrayCodeAI/graycode-eco" - New(Config{Endpoint: s.URL, DeviceToken: "hwc_test"}).RecordDeliveryContext(context.Background(), event) + if err := New(Config{Endpoint: s.URL, DeviceToken: "hwc_test"}).SendDeliveryContext(context.Background(), event); err != nil { + t.Fatal(err) + } if gotPath != "/v1/delivery-context" || gotAuth != "Bearer hwc_test" { t.Fatalf("path/auth = %q/%q", gotPath, gotAuth) } } -func TestRecordDeliveryContextIncludesCIRunAndDeployment(t *testing.T) { +func TestSendDeliveryContextIncludesCIRunAndDeployment(t *testing.T) { var body DeliveryContext s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&body); err != nil { @@ -57,7 +59,9 @@ func TestRecordDeliveryContextIncludesCIRunAndDeployment(t *testing.T) { event.Repository.Provider, event.Repository.ExternalID, event.Repository.Name = "github", "1", "GrayCodeAI/rho" event.CIRun = &CIRunContext{Provider: "github", ExternalID: "run-1", Workflow: "test", Status: "succeeded"} event.Deployment = &DeploymentContext{Provider: "github", ExternalID: "deploy-1", Environment: "production", Status: "succeeded"} - New(Config{Endpoint: s.URL, DeviceToken: "hwc_test"}).RecordDeliveryContext(context.Background(), event) + if err := New(Config{Endpoint: s.URL, DeviceToken: "hwc_test"}).SendDeliveryContext(context.Background(), event); err != nil { + t.Fatal(err) + } if body.CIRun == nil || body.CIRun.ExternalID != "run-1" { t.Fatalf("CI run = %+v", body.CIRun) } diff --git a/internal/platform/cloud/contract.go b/internal/platform/cloud/contract.go new file mode 100644 index 00000000..012332ec --- /dev/null +++ b/internal/platform/cloud/contract.go @@ -0,0 +1,63 @@ +package cloud + +import ( + "fmt" + "regexp" + "slices" + "strings" + "unicode/utf16" +) + +// opaqueIDPattern and the 16..128 length bound mirror the Worker's opaqueID +// schema used for project, device, event, session and sync identifiers. +var opaqueIDPattern = regexp.MustCompile(`^[A-Za-z0-9._:-]+$`) + +const ( + minOpaqueID = 16 + maxOpaqueID = 128 +) + +// ValidOpaqueID reports whether id satisfies GrayCode Cloud's opaque +// identifier schema: 16 to 128 letters, digits, '.', '_', ':' or '-'. +func ValidOpaqueID(id string) bool { + return len(id) >= minOpaqueID && len(id) <= maxOpaqueID && opaqueIDPattern.MatchString(id) +} + +// utf16Len counts s in UTF-16 code units, the unit zod's string length +// checks use. +func utf16Len(s string) int { + n := 0 + for _, r := range s { + if width := utf16.RuneLen(r); width > 0 { + n += width + } else { + n++ + } + } + return n +} + +// requireText checks a mandatory, already-trimmed text field of at most max +// UTF-16 code units. +func requireText(field, value string, maxUnits int) error { + if value == "" { + return fmt.Errorf("%s is required", field) + } + return optionalText(field, value, maxUnits) +} + +// optionalText checks an optional, already-trimmed text field. +func optionalText(field, value string, maxUnits int) error { + if n := utf16Len(value); n > maxUnits { + return fmt.Errorf("%s is %d characters; GrayCode Cloud accepts at most %d", field, n, maxUnits) + } + return nil +} + +// requireOneOf checks an enum field. +func requireOneOf(field, value string, allowed []string) error { + if !slices.Contains(allowed, value) { + return fmt.Errorf("%s %q is not one of %s", field, value, strings.Join(allowed, ", ")) + } + return nil +} diff --git a/internal/platform/cloud/delivery.go b/internal/platform/cloud/delivery.go new file mode 100644 index 00000000..8b187cca --- /dev/null +++ b/internal/platform/cloud/delivery.go @@ -0,0 +1,121 @@ +package cloud + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strings" +) + +// CI run and deployment statuses accepted by POST /v1/delivery-context. +var ( + CIRunStatuses = []string{"queued", "running", "succeeded", "failed", "cancelled"} + DeploymentStatuses = []string{"queued", "running", "succeeded", "failed", "cancelled", "rolled_back"} +) + +// SendDeliveryContext uploads repository and delivery metadata for the +// connected project. It backs the explicit `rho cloud context` command, so +// validation failures, transport errors and non-2xx responses are all +// returned; nothing is reported as synced unless GrayCode Cloud accepted it. +func (c *Client) SendDeliveryContext(ctx context.Context, event DeliveryContext) error { + if err := c.checkEndpoint(); err != nil { + return err + } + if c.token == "" { + return fmt.Errorf("GrayCode Cloud is not connected; run `rho cloud login`") + } + event = event.normalized() + if err := event.Validate(); err != nil { + return err + } + body, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("marshal delivery context: %w", err) + } + req, err := c.newJSONRequest(ctx, "/v1/delivery-context", body, true) + if err != nil { + return err + } + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("sync delivery context to GrayCode Cloud: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return readAPIError("delivery context sync", resp) + } + return nil +} + +// normalized trims every text field, as the Worker's schema does before its +// length checks. +func (e DeliveryContext) normalized() DeliveryContext { + trim := strings.TrimSpace + e.ProjectID = trim(e.ProjectID) + e.Repository.Provider = trim(e.Repository.Provider) + e.Repository.ExternalID = trim(e.Repository.ExternalID) + e.Repository.Name = trim(e.Repository.Name) + e.Repository.URL = trim(e.Repository.URL) + e.Repository.DefaultBranch = trim(e.Repository.DefaultBranch) + e.Branch = trim(e.Branch) + e.CommitSHA = trim(e.CommitSHA) + if e.CIRun != nil { + run := *e.CIRun + run.Provider, run.ExternalID, run.Workflow, run.Status = trim(run.Provider), trim(run.ExternalID), trim(run.Workflow), trim(run.Status) + e.CIRun = &run + } + if e.Deployment != nil { + deployment := *e.Deployment + deployment.Provider, deployment.ExternalID = trim(deployment.Provider), trim(deployment.ExternalID) + deployment.Environment, deployment.Status = trim(deployment.Environment), trim(deployment.Status) + e.Deployment = &deployment + } + return e +} + +// Validate checks the event against the POST /v1/delivery-context schema so +// the user gets a specific message instead of the Worker's generic +// "Invalid delivery context". Call it on a normalized (trimmed) event. +func (e DeliveryContext) Validate() error { + if !ValidOpaqueID(e.ProjectID) { + return fmt.Errorf("project ID %q is not a valid GrayCode Cloud identifier", e.ProjectID) + } + checks := []error{ + requireText("repository provider", e.Repository.Provider, 50), + requireText("repository external ID", e.Repository.ExternalID, 200), + requireText("repository name", e.Repository.Name, 300), + optionalText("repository default branch", e.Repository.DefaultBranch, 200), + optionalText("branch", e.Branch, 200), + optionalText("commit SHA", e.CommitSHA, 128), + } + if e.Repository.URL != "" { + if u, err := url.Parse(e.Repository.URL); err != nil || u.Scheme == "" || len(e.Repository.URL) > 2000 { + checks = append(checks, fmt.Errorf("repository URL %q is not a valid URL", e.Repository.URL)) + } + } + if run := e.CIRun; run != nil { + checks = append( + checks, + requireText("CI run provider", run.Provider, 50), + requireText("CI run ID", run.ExternalID, 200), + optionalText("CI workflow", run.Workflow, 200), + requireOneOf("CI run status", run.Status, CIRunStatuses), + ) + } + if deployment := e.Deployment; deployment != nil { + checks = append( + checks, + requireText("deployment provider", deployment.Provider, 50), + requireText("deployment ID", deployment.ExternalID, 200), + requireText("deployment environment", deployment.Environment, 100), + requireOneOf("deployment status", deployment.Status, DeploymentStatuses), + ) + } + for _, err := range checks { + if err != nil { + return err + } + } + return nil +} diff --git a/internal/platform/cloud/delivery_test.go b/internal/platform/cloud/delivery_test.go new file mode 100644 index 00000000..c6e7a641 --- /dev/null +++ b/internal/platform/cloud/delivery_test.go @@ -0,0 +1,107 @@ +package cloud + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func validDeliveryContext() DeliveryContext { + event := DeliveryContext{ProjectID: "project_0123456789", Branch: "main", CommitSHA: "abc123"} + event.Repository.Provider, event.Repository.ExternalID, event.Repository.Name = "github", "GrayCodeAI/rho", "GrayCodeAI/rho" + return event +} + +func TestSendDeliveryContextReportsRejection(t *testing.T) { + server := httptest.NewServer(respondWith(http.StatusBadRequest, `{"error":"Invalid delivery context"}`)) + defer server.Close() + err := New(Config{Endpoint: server.URL, DeviceToken: "hwc_test"}).SendDeliveryContext(context.Background(), validDeliveryContext()) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusBadRequest || !strings.Contains(err.Error(), "Invalid delivery context") { + t.Fatalf("error = %v, want the Worker's 400", err) + } + + revoked := httptest.NewServer(respondWith(http.StatusUnauthorized, `{"error":"Unauthorized"}`)) + defer revoked.Close() + err = New(Config{Endpoint: revoked.URL, DeviceToken: "hwc_revoked"}).SendDeliveryContext(context.Background(), validDeliveryContext()) + if err == nil || !strings.Contains(err.Error(), "rho cloud login") { + t.Fatalf("revoked-token error = %v, want a reconnect hint", err) + } +} + +func TestSendDeliveryContextValidatesBeforeSending(t *testing.T) { + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + client := New(Config{Endpoint: server.URL, DeviceToken: "hwc_test"}) + + cases := map[string]func(*DeliveryContext){ + "short project id": func(e *DeliveryContext) { e.ProjectID = "short" }, + "bad ci status": func(e *DeliveryContext) { + e.CIRun = &CIRunContext{Provider: "github", ExternalID: "1", Status: "passed"} + }, + "bad deploy status": func(e *DeliveryContext) { + e.Deployment = &DeploymentContext{Provider: "github", ExternalID: "d1", Environment: "prod", Status: "done"} + }, + "no environment": func(e *DeliveryContext) { + e.Deployment = &DeploymentContext{Provider: "github", ExternalID: "d1", Status: "running"} + }, + "blank repository": func(e *DeliveryContext) { e.Repository.Name = " " }, + "long branch": func(e *DeliveryContext) { e.Branch = strings.Repeat("b", 201) }, + "long commit": func(e *DeliveryContext) { e.CommitSHA = strings.Repeat("c", 129) }, + } + for name, mutate := range cases { + event := validDeliveryContext() + mutate(&event) + if err := client.SendDeliveryContext(context.Background(), event); err == nil { + t.Errorf("%s: invalid delivery context was accepted", name) + } + } + if n := hits.Load(); n != 0 { + t.Fatalf("server received %d invalid requests, want 0", n) + } +} + +func TestSendDeliveryContextTrimsFields(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&body) + w.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + event := validDeliveryContext() + event.Branch = " main \n" + event.CIRun = &CIRunContext{Provider: " github ", ExternalID: " 42 ", Status: " succeeded "} + if err := New(Config{Endpoint: server.URL, DeviceToken: "hwc_test"}).SendDeliveryContext(context.Background(), event); err != nil { + t.Fatal(err) + } + ciRun := body["ciRun"].(map[string]any) + if body["branch"] != "main" || ciRun["status"] != "succeeded" || ciRun["externalId"] != "42" { + t.Fatalf("body = %v", body) + } +} + +func TestValidOpaqueID(t *testing.T) { + for id, want := range map[string]bool{ + "0123456789abcdef": true, + "exec-1727400000000-ab12": true, + "graph_" + strings.Repeat("a", 64): true, + "a:b.c_d-e0123456789": true, + "0123456789abcde": false, + strings.Repeat("a", 129): false, + "0123456789 abcdef": false, + "0123456789/abcdef": false, + } { + if got := ValidOpaqueID(id); got != want { + t.Errorf("ValidOpaqueID(%q) = %v, want %v", id, got, want) + } + } +} From a68c094f316923cd453371847a769d33a3faece8 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:46:09 +0530 Subject: [PATCH 07/16] 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. Co-Authored-By: Claude Opus 5.5 --- cmd/cloud.go | 17 ++- cmd/cloud_graph.go | 6 +- cmd/cloud_test.go | 49 +++++++++ internal/platform/cloud/config.go | 46 ++++++-- internal/platform/cloud/config_test.go | 140 +++++++++++++++++++++++++ internal/platform/cloud/delivery.go | 2 +- internal/platform/cloud/graph.go | 7 +- 7 files changed, 248 insertions(+), 19 deletions(-) create mode 100644 internal/platform/cloud/config_test.go diff --git a/cmd/cloud.go b/cmd/cloud.go index 9f5e943b..9217b4c1 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "fmt" "os" "runtime" @@ -132,11 +133,17 @@ func newCloudStatusCmd() *cobra.Command { return &cobra.Command{ Use: "status", Short: "Show Rho Cloud connection status", RunE: func(cmd *cobra.Command, _ []string) error { - client, cfg, err := cloud.LoadClient() - if err != nil || !client.Enabled() { - cmd.Println(auditTint("Rho Cloud is not connected.", textMuted)) + client, cfg, err := loadCloudClient() + if errors.Is(err, cloud.ErrNotConnected) { + cmd.Println(auditTint("GrayCode Cloud is not connected. Run `rho cloud login` to connect.", textMuted)) return nil } + if err != nil { + return err + } + if !client.Enabled() { + return cloud.ErrNotConnected + } cmd.Println(auditTint("Rho Cloud connected: ", doneGreen) + auditTint(cfg.Endpoint, textPrimary) + auditTint(fmt.Sprintf(" (device %s, project %s)", cfg.DeviceID, cfg.ProjectID), textMuted)) return nil }, @@ -149,8 +156,8 @@ func newCloudContextCmd() *cobra.Command { Short: "Sync repository context to Rho Cloud", RunE: func(cmd *cobra.Command, _ []string) error { client, cfg, err := loadCloudClient() - if err != nil || !client.Enabled() { - return fmt.Errorf("rho cloud is not connected") + if err != nil { + return err } detected, detectErr := detectGitContext(cmd.Context()) repository, _ := cmd.Flags().GetString("repository") diff --git a/cmd/cloud_graph.go b/cmd/cloud_graph.go index 8be34a96..8fbc64a5 100644 --- a/cmd/cloud_graph.go +++ b/cmd/cloud_graph.go @@ -51,9 +51,9 @@ execution never depends on cloud synchronization.`, if err != nil { return err } - client, cfg, err := cloud.LoadClient() - if err != nil || !client.Enabled() { - return fmt.Errorf("rho cloud is not connected") + client, cfg, err := loadCloudClient() + if err != nil { + return err } prepared, err := cloud.PrepareGraph(export) if err != nil { diff --git a/cmd/cloud_test.go b/cmd/cloud_test.go index a7d61307..0af9d27c 100644 --- a/cmd/cloud_test.go +++ b/cmd/cloud_test.go @@ -6,10 +6,13 @@ import ( "errors" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "sync/atomic" "testing" + "github.com/GrayCodeAI/rho/internal/executiongraph" cloud "github.com/GrayCodeAI/rho/internal/platform/cloud" "github.com/spf13/cobra" ) @@ -153,3 +156,49 @@ func TestCloudContextRejectsUnknownStatusFlags(t *testing.T) { t.Fatalf("server received %d requests for invalid flags", hits.Load()) } } + +func TestCloudStatusNotConnected(t *testing.T) { + useCloudClient(t, nil, cloud.DeviceConfig{}, cloud.ErrNotConnected) + out, err := runCloudCommand(t, newCloudStatusCmd()) + if err != nil || !strings.Contains(out, "not connected") { + t.Fatalf("out = %q, err = %v", out, err) + } +} + +func TestCloudStatusReportsBrokenConnection(t *testing.T) { + broken := errors.New("read GrayCode Cloud device token from the credential store: security: exit status 51") + useCloudClient(t, nil, cloud.DeviceConfig{}, broken) + out, err := runCloudCommand(t, newCloudStatusCmd()) + if !errors.Is(err, broken) { + t.Fatalf("error = %v, want the load failure", err) + } + if strings.Contains(out, "not connected") { + t.Fatalf("a broken connection was reported as not connected: %q", out) + } +} + +func TestCloudStatusConnected(t *testing.T) { + cfg := cloud.DeviceConfig{Endpoint: cloud.DefaultEndpoint, DeviceID: "device_0123456789", ProjectID: "project_0123456789"} + useCloudClient(t, cloud.New(cloud.Config{Endpoint: cfg.Endpoint, DeviceToken: "hwc_test"}), cfg, nil) + out, err := runCloudCommand(t, newCloudStatusCmd()) + if err != nil || !strings.Contains(out, cloud.DefaultEndpoint) || !strings.Contains(out, "project_0123456789") { + t.Fatalf("out = %q, err = %v", out, err) + } +} + +func TestExplicitCloudCommandsReportLoadErrors(t *testing.T) { + noGitRemote(t) + broken := errors.New("GrayCode Cloud configuration is incomplete") + useCloudClient(t, nil, cloud.DeviceConfig{}, broken) + if _, err := runCloudCommand(t, newCloudContextCmd(), "--repository", "GrayCodeAI/rho"); !errors.Is(err, broken) { + t.Fatalf("context error = %v, want the load failure", err) + } + missionDir := t.TempDir() + graph := `{"schema_version":"` + executiongraph.SchemaVersion + `","generated_at":"2026-09-27T12:00:00Z","scope":{},"nodes":[],"edges":[],"events":[]}` + if err := os.WriteFile(filepath.Join(missionDir, "mission-graph.json"), []byte(graph), 0o600); err != nil { + t.Fatal(err) + } + if _, err := runCloudCommand(t, newCloudGraphCmd(), "sync", "--mission-dir", missionDir); !errors.Is(err, broken) { + t.Fatalf("graph sync error = %v, want the load failure", err) + } +} diff --git a/internal/platform/cloud/config.go b/internal/platform/cloud/config.go index 49b3cf49..c63ae771 100644 --- a/internal/platform/cloud/config.go +++ b/internal/platform/cloud/config.go @@ -2,9 +2,11 @@ package cloud import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" + "strings" "github.com/GrayCodeAI/rho/internal/auth" "github.com/GrayCodeAI/rho/internal/storage" @@ -15,6 +17,20 @@ const ( tokenAccount = "device-token" ) +// ErrNotConnected means this device has no saved GrayCode Cloud connection. +// Any other LoadClient error means a connection was saved but cannot be used. +var ErrNotConnected = errors.New("GrayCode Cloud is not connected; run `rho cloud login`") + +// secretStore is the part of auth.SecureStorage the device token needs. +type secretStore interface { + Get(account string) (string, error) + Set(account, token string) error +} + +// newTokenStore returns the device-token store; tests replace it so they +// never touch the real OS keychain. +var newTokenStore = func() secretStore { return auth.NewSecureStorage(tokenService) } + type DeviceConfig struct { Endpoint string `json:"endpoint"` DeviceID string `json:"device_id"` @@ -38,7 +54,7 @@ func SaveDeviceConfig(cfg DeviceConfig, token string) error { return err } cfg.Endpoint = endpoint - if err := auth.NewSecureStorage(tokenService).Set(tokenAccount, token); err != nil { + if err := newTokenStore().Set(tokenAccount, token); err != nil { return err } b, err := json.Marshal(cfg) @@ -51,21 +67,35 @@ func SaveDeviceConfig(cfg DeviceConfig, token string) error { return os.WriteFile(configPath(), b, 0o600) } +// LoadClient returns a client for the saved connection. It returns +// ErrNotConnected when nothing was saved, and a descriptive error when the +// saved configuration or device token cannot be used, so callers can tell +// "never connected" apart from a broken connection. func LoadClient() (*Client, DeviceConfig, error) { cfg, err := LoadDeviceConfig() + if errors.Is(err, os.ErrNotExist) { + return nil, cfg, ErrNotConnected + } if err != nil { - return nil, cfg, err + return nil, cfg, fmt.Errorf("read GrayCode Cloud configuration %s: %w", configPath(), err) + } + if cfg.Endpoint == "" || cfg.DeviceID == "" || cfg.ProjectID == "" { + return nil, cfg, fmt.Errorf("GrayCode Cloud configuration %s is incomplete (endpoint, device ID and project ID are required); run `rho cloud login` again", configPath()) } // Refuse a saved plaintext endpoint before the token is even read, so a // tampered or pre-TLS-check cloud.json cannot route the token over HTTP. - if cfg.Endpoint != "" { - if _, err := NormalizeEndpoint(cfg.Endpoint); err != nil { - return nil, cfg, fmt.Errorf("saved GrayCode Cloud endpoint in %s is not allowed: %w; run `rho cloud login` again", configPath(), err) - } + if _, err := NormalizeEndpoint(cfg.Endpoint); err != nil { + return nil, cfg, fmt.Errorf("saved GrayCode Cloud endpoint in %s is not allowed: %w; run `rho cloud login` again", configPath(), err) + } + token, err := newTokenStore().Get(tokenAccount) + // Credential helpers can return a trailing newline (the Windows + // PowerShell reader does), which would make an invalid header value. + token = strings.TrimSpace(token) + if errors.Is(err, os.ErrNotExist) || (err == nil && token == "") { + return nil, cfg, errors.New("GrayCode Cloud device token is missing from the credential store; run `rho cloud login` again") } - token, err := auth.NewSecureStorage(tokenService).Get(tokenAccount) if err != nil { - return nil, cfg, err + return nil, cfg, fmt.Errorf("read GrayCode Cloud device token from the credential store: %w", err) } return New(Config{Endpoint: cfg.Endpoint, DeviceToken: token}), cfg, nil } diff --git a/internal/platform/cloud/config_test.go b/internal/platform/cloud/config_test.go new file mode 100644 index 00000000..cf64e2a8 --- /dev/null +++ b/internal/platform/cloud/config_test.go @@ -0,0 +1,140 @@ +package cloud + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// fakeTokenStore is an in-memory secretStore. +type fakeTokenStore struct { + tokens map[string]string + getErr error +} + +func (f *fakeTokenStore) Get(account string) (string, error) { + if f.getErr != nil { + return "", f.getErr + } + token, ok := f.tokens[account] + if !ok { + return "", os.ErrNotExist + } + return token, nil +} + +func (f *fakeTokenStore) Set(account, token string) error { + f.tokens[account] = token + return nil +} + +// useFakeTokenStore isolates the config dir and token store for one test. +func useFakeTokenStore(t *testing.T) (*fakeTokenStore, string) { + t.Helper() + dir := t.TempDir() + t.Setenv("RHO_CONFIG_DIR", dir) + store := &fakeTokenStore{tokens: map[string]string{}} + original := newTokenStore + t.Cleanup(func() { newTokenStore = original }) + newTokenStore = func() secretStore { return store } + return store, dir +} + +func writeCloudJSON(t *testing.T, dir, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, "cloud.json"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +const validCloudJSON = `{"endpoint":"https://cloud.graycodeai.com","device_id":"device_0123456789","project_id":"project_0123456789"}` + +func TestLoadClientNotConnected(t *testing.T) { + useFakeTokenStore(t) + if _, _, err := LoadClient(); !errors.Is(err, ErrNotConnected) { + t.Fatalf("error = %v, want ErrNotConnected", err) + } +} + +func TestLoadClientReportsBrokenConnections(t *testing.T) { + cases := map[string]struct { + cloudJSON string + token string + getErr error + want string + }{ + "corrupt config": {cloudJSON: `{`, want: "read GrayCode Cloud configuration"}, + "incomplete config": {cloudJSON: `{"endpoint":"https://cloud.graycodeai.com"}`, want: "incomplete"}, + "missing token": {cloudJSON: validCloudJSON, want: "device token is missing"}, + "blank token": {cloudJSON: validCloudJSON, token: " \n", want: "device token is missing"}, + "keychain failure": {cloudJSON: validCloudJSON, getErr: errors.New("security: exit status 51: user interaction is not allowed"), want: "user interaction is not allowed"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + store, dir := useFakeTokenStore(t) + writeCloudJSON(t, dir, tc.cloudJSON) + if tc.token != "" { + store.tokens[tokenAccount] = tc.token + } + store.getErr = tc.getErr + _, _, err := LoadClient() + if err == nil || errors.Is(err, ErrNotConnected) || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want a non-ErrNotConnected error containing %q", err, tc.want) + } + }) + } +} + +func TestSaveAndLoadClientRoundTripTrimsToken(t *testing.T) { + store, _ := useFakeTokenStore(t) + var gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + if err := SaveDeviceConfig(DeviceConfig{Endpoint: server.URL + "/", DeviceID: "device_0123456789", ProjectID: "project_0123456789"}, "hwc_saved"); err != nil { + t.Fatal(err) + } + // A credential helper that appends CRLF (the Windows reader does) must + // not produce an invalid Authorization header. + store.tokens[tokenAccount] = "hwc_saved\r\n" + client, cfg, err := LoadClient() + if err != nil { + t.Fatal(err) + } + if cfg.Endpoint != server.URL { + t.Fatalf("saved endpoint = %q, want normalized %q", cfg.Endpoint, server.URL) + } + if err := client.SendDeliveryContext(context.Background(), validDeliveryContext()); err != nil { + t.Fatal(err) + } + if gotAuth != "Bearer hwc_saved" { + t.Fatalf("Authorization = %q", gotAuth) + } +} + +func TestSavedConfigFileIsPrivate(t *testing.T) { + _, dir := useFakeTokenStore(t) + if err := SaveDeviceConfig(DeviceConfig{Endpoint: DefaultEndpoint, DeviceID: "device_0123456789", ProjectID: "project_0123456789"}, "hwc_saved"); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(dir, "cloud.json")) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("cloud.json mode = %o, want 600", perm) + } + raw, _ := os.ReadFile(filepath.Join(dir, "cloud.json")) + var saved map[string]string + if err := json.Unmarshal(raw, &saved); err != nil || strings.Contains(string(raw), "hwc_saved") { + t.Fatalf("cloud.json = %s (err %v); the token must live only in the credential store", raw, err) + } +} diff --git a/internal/platform/cloud/delivery.go b/internal/platform/cloud/delivery.go index 8b187cca..8c257d78 100644 --- a/internal/platform/cloud/delivery.go +++ b/internal/platform/cloud/delivery.go @@ -23,7 +23,7 @@ func (c *Client) SendDeliveryContext(ctx context.Context, event DeliveryContext) return err } if c.token == "" { - return fmt.Errorf("GrayCode Cloud is not connected; run `rho cloud login`") + return ErrNotConnected } event = event.normalized() if err := event.Validate(); err != nil { diff --git a/internal/platform/cloud/graph.go b/internal/platform/cloud/graph.go index bd769ea6..b3d429c5 100644 --- a/internal/platform/cloud/graph.go +++ b/internal/platform/cloud/graph.go @@ -150,8 +150,11 @@ func sha256Hex(value []byte) string { // command, so errors are returned rather than discarded. func (c *Client) SyncGraph(ctx context.Context, request GraphSyncRequest) (GraphSyncResult, error) { var result GraphSyncResult - if !c.Enabled() { - return result, fmt.Errorf("rho cloud is not connected") + if err := c.checkEndpoint(); err != nil { + return result, err + } + if c.token == "" { + return result, ErrNotConnected } body, err := json.Marshal(request) if err != nil { From f45db7b0fd63f3f5ba91149f553244fd33d3e157 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:47:31 +0530 Subject: [PATCH 08/16] 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. Co-Authored-By: Claude Opus 5.5 --- cmd/exec.go | 30 +++++---- internal/platform/cloud/client.go | 19 ------ internal/platform/cloud/client_test.go | 9 ++- internal/platform/cloud/endpoint_test.go | 4 +- internal/platform/cloud/usage.go | 83 ++++++++++++++++++++++++ internal/platform/cloud/usage_test.go | 69 ++++++++++++++++++++ 6 files changed, 178 insertions(+), 36 deletions(-) create mode 100644 internal/platform/cloud/usage.go create mode 100644 internal/platform/cloud/usage_test.go diff --git a/cmd/exec.go b/cmd/exec.go index 85455875..01be2b6c 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -372,20 +372,22 @@ func runExec(_ *cobra.Command, args []string) error { // Optional cloud accounting is best-effort and never affects local execution. if client, cfg, loadErr := cloud.LoadClient(); loadErr == nil && client.Enabled() { - go client.RecordUsage(context.Background(), cloud.UsageEvent{ - EventID: fmt.Sprintf("exec-%d-%s", start.UnixMilli(), randomHex(8)), - DeviceID: cfg.DeviceID, - ProjectID: cfg.ProjectID, - SessionID: sessionID, - Capability: "rho", - Model: effectiveModel, - InputTokens: totalIn, - OutputTokens: totalOut, - TokensUsed: totalIn + totalOut, - DurationMS: int(time.Since(start).Milliseconds()), - Status: map[bool]string{true: "failed", false: "completed"}[exitCode != 0], - OccurredAt: time.Now().UTC().Format(time.RFC3339), - }) + go func() { + _ = client.RecordUsage(context.Background(), cloud.UsageEvent{ + EventID: fmt.Sprintf("exec-%d-%s", start.UnixMilli(), randomHex(8)), + DeviceID: cfg.DeviceID, + ProjectID: cfg.ProjectID, + SessionID: sessionID, + Capability: cloud.CapabilityRho, + Model: effectiveModel, + InputTokens: totalIn, + OutputTokens: totalOut, + TokensUsed: totalIn + totalOut, + DurationMS: int(time.Since(start).Milliseconds()), + Status: map[bool]string{true: "failed", false: "completed"}[exitCode != 0], + OccurredAt: time.Now().UTC().Format(time.RFC3339), + }) + }() } // Persist session for resume/search (skip in ephemeral/CI mode) diff --git a/internal/platform/cloud/client.go b/internal/platform/cloud/client.go index 80723e2d..192bf11f 100644 --- a/internal/platform/cloud/client.go +++ b/internal/platform/cloud/client.go @@ -331,22 +331,3 @@ func (c *Client) PollDeviceLogin(ctx context.Context, deviceCode string) (Device } return c.pollDeviceLogin(ctx, deviceCode) } - -// RecordUsage intentionally discards transport failures. Cloud sync must not alter local execution. -func (c *Client) RecordUsage(ctx context.Context, event UsageEvent) { - if !c.Enabled() { - return - } - body, err := json.Marshal(event) - if err != nil { - return - } - req, err := c.newJSONRequest(ctx, "/v1/usage", body, true) - if err != nil { - return - } - resp, err := c.http.Do(req) - if err == nil && resp != nil { - _ = resp.Body.Close() - } -} diff --git a/internal/platform/cloud/client_test.go b/internal/platform/cloud/client_test.go index 12e7323c..fc86f0fe 100644 --- a/internal/platform/cloud/client_test.go +++ b/internal/platform/cloud/client_test.go @@ -3,6 +3,7 @@ package cloud import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -19,14 +20,18 @@ func TestRecordUsageSendsAggregateEvent(t *testing.T) { })) defer s.Close() c := New(Config{Endpoint: s.URL, DeviceToken: "hwc_test"}) - c.RecordUsage(context.Background(), UsageEvent{EventID: "event_0123456789", DeviceID: "device_0123456789", ProjectID: "project_0123456789", Capability: "rho", OccurredAt: "2026-07-10T00:00:00Z"}) + if err := c.RecordUsage(context.Background(), UsageEvent{EventID: "event_0123456789", DeviceID: "device_0123456789", ProjectID: "project_0123456789", Capability: "rho", OccurredAt: "2026-07-10T00:00:00Z"}); err != nil { + t.Fatal(err) + } if gotAuth != "Bearer hwc_test" { t.Fatalf("authorization = %q", gotAuth) } } func TestDisabledClientDoesNotSend(t *testing.T) { - New(Config{}).RecordUsage(context.Background(), UsageEvent{}) + if err := New(Config{}).RecordUsage(context.Background(), UsageEvent{}); !errors.Is(err, ErrNotConnected) { + t.Fatalf("error = %v, want ErrNotConnected", err) + } } func TestSendDeliveryContextUsesDeviceScopedEndpoint(t *testing.T) { diff --git a/internal/platform/cloud/endpoint_test.go b/internal/platform/cloud/endpoint_test.go index 33304094..1b6c30da 100644 --- a/internal/platform/cloud/endpoint_test.go +++ b/internal/platform/cloud/endpoint_test.go @@ -94,7 +94,9 @@ func TestInsecureEndpointNeverSendsTheDeviceToken(t *testing.T) { t.Fatal("client with a plaintext remote endpoint must not be enabled") } ctx := context.Background() - client.RecordUsage(ctx, UsageEvent{EventID: "event_0123456789", Capability: "rho"}) + if err := client.RecordUsage(ctx, UsageEvent{EventID: "event_0123456789", Capability: "rho"}); err == nil { + t.Fatal("RecordUsage succeeded against a plaintext endpoint") + } if _, err := client.SyncGraph(ctx, GraphSyncRequest{SyncID: "graph_0123456789abcdef", Graph: json.RawMessage(`{}`)}); err == nil { t.Fatal("SyncGraph succeeded against a plaintext endpoint") } diff --git a/internal/platform/cloud/usage.go b/internal/platform/cloud/usage.go new file mode 100644 index 00000000..8a4332a1 --- /dev/null +++ b/internal/platform/cloud/usage.go @@ -0,0 +1,83 @@ +package cloud + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "time" +) + +// CapabilityRho is the canonical usage capability rho reports. GrayCode Cloud +// also accepts "graycode", but only as a legacy alias for pre-rename clients. +const CapabilityRho = "rho" + +// Bounds of the POST /v1/usage schema. +const ( + // MaxUsageDurationMS is the durationMs ceiling (24 hours). Longer runs are + // clamped so they are still recorded instead of rejected. + MaxUsageDurationMS = 86_400_000 + maxUsageTokens = 10_000_000 + maxUsageCostMicros int64 = 10_000_000_000 + maxUsageText = 100 +) + +var usageStatuses = []string{"completed", "failed", "cancelled"} + +// RecordUsage uploads one usage event after clamping it into the /v1/usage +// schema. It serves the automatic, fail-open path (`rho exec`): callers must +// never let the returned error change local execution. The error exists so a +// caller can surface a server rejection (*APIError) rather than lose it. +func (c *Client) RecordUsage(ctx context.Context, event UsageEvent) error { + if !c.Enabled() { + return ErrNotConnected + } + body, err := json.Marshal(event.normalized(time.Now())) + if err != nil { + return fmt.Errorf("marshal usage event: %w", err) + } + req, err := c.newJSONRequest(ctx, "/v1/usage", body, true) + if err != nil { + return err + } + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("upload usage to GrayCode Cloud: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return readAPIError("usage upload", resp) + } + return nil +} + +// normalized clamps e into the /v1/usage schema: the capability defaults to +// rho, counters are clamped to their bounds, free text is cut to 100 UTF-16 +// code units, an unknown status is dropped, and a missing timestamp is set. +func (e UsageEvent) normalized(now time.Time) UsageEvent { + if e.Capability == "" { + e.Capability = CapabilityRho + } + e.DurationMS = clamp(e.DurationMS, 0, MaxUsageDurationMS) + for _, counter := range []*int{ + &e.InputTokens, &e.OutputTokens, &e.CachedInputTokens, + &e.ReasoningTokens, &e.TokensUsed, &e.TokensSaved, + } { + *counter = clamp(*counter, 0, maxUsageTokens) + } + e.EstimatedCostMicros = int(min(max(int64(e.EstimatedCostMicros), 0), maxUsageCostMicros)) + e.Provider = boundedField(e.Provider, maxUsageText, "") + e.Model = boundedField(e.Model, maxUsageText, "") + e.ErrorCode = boundedField(e.ErrorCode, maxUsageText, "") + if !slices.Contains(usageStatuses, e.Status) { + e.Status = "" + } + if e.OccurredAt == "" { + e.OccurredAt = now.UTC().Format(time.RFC3339) + } + return e +} + +func clamp(value, lo, hi int) int { + return min(max(value, lo), hi) +} diff --git a/internal/platform/cloud/usage_test.go b/internal/platform/cloud/usage_test.go new file mode 100644 index 00000000..4e08c4bf --- /dev/null +++ b/internal/platform/cloud/usage_test.go @@ -0,0 +1,69 @@ +package cloud + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestUsageEventNormalizedClampsToContract(t *testing.T) { + now := time.Date(2026, time.September, 27, 12, 0, 0, 0, time.UTC) + got := UsageEvent{ + DurationMS: MaxUsageDurationMS + 1, + InputTokens: maxUsageTokens * 2, + OutputTokens: -5, + TokensUsed: maxUsageTokens + 1, + EstimatedCostMicros: -1, + Model: strings.Repeat("m", 150), + Status: "timeout", + }.normalized(now) + if got.Capability != CapabilityRho { + t.Fatalf("capability = %q, want %q", got.Capability, CapabilityRho) + } + if got.DurationMS != MaxUsageDurationMS { + t.Fatalf("durationMs = %d, want %d", got.DurationMS, MaxUsageDurationMS) + } + if got.InputTokens != maxUsageTokens || got.OutputTokens != 0 || got.TokensUsed != maxUsageTokens { + t.Fatalf("token counters = %+v", got) + } + if got.EstimatedCostMicros != 0 || len(got.Model) != maxUsageText || got.Status != "" { + t.Fatalf("normalized = %+v", got) + } + if got.OccurredAt != "2026-09-27T12:00:00Z" { + t.Fatalf("occurredAt = %q", got.OccurredAt) + } + + kept := UsageEvent{Capability: "rho", DurationMS: 1500, Status: "failed", OccurredAt: "2026-01-01T00:00:00Z"}.normalized(now) + if kept.DurationMS != 1500 || kept.Status != "failed" || kept.OccurredAt != "2026-01-01T00:00:00Z" { + t.Fatalf("in-range event changed: %+v", kept) + } +} + +func TestRecordUsageSendsClampedDurationAndReportsRejection(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"Invalid usage event"}`)) + })) + defer server.Close() + err := New(Config{Endpoint: server.URL, DeviceToken: "hwc_test"}).RecordUsage(context.Background(), UsageEvent{ + EventID: "exec-1727400000000-0123456789abcdef", DeviceID: "device_0123456789", ProjectID: "project_0123456789", + DurationMS: 25 * 60 * 60 * 1000, TokensUsed: 1, + }) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.Message != "Invalid usage event" { + t.Fatalf("error = %v, want the Worker's rejection", err) + } + if body["durationMs"] != float64(MaxUsageDurationMS) || body["capability"] != CapabilityRho { + t.Fatalf("body = %v", body) + } +} From 280244f5ae08549249dab09e462b02d0e740684c Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:49:05 +0530 Subject: [PATCH 09/16] 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. Co-Authored-By: Claude Opus 5.5 --- cmd/cloud_usage.go | 40 +++++++++++++++++ cmd/cloud_usage_test.go | 98 +++++++++++++++++++++++++++++++++++++++++ cmd/exec.go | 39 ++++++++-------- 3 files changed, 158 insertions(+), 19 deletions(-) create mode 100644 cmd/cloud_usage.go create mode 100644 cmd/cloud_usage_test.go diff --git a/cmd/cloud_usage.go b/cmd/cloud_usage.go new file mode 100644 index 00000000..2b56f86f --- /dev/null +++ b/cmd/cloud_usage.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "time" + + cloud "github.com/GrayCodeAI/rho/internal/platform/cloud" +) + +// cloudUsageWait bounds how long a command waits, after writing its output, +// for the fail-open GrayCode Cloud usage upload to finish. Tests shorten it. +var cloudUsageWait = 3 * time.Second + +// startCloudUsage uploads one usage event in the background when GrayCode +// Cloud is connected and returns a function that waits for the upload, +// bounded by cloudUsageWait. Callers defer the returned function so the +// process does not exit (and kill the upload) before the request completes. +// Upload failures never change the command's result; a server rejection is +// reported on stderr, while transport errors (for example offline) stay +// silent. +func startCloudUsage(stderr io.Writer, build func(cloud.DeviceConfig) cloud.UsageEvent) (wait func()) { + client, cfg, err := loadCloudClient() + if err != nil || !client.Enabled() { + return func() {} + } + event := build(cfg) + ctx, cancel := context.WithTimeout(context.Background(), cloudUsageWait) + done := make(chan error, 1) + go func() { done <- client.RecordUsage(ctx, event) }() + return func() { + defer cancel() + var apiErr *cloud.APIError + if err := <-done; errors.As(err, &apiErr) && !IsQuiet() { + _, _ = fmt.Fprintln(stderr, auditTint("warning: "+apiErr.Error(), textMuted)) + } + } +} diff --git a/cmd/cloud_usage_test.go b/cmd/cloud_usage_test.go new file mode 100644 index 00000000..56545fd9 --- /dev/null +++ b/cmd/cloud_usage_test.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "bytes" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + cloud "github.com/GrayCodeAI/rho/internal/platform/cloud" +) + +var testUsageConfig = cloud.DeviceConfig{Endpoint: "https://cloud.graycodeai.com", DeviceID: "device_0123456789", ProjectID: "project_0123456789"} + +func buildTestUsage(cfg cloud.DeviceConfig) cloud.UsageEvent { + return cloud.UsageEvent{EventID: "exec-1727400000000-0123456789abcdef", DeviceID: cfg.DeviceID, ProjectID: cfg.ProjectID, TokensUsed: 1} +} + +func TestStartCloudUsageWaitsForDelivery(t *testing.T) { + var received atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(150 * time.Millisecond) // slower than returning from the command + received.Store(true) + w.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + useCloudClient(t, cloud.New(cloud.Config{Endpoint: server.URL, DeviceToken: "hwc_test"}), testUsageConfig, nil) + + var stderr bytes.Buffer + wait := startCloudUsage(&stderr, buildTestUsage) + wait() + if !received.Load() { + t.Fatal("wait returned before the usage event reached GrayCode Cloud") + } + if stderr.Len() != 0 { + t.Fatalf("unexpected output: %q", stderr.String()) + } +} + +func TestStartCloudUsageWaitIsBounded(t *testing.T) { + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-release: + case <-r.Context().Done(): + } + w.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + defer close(release) + useCloudClient(t, cloud.New(cloud.Config{Endpoint: server.URL, DeviceToken: "hwc_test"}), testUsageConfig, nil) + original := cloudUsageWait + t.Cleanup(func() { cloudUsageWait = original }) + cloudUsageWait = 100 * time.Millisecond + + var stderr bytes.Buffer + began := time.Now() + startCloudUsage(&stderr, buildTestUsage)() + if elapsed := time.Since(began); elapsed > 2*time.Second { + t.Fatalf("wait took %v; a hung endpoint must not hold the process open", elapsed) + } + if stderr.Len() != 0 { + t.Fatalf("a transport timeout should stay silent, got %q", stderr.String()) + } +} + +func TestStartCloudUsageReportsRejection(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"Invalid usage event"}`)) + })) + defer server.Close() + useCloudClient(t, cloud.New(cloud.Config{Endpoint: server.URL, DeviceToken: "hwc_test"}), testUsageConfig, nil) + + var stderr bytes.Buffer + startCloudUsage(&stderr, buildTestUsage)() + if !strings.Contains(stderr.String(), "Invalid usage event") { + t.Fatalf("stderr = %q, want the rejection reported", stderr.String()) + } +} + +func TestStartCloudUsageSkipsWhenNotConnected(t *testing.T) { + useCloudClient(t, nil, cloud.DeviceConfig{}, cloud.ErrNotConnected) + built := false + startCloudUsage(&bytes.Buffer{}, func(cloud.DeviceConfig) cloud.UsageEvent { built = true; return cloud.UsageEvent{} })() + if built { + t.Fatal("usage event built without a connection") + } + useCloudClient(t, nil, cloud.DeviceConfig{}, errors.New("broken keychain")) + startCloudUsage(&bytes.Buffer{}, func(cloud.DeviceConfig) cloud.UsageEvent { built = true; return cloud.UsageEvent{} })() + if built { + t.Fatal("usage event built for a broken connection") + } +} diff --git a/cmd/exec.go b/cmd/exec.go index 01be2b6c..6aa32533 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -370,25 +370,26 @@ func runExec(_ *cobra.Command, args []string) error { } sessionID := fmt.Sprintf("exec-%d-%s", start.UnixMilli(), randomHex(4)) - // Optional cloud accounting is best-effort and never affects local execution. - if client, cfg, loadErr := cloud.LoadClient(); loadErr == nil && client.Enabled() { - go func() { - _ = client.RecordUsage(context.Background(), cloud.UsageEvent{ - EventID: fmt.Sprintf("exec-%d-%s", start.UnixMilli(), randomHex(8)), - DeviceID: cfg.DeviceID, - ProjectID: cfg.ProjectID, - SessionID: sessionID, - Capability: cloud.CapabilityRho, - Model: effectiveModel, - InputTokens: totalIn, - OutputTokens: totalOut, - TokensUsed: totalIn + totalOut, - DurationMS: int(time.Since(start).Milliseconds()), - Status: map[bool]string{true: "failed", false: "completed"}[exitCode != 0], - OccurredAt: time.Now().UTC().Format(time.RFC3339), - }) - }() - } + // Optional GrayCode Cloud accounting is fail-open: it never changes the + // result, and the deferred wait is bounded by cloudUsageWait so a slow or + // unreachable endpoint cannot hold the process open for long. + waitCloudUsage := startCloudUsage(os.Stderr, func(cfg cloud.DeviceConfig) cloud.UsageEvent { + return cloud.UsageEvent{ + EventID: fmt.Sprintf("exec-%d-%s", start.UnixMilli(), randomHex(8)), + DeviceID: cfg.DeviceID, + ProjectID: cfg.ProjectID, + SessionID: sessionID, + Capability: cloud.CapabilityRho, + Model: effectiveModel, + InputTokens: totalIn, + OutputTokens: totalOut, + TokensUsed: totalIn + totalOut, + DurationMS: int(time.Since(start).Milliseconds()), + Status: map[bool]string{true: "failed", false: "completed"}[exitCode != 0], + OccurredAt: time.Now().UTC().Format(time.RFC3339), + } + }) + defer waitCloudUsage() // Persist session for resume/search (skip in ephemeral/CI mode) if !execEphemeral { From 2200e7b4a565f9edcc718cadbdd8a28528d9fc52 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:51:26 +0530 Subject: [PATCH 10/16] fix(cloud): read the device token from stdin or a hidden prompt `rho cloud connect --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 --- cmd/cloud.go | 78 +++++++++++++++++++++++++++++++--- cmd/cloud_test.go | 106 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 5 deletions(-) diff --git a/cmd/cloud.go b/cmd/cloud.go index 9217b4c1..ad43870a 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -4,14 +4,17 @@ import ( "context" "errors" "fmt" + "io" "os" "runtime" "slices" "strings" "time" + "unicode" cloud "github.com/GrayCodeAI/rho/internal/platform/cloud" "github.com/spf13/cobra" + "golang.org/x/term" ) // cloudEndpointFlagHelp documents the endpoint precedence shared by the @@ -27,6 +30,11 @@ func newCloudConnectCmd() *cobra.Command { command := &cobra.Command{ Use: "connect", Short: "Connect this Rho device to Rho Cloud", + Long: `Save an existing GrayCode Cloud device connection. Prefer "rho cloud login", +which needs no token. The device token is read with --token-stdin or from a +hidden prompt, never from the command line: + + rho cloud connect --device-id --project-id --token-stdin < token.txt`, RunE: func(cmd *cobra.Command, _ []string) error { endpointFlag, _ := cmd.Flags().GetString("endpoint") endpoint, err := cloud.ResolveEndpoint(endpointFlag) @@ -35,11 +43,17 @@ func newCloudConnectCmd() *cobra.Command { } deviceID, _ := cmd.Flags().GetString("device-id") projectID, _ := cmd.Flags().GetString("project-id") - token, _ := cmd.Flags().GetString("token") - if deviceID == "" || projectID == "" || token == "" { - return fmt.Errorf("device-id, project-id, and token are required") + if deviceID == "" || projectID == "" { + return fmt.Errorf("device-id and project-id are required") + } + if !cloud.ValidOpaqueID(deviceID) || !cloud.ValidOpaqueID(projectID) { + return fmt.Errorf("device-id and project-id must be GrayCode Cloud identifiers (16-128 letters, digits, '.', '_', ':' or '-')") + } + token, err := readDeviceToken(cmd) + if err != nil { + return err } - if err := cloud.SaveDeviceConfig(cloud.DeviceConfig{Endpoint: endpoint, DeviceID: deviceID, ProjectID: projectID}, token); err != nil { + if err := saveCloudDevice(cloud.DeviceConfig{Endpoint: endpoint, DeviceID: deviceID, ProjectID: projectID}, token); err != nil { return err } cmd.Println(auditTint("Rho Cloud connected. Usage synchronization is opt-in and fail-open.", doneGreen)) @@ -49,10 +63,64 @@ func newCloudConnectCmd() *cobra.Command { command.Flags().String("endpoint", "", cloudEndpointFlagHelp) command.Flags().String("device-id", "", "Rho Cloud device ID") command.Flags().String("project-id", "", "Rho Cloud project ID") - command.Flags().String("token", "", "Rho Cloud device token") + command.Flags().Bool("token-stdin", false, "Read the device token from standard input") + command.Flags().String("token", "", "Device token (deprecated: exposed in shell history and process listings)") + _ = command.Flags().MarkDeprecated("token", "it exposes the device token in shell history and process listings; use --token-stdin or the interactive prompt") return command } +// maxDeviceTokenInput bounds how much is read for a device token. +const maxDeviceTokenInput = 4 << 10 + +// Token-input seams; tests replace them. +var ( + saveCloudDevice = cloud.SaveDeviceConfig + readHiddenSecret = func() ([]byte, error) { return term.ReadPassword(int(os.Stdin.Fd())) } +) + +// readDeviceToken returns the device token for `rho cloud connect` from, in +// order: --token-stdin, the deprecated --token flag, or a hidden prompt when +// stdin is a terminal. The token never has to appear in argv, where it would +// land in shell history and be visible to other users via ps. +func readDeviceToken(cmd *cobra.Command) (string, error) { + flagToken, _ := cmd.Flags().GetString("token") + fromStdin, _ := cmd.Flags().GetBool("token-stdin") + var raw string + switch { + case fromStdin && flagToken != "": + return "", errors.New("use either --token-stdin or --token, not both") + case fromStdin: + data, err := io.ReadAll(io.LimitReader(cmd.InOrStdin(), maxDeviceTokenInput+1)) + if err != nil { + return "", fmt.Errorf("read device token from stdin: %w", err) + } + if len(data) > maxDeviceTokenInput { + return "", errors.New("device token on stdin is too long") + } + raw = string(data) + case flagToken != "": + raw = flagToken + case stdinIsTerminal(): + _, _ = fmt.Fprint(cmd.ErrOrStderr(), "GrayCode Cloud device token: ") + data, err := readHiddenSecret() + _, _ = fmt.Fprintln(cmd.ErrOrStderr()) + if err != nil { + return "", fmt.Errorf("read device token: %w", err) + } + raw = string(data) + default: + return "", errors.New("a device token is required: pipe it with --token-stdin, or run in a terminal to be prompted (`rho cloud login` needs no token)") + } + token := strings.TrimSpace(raw) + if token == "" { + return "", errors.New("the device token is empty") + } + if strings.ContainsFunc(token, func(r rune) bool { return !unicode.IsGraphic(r) || unicode.IsSpace(r) }) { + return "", errors.New("the device token must be a single line without spaces") + } + return token, nil +} + func newCloudLoginCmd() *cobra.Command { command := &cobra.Command{ Use: "login", diff --git a/cmd/cloud_test.go b/cmd/cloud_test.go index 0af9d27c..eefcfbce 100644 --- a/cmd/cloud_test.go +++ b/cmd/cloud_test.go @@ -202,3 +202,109 @@ func TestExplicitCloudCommandsReportLoadErrors(t *testing.T) { t.Fatalf("graph sync error = %v, want the load failure", err) } } + +// captureCloudSave records what connect would save instead of touching the +// real credential store. +func captureCloudSave(t *testing.T) *struct { + cfg cloud.DeviceConfig + token string + calls int +} { + t.Helper() + saved := &struct { + cfg cloud.DeviceConfig + token string + calls int + }{} + original := saveCloudDevice + t.Cleanup(func() { saveCloudDevice = original }) + saveCloudDevice = func(cfg cloud.DeviceConfig, token string) error { + saved.cfg, saved.token = cfg, token + saved.calls++ + return nil + } + return saved +} + +// promptable sets whether stdin looks like a terminal and what a hidden +// prompt returns. +func promptable(t *testing.T, terminal bool, answer string) { + t.Helper() + originalTTY, originalRead := stdinIsTerminal, readHiddenSecret + t.Cleanup(func() { stdinIsTerminal, readHiddenSecret = originalTTY, originalRead }) + stdinIsTerminal = func() bool { return terminal } + readHiddenSecret = func() ([]byte, error) { return []byte(answer), nil } +} + +var connectIDs = []string{"--device-id", "device_0123456789", "--project-id", "project_0123456789"} + +func TestCloudConnectReadsTokenFromStdin(t *testing.T) { + t.Setenv(cloud.EndpointEnv, "") + saved := captureCloudSave(t) + promptable(t, false, "") + command := newCloudConnectCmd() + command.SetIn(strings.NewReader("hwc_from_stdin\n")) + if _, err := runCloudCommand(t, command, append(connectIDs, "--token-stdin")...); err != nil { + t.Fatal(err) + } + if saved.token != "hwc_from_stdin" || saved.cfg.Endpoint != cloud.DefaultEndpoint || saved.cfg.ProjectID != "project_0123456789" { + t.Fatalf("saved = %+v", saved) + } +} + +func TestCloudConnectPromptsWithoutEchoOnTerminal(t *testing.T) { + saved := captureCloudSave(t) + promptable(t, true, " hwc_prompted \n") + out, err := runCloudCommand(t, newCloudConnectCmd(), connectIDs...) + if err != nil { + t.Fatal(err) + } + if saved.token != "hwc_prompted" || !strings.Contains(out, "device token:") { + t.Fatalf("saved token %q, output %q", saved.token, out) + } +} + +func TestCloudConnectTokenFlagIsDeprecated(t *testing.T) { + saved := captureCloudSave(t) + promptable(t, false, "") + command := newCloudConnectCmd() + if flag := command.Flags().Lookup("token"); flag == nil || flag.Deprecated == "" || !flag.Hidden { + t.Fatalf("--token flag = %+v, want deprecated and hidden", flag) + } + out, err := runCloudCommand(t, command, append(connectIDs, "--token", "hwc_argv")...) + if err != nil { + t.Fatal(err) + } + if saved.token != "hwc_argv" || !strings.Contains(out, "deprecated") || !strings.Contains(out, "--token-stdin") { + t.Fatalf("saved %q, output %q; --token must keep working with a warning", saved.token, out) + } +} + +func TestCloudConnectRejectsMissingOrInvalidTokenInput(t *testing.T) { + saved := captureCloudSave(t) + promptable(t, false, "") + if _, err := runCloudCommand(t, newCloudConnectCmd(), connectIDs...); err == nil || !strings.Contains(err.Error(), "--token-stdin") { + t.Fatalf("non-interactive error = %v, want a --token-stdin hint", err) + } + both := newCloudConnectCmd() + both.SetIn(strings.NewReader("hwc_a")) + if _, err := runCloudCommand(t, both, append(connectIDs, "--token-stdin", "--token", "hwc_b")...); err == nil { + t.Fatal("--token-stdin with --token was accepted") + } + spaced := newCloudConnectCmd() + spaced.SetIn(strings.NewReader("hwc_a hwc_b\n")) + if _, err := runCloudCommand(t, spaced, append(connectIDs, "--token-stdin")...); err == nil { + t.Fatal("a token containing spaces was accepted") + } + huge := newCloudConnectCmd() + huge.SetIn(strings.NewReader(strings.Repeat("a", maxDeviceTokenInput+1))) + if _, err := runCloudCommand(t, huge, append(connectIDs, "--token-stdin")...); err == nil { + t.Fatal("an oversized token was accepted") + } + if _, err := runCloudCommand(t, newCloudConnectCmd(), "--device-id", "short", "--project-id", "project_0123456789", "--token-stdin"); err == nil || !strings.Contains(err.Error(), "identifiers") { + t.Fatalf("invalid device-id error = %v", err) + } + if saved.calls != 0 { + t.Fatalf("connect saved %d times for invalid input", saved.calls) + } +} From d1a25e9ff268ebc4c5889f2c9c428969ffe2c298 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:54:00 +0530 Subject: [PATCH 11/16] 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. Co-Authored-By: Claude Opus 5.5 --- cmd/cloud.go | 29 ++++++++++++++++++--- cmd/cloud_test.go | 36 +++++++++++++++++++++++++++ internal/auth/auth.go | 36 ++++++++++++++++++++++++--- internal/auth/plaintext_store_test.go | 36 +++++++++++++++++++++++++++ internal/platform/cloud/config.go | 7 ++++++ 5 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 internal/auth/plaintext_store_test.go diff --git a/cmd/cloud.go b/cmd/cloud.go index ad43870a..0870203d 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -56,6 +56,7 @@ hidden prompt, never from the command line: if err := saveCloudDevice(cloud.DeviceConfig{Endpoint: endpoint, DeviceID: deviceID, ProjectID: projectID}, token); err != nil { return err } + warnIfPlaintextTokenStore(cmd) cmd.Println(auditTint("Rho Cloud connected. Usage synchronization is opt-in and fail-open.", doneGreen)) return nil }, @@ -72,12 +73,25 @@ hidden prompt, never from the command line: // maxDeviceTokenInput bounds how much is read for a device token. const maxDeviceTokenInput = 4 << 10 -// Token-input seams; tests replace them. +// Credential seams; tests replace them. var ( - saveCloudDevice = cloud.SaveDeviceConfig - readHiddenSecret = func() ([]byte, error) { return term.ReadPassword(int(os.Stdin.Fd())) } + saveCloudDevice = cloud.SaveDeviceConfig + cloudTokenStorage = cloud.TokenStorage + readHiddenSecret = func() ([]byte, error) { return term.ReadPassword(int(os.Stdin.Fd())) } ) +// warnIfPlaintextTokenStore tells the user, every time a device token is +// saved, when this platform has no OS credential store and the token went to +// the 0600 plaintext fallback file instead. +func warnIfPlaintextTokenStore(cmd *cobra.Command) { + where, plaintext := cloudTokenStorage() + if !plaintext { + return + } + cmd.PrintErrln(auditTint("Warning: rho has no OS credential store integration on this platform, so the GrayCode Cloud device token was saved in the "+where+ + ". Any process running as your user, and any backup of that file, can read it. Keep the file private and revoke the device in GrayCode Cloud if this machine is shared or lost.", warnAmber)) +} + // readDeviceToken returns the device token for `rho cloud connect` from, in // order: --token-stdin, the deprecated --token flag, or a hidden prompt when // stdin is a terminal. The token never has to appear in argv, where it would @@ -181,12 +195,13 @@ http:// on localhost, 127.0.0.1 and [::1].`, } // PollDeviceLogin reports expired, consumed, unknown and // incomplete states as errors, so this is a complete approval. - if err := cloud.SaveDeviceConfig(cloud.DeviceConfig{Endpoint: endpoint, DeviceID: poll.DeviceID, ProjectID: poll.ProjectID}, poll.Token); err != nil { + if err := saveCloudDevice(cloud.DeviceConfig{Endpoint: endpoint, DeviceID: poll.DeviceID, ProjectID: poll.ProjectID}, poll.Token); err != nil { prog.FailStep(0, err.Error()) return err } prog.CompleteStep(0) prog.Done() + warnIfPlaintextTokenStore(cmd) cmd.Println(auditTint("Rho Cloud connected for project ", doneGreen) + auditTint(poll.ProjectID, textPrimary) + auditTint(".", doneGreen)) return nil } @@ -212,7 +227,13 @@ func newCloudStatusCmd() *cobra.Command { if !client.Enabled() { return cloud.ErrNotConnected } + where, plaintext := cloudTokenStorage() + storageLine := auditTint("Device token: "+where, textMuted) + if plaintext { + storageLine = auditTint("Device token: "+where+" (not an OS credential store)", warnAmber) + } cmd.Println(auditTint("Rho Cloud connected: ", doneGreen) + auditTint(cfg.Endpoint, textPrimary) + auditTint(fmt.Sprintf(" (device %s, project %s)", cfg.DeviceID, cfg.ProjectID), textMuted)) + cmd.Println(storageLine) return nil }, } diff --git a/cmd/cloud_test.go b/cmd/cloud_test.go index eefcfbce..cdc503a4 100644 --- a/cmd/cloud_test.go +++ b/cmd/cloud_test.go @@ -308,3 +308,39 @@ func TestCloudConnectRejectsMissingOrInvalidTokenInput(t *testing.T) { t.Fatalf("connect saved %d times for invalid input", saved.calls) } } + +func useTokenStorage(t *testing.T, where string, plaintext bool) { + t.Helper() + original := cloudTokenStorage + t.Cleanup(func() { cloudTokenStorage = original }) + cloudTokenStorage = func() (string, bool) { return where, plaintext } +} + +func TestCloudConnectWarnsAboutPlaintextTokenStore(t *testing.T) { + captureCloudSave(t) + promptable(t, true, "hwc_prompted") + useTokenStorage(t, "plaintext file /home/dev/.config/rho/.tokens (mode 0600)", true) + out, err := runCloudCommand(t, newCloudConnectCmd(), connectIDs...) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "Warning:") || !strings.Contains(out, "/home/dev/.config/rho/.tokens") { + t.Fatalf("output = %q, want a plaintext token warning naming the file", out) + } + + useTokenStorage(t, "macOS Keychain", false) + out, err = runCloudCommand(t, newCloudConnectCmd(), connectIDs...) + if err != nil || strings.Contains(out, "Warning:") { + t.Fatalf("keychain output = %q, err = %v; no warning expected", out, err) + } +} + +func TestCloudStatusShowsTokenStorage(t *testing.T) { + cfg := cloud.DeviceConfig{Endpoint: cloud.DefaultEndpoint, DeviceID: "device_0123456789", ProjectID: "project_0123456789"} + useCloudClient(t, cloud.New(cloud.Config{Endpoint: cfg.Endpoint, DeviceToken: "hwc_test"}), cfg, nil) + useTokenStorage(t, "plaintext file /tmp/rho/.tokens (mode 0600)", true) + out, err := runCloudCommand(t, newCloudStatusCmd()) + if err != nil || !strings.Contains(out, "Device token: plaintext file /tmp/rho/.tokens") || !strings.Contains(out, "not an OS credential store") { + t.Fatalf("out = %q, err = %v", out, err) + } +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 02e59131..fb8855b3 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -43,11 +43,38 @@ func (t *TokenStore) Has(provider string) bool { return ok } -// SecureStorage handles secure token storage using OS keychain/keyring. +// SecureStorage stores secrets in the OS credential store where rho has one: +// the macOS Keychain and the Windows Credential Manager. On every other +// platform (Linux included; there is no Secret Service integration yet) it +// falls back to a plaintext JSON file at TokenFilePath, written atomically +// with mode 0600 and refused if it is a symlink. Callers that store +// long-lived credentials must check UsesPlaintextTokenFile and tell the user. type SecureStorage struct { service string } +// UsesPlaintextTokenFile reports whether SecureStorage keeps secrets in the +// plaintext fallback file on this platform instead of an OS credential store. +func UsesPlaintextTokenFile() bool { return usesPlaintextTokenFile(runtime.GOOS) } + +func usesPlaintextTokenFile(goos string) bool { return goos != "darwin" && goos != "windows" } + +// TokenFilePath returns the plaintext fallback token file. +func TokenFilePath() string { return filepath.Join(storage.ConfigDir(), ".tokens") } + +// CredentialStoreName describes where SecureStorage keeps secrets on this +// platform, for user-facing messages. +func CredentialStoreName() string { + switch runtime.GOOS { + case "darwin": + return "macOS Keychain" + case "windows": + return "Windows Credential Manager" + default: + return "plaintext file " + TokenFilePath() + " (mode 0600)" + } +} + // NewSecureStorage creates a new secure storage. func NewSecureStorage(service string) *SecureStorage { return &SecureStorage{service: service} @@ -61,7 +88,8 @@ func (s *SecureStorage) Get(account string) (string, error) { if runtime.GOOS == "windows" { return s.getWindows(account) } - // Fallback to file-based storage for Linux (keyring handled by flux layer) + // No OS credential store integration on this platform: use the 0600 + // plaintext fallback file (see UsesPlaintextTokenFile). return s.getFile(account) } @@ -216,7 +244,7 @@ func (s *SecureStorage) setWindows(account, token string) error { } func (s *SecureStorage) getFile(account string) (string, error) { - path := filepath.Join(storage.ConfigDir(), ".tokens") + path := TokenFilePath() info, err := os.Lstat(path) if err != nil { return "", err @@ -243,7 +271,7 @@ func (s *SecureStorage) getFile(account string) (string, error) { } func (s *SecureStorage) setFile(account, token string) error { - path := filepath.Join(storage.ConfigDir(), ".tokens") + path := TokenFilePath() if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } diff --git a/internal/auth/plaintext_store_test.go b/internal/auth/plaintext_store_test.go new file mode 100644 index 00000000..25f8eed9 --- /dev/null +++ b/internal/auth/plaintext_store_test.go @@ -0,0 +1,36 @@ +package auth + +import ( + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestUsesPlaintextTokenFileOnlyWithoutOSStore(t *testing.T) { + for goos, want := range map[string]bool{ + "darwin": false, + "windows": false, + "linux": true, + "freebsd": true, + } { + if got := usesPlaintextTokenFile(goos); got != want { + t.Errorf("usesPlaintextTokenFile(%q) = %v, want %v", goos, got, want) + } + } + if UsesPlaintextTokenFile() != usesPlaintextTokenFile(runtime.GOOS) { + t.Fatal("UsesPlaintextTokenFile disagrees with the current platform") + } +} + +func TestCredentialStoreNameNamesThePlaintextFile(t *testing.T) { + dir := t.TempDir() + t.Setenv("RHO_CONFIG_DIR", dir) + if TokenFilePath() != filepath.Join(dir, ".tokens") { + t.Fatalf("TokenFilePath = %q", TokenFilePath()) + } + name := CredentialStoreName() + if UsesPlaintextTokenFile() != strings.Contains(name, TokenFilePath()) { + t.Fatalf("CredentialStoreName = %q; it must name the plaintext file exactly when it is used", name) + } +} diff --git a/internal/platform/cloud/config.go b/internal/platform/cloud/config.go index c63ae771..6748d532 100644 --- a/internal/platform/cloud/config.go +++ b/internal/platform/cloud/config.go @@ -31,6 +31,13 @@ type secretStore interface { // never touch the real OS keychain. var newTokenStore = func() secretStore { return auth.NewSecureStorage(tokenService) } +// TokenStorage describes where the device token is kept on this platform and +// whether that is the plaintext fallback file rather than an OS credential +// store. +func TokenStorage() (where string, plaintext bool) { + return auth.CredentialStoreName(), auth.UsesPlaintextTokenFile() +} + type DeviceConfig struct { Endpoint string `json:"endpoint"` DeviceID string `json:"device_id"` From c74d1ba33a7e9cbc5e6ea7dbbf10761e5c161786 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:56:48 +0530 Subject: [PATCH 12/16] 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. Co-Authored-By: Claude Opus 5.5 --- internal/daemon/graph_policy_parity_test.go | 33 +++++ internal/daemon/routes_graph_sync.go | 7 +- internal/platform/cloud/graph.go | 65 +++++++++- internal/platform/cloud/graph_policy_test.go | 118 ++++++++++++++++++ .../testdata/graph_attribute_policy.json | 21 ++++ 5 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 internal/daemon/graph_policy_parity_test.go create mode 100644 internal/platform/cloud/graph_policy_test.go create mode 100644 internal/platform/cloud/testdata/graph_attribute_policy.json diff --git a/internal/daemon/graph_policy_parity_test.go b/internal/daemon/graph_policy_parity_test.go new file mode 100644 index 00000000..94be6cd3 --- /dev/null +++ b/internal/daemon/graph_policy_parity_test.go @@ -0,0 +1,33 @@ +package daemon + +import ( + "encoding/json" + "os" + "testing" +) + +// TestGraphAttributePolicyMatchesCloud keeps the daemon's mirror of the cloud +// sensitive-attribute policy on the shared fixture the cloud client tests use. +func TestGraphAttributePolicyMatchesCloud(t *testing.T) { + raw, err := os.ReadFile("../platform/cloud/testdata/graph_attribute_policy.json") + if err != nil { + t.Fatal(err) + } + var fixture struct { + Cases []struct { + Key string `json:"key"` + CloudSafe bool `json:"cloudSafe"` + } `json:"cases"` + } + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatal(err) + } + if len(fixture.Cases) == 0 { + t.Fatal("empty policy fixture") + } + for _, tc := range fixture.Cases { + if unsafe := hasUnsafeAttributes(map[string]string{tc.Key: "v"}); unsafe == tc.CloudSafe { + t.Errorf("key %q: daemon unsafe = %v, want cloud-safe %v", tc.Key, unsafe, tc.CloudSafe) + } + } +} diff --git a/internal/daemon/routes_graph_sync.go b/internal/daemon/routes_graph_sync.go index d45b3621..c8232993 100644 --- a/internal/daemon/routes_graph_sync.go +++ b/internal/daemon/routes_graph_sync.go @@ -41,11 +41,12 @@ var graphSchemaVersionPattern = regexp.MustCompile(`^[a-z0-9-]+\.graph/v1$`) // graphSensitiveAttribute and graphSafeSensitiveAttribute mirror the cloud // plane's sensitive-attribute policy: a node/edge attribute key that names // sensitive content is rejected unless it is an explicit digest/count (or -// sast_source). Producers that want those values to reach the cloud must -// hash them behind a `_sha256` key (as PrepareGraph does). +// exactly sast_source). Both checks are case-insensitive, as on GrayCode +// Cloud and in PrepareGraph. Producers that want those values to reach the +// cloud must hash them behind a `_sha256` key (as PrepareGraph does). var ( graphSensitiveAttribute = regexp.MustCompile(`(?i)(?:content|prompt|secret|credential|password|api[_-]?key|query|reason|url|path|command|provider|model|repository|branch|commit|source|target|message|evidence|element|file|fix)`) - graphSafeSensitiveAttribute = regexp.MustCompile(`(?:_sha256|_digest|_count|_tokens?|token_count)$`) + graphSafeSensitiveAttribute = regexp.MustCompile(`(?i)(?:_sha256|_digest|_count|_tokens?|token_count)$`) ) // GraphSyncRequest is the JSON body for POST /v1/graph/sync. The graph is diff --git a/internal/platform/cloud/graph.go b/internal/platform/cloud/graph.go index b3d429c5..37f10cf5 100644 --- a/internal/platform/cloud/graph.go +++ b/internal/platform/cloud/graph.go @@ -12,11 +12,35 @@ import ( const MaxGraphSyncBodySize = 1 << 20 +// The cloud sensitive-attribute policy. It must match graphHasUnsafeCloudData +// in GrayCode Cloud (graycode-platform apps/worker/src/domain/graph.ts) and +// the daemon mirror in internal/daemon; testdata/graph_attribute_policy.json +// pins the shared expectations: +// - a key naming sensitive content is hashed behind "_sha256"; +// - the safe-suffix check is case-insensitive on both sides, so FILE_COUNT +// or Model_Tokens are counts, not content; +// - "sast_source" (exact key) is exempt on both sides: it marks whether a +// finding came from static analysis and carries no source text or path; +// - any scope with a tenant_id is rejected on both sides (see +// rejectTenantScopes). var ( sensitiveGraphAttribute = regexp.MustCompile(`(?i)(content|prompt|secret|credential|password|api[_-]?key|query|reason|url|path|command|provider|model|repository|branch|commit|source|target|message|evidence|element|file|fix)`) safeGraphAttribute = regexp.MustCompile(`(?i)(_sha256|_digest|_count|_tokens?|token_count)$`) ) +const ( + sastSourceAttribute = "sast_source" + // Attribute bounds of the portable graph schema (UTF-16 code units). + maxGraphAttributes = 64 + maxGraphAttributeKey = 64 + maxGraphAttributeValue = 512 +) + +// isSensitiveGraphAttribute reports whether key must be hashed before upload. +func isSensitiveGraphAttribute(key string) bool { + return key != sastSourceAttribute && sensitiveGraphAttribute.MatchString(key) && !safeGraphAttribute.MatchString(key) +} + // PreparedGraph is a bounded, cloud-safe graph document and its deterministic // upload identity. type PreparedGraph struct { @@ -70,6 +94,9 @@ func PrepareGraph(graph any) (PreparedGraph, error) { if facts > 900 { return PreparedGraph{}, fmt.Errorf("graph has %d facts; Rho Cloud accepts at most 900", facts) } + if err := rejectTenantScopes(document, nodes, edges, events); err != nil { + return PreparedGraph{}, err + } for _, collection := range [][]any{nodes, edges} { for _, fact := range collection { @@ -110,6 +137,33 @@ func graphFacts(document map[string]any, field string, limit int) ([]any, error) return value, nil } +// rejectTenantScopes refuses a graph whose document scope or any fact scope +// sets tenant_id. GrayCode Cloud rejects such graphs because the connected +// project already scopes every upload; failing here gives a specific message +// instead of the Worker's generic 400. +func rejectTenantScopes(document map[string]any, collections ...[]any) error { + if hasTenantScope(document) { + return fmt.Errorf("graph scope sets tenant_id; GrayCode Cloud rejects tenant-scoped graphs because the connected project already scopes the upload") + } + for _, collection := range collections { + for _, fact := range collection { + if item, ok := fact.(map[string]any); ok && hasTenantScope(item) { + return fmt.Errorf("graph fact %v scope sets tenant_id; GrayCode Cloud rejects tenant-scoped facts because the connected project already scopes the upload", item["id"]) + } + } + } + return nil +} + +func hasTenantScope(item map[string]any) bool { + scope, ok := item["scope"].(map[string]any) + if !ok { + return false + } + tenant, present := scope["tenant_id"] + return present && tenant != nil && tenant != "" +} + func sanitizeGraphAttributes(fact map[string]any) error { value, exists := fact["attributes"] if !exists { @@ -127,15 +181,24 @@ func sanitizeGraphAttributes(fact map[string]any) error { } safeKey := key safeValue := text - if sensitiveGraphAttribute.MatchString(key) && !safeGraphAttribute.MatchString(key) { + if isSensitiveGraphAttribute(key) { safeKey = key + "_sha256" safeValue = sha256Hex([]byte(text)) } + if n := utf16Len(safeKey); n == 0 || n > maxGraphAttributeKey { + return fmt.Errorf("graph attribute key %q is %d characters after cloud sanitization; GrayCode Cloud accepts 1 to %d", safeKey, n, maxGraphAttributeKey) + } + if n := utf16Len(safeValue); n > maxGraphAttributeValue { + return fmt.Errorf("graph attribute %q value is %d characters; GrayCode Cloud accepts at most %d", safeKey, n, maxGraphAttributeValue) + } if _, duplicate := sanitized[safeKey]; duplicate { return fmt.Errorf("graph attributes collide after cloud sanitization at %q", safeKey) } sanitized[safeKey] = safeValue } + if len(sanitized) > maxGraphAttributes { + return fmt.Errorf("graph fact has %d attributes; GrayCode Cloud accepts at most %d", len(sanitized), maxGraphAttributes) + } fact["attributes"] = sanitized return nil } diff --git a/internal/platform/cloud/graph_policy_test.go b/internal/platform/cloud/graph_policy_test.go new file mode 100644 index 00000000..0cd54376 --- /dev/null +++ b/internal/platform/cloud/graph_policy_test.go @@ -0,0 +1,118 @@ +package cloud + +import ( + "encoding/json" + "os" + "strings" + "testing" + "time" + + graphcontracts "github.com/GrayCodeAI/rho/internal/contracts/graph" + "github.com/GrayCodeAI/rho/internal/executiongraph" +) + +type graphPolicyCase struct { + Key string `json:"key"` + CloudSafe bool `json:"cloudSafe"` +} + +func loadGraphPolicy(t *testing.T) []graphPolicyCase { + t.Helper() + raw, err := os.ReadFile("testdata/graph_attribute_policy.json") + if err != nil { + t.Fatal(err) + } + var fixture struct { + Cases []graphPolicyCase `json:"cases"` + } + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatal(err) + } + return fixture.Cases +} + +func testGraph(attributes map[string]string) executiongraph.Export { + at := time.Date(2026, time.September, 27, 12, 0, 0, 0, time.UTC) + return executiongraph.Export{ + SchemaVersion: executiongraph.SchemaVersion, + GeneratedAt: at, + Nodes: []graphcontracts.Node{{ + ID: "rho/session/session-0123456789", Kind: graphcontracts.NodeExecution, CreatedAt: at, + Provenance: graphcontracts.Provenance{Producer: "rho"}, Attributes: attributes, + }}, + Edges: []graphcontracts.Edge{}, + Events: []graphcontracts.Event{}, + } +} + +func TestGraphAttributePolicyMatchesSharedFixture(t *testing.T) { + for _, tc := range loadGraphPolicy(t) { + if got := !isSensitiveGraphAttribute(tc.Key); got != tc.CloudSafe { + t.Errorf("key %q: cloud-safe = %v, want %v", tc.Key, got, tc.CloudSafe) + } + prepared, err := PrepareGraph(testGraph(map[string]string{tc.Key: "value"})) + if err != nil { + t.Fatalf("key %q: %v", tc.Key, err) + } + var document struct { + Nodes []struct { + Attributes map[string]string `json:"attributes"` + } `json:"nodes"` + } + if err := json.Unmarshal(prepared.Graph, &document); err != nil { + t.Fatal(err) + } + attributes := document.Nodes[0].Attributes + if tc.CloudSafe { + if attributes[tc.Key] != "value" { + t.Errorf("key %q: cloud-safe attribute was altered: %v", tc.Key, attributes) + } + continue + } + if _, leaked := attributes[tc.Key]; leaked || attributes[tc.Key+"_sha256"] != sha256Hex([]byte("value")) { + t.Errorf("key %q: sensitive attribute was not hashed: %v", tc.Key, attributes) + } + } +} + +func TestPrepareGraphRejectsTenantScopes(t *testing.T) { + cases := map[string]func(*executiongraph.Export){ + "document": func(g *executiongraph.Export) { g.Scope.TenantID = "tenant-1" }, + "node": func(g *executiongraph.Export) { g.Nodes[0].Scope.TenantID = "tenant-1" }, + "event": func(g *executiongraph.Export) { + g.Events = []graphcontracts.Event{{ + ID: "event-1", Type: graphcontracts.EventCreated, Subject: graphcontracts.Ref{Kind: graphcontracts.NodeExecution, ID: g.Nodes[0].ID}, + Scope: graphcontracts.Scope{TenantID: "tenant-1"}, OccurredAt: g.GeneratedAt, Provenance: graphcontracts.Provenance{Producer: "rho"}, + }} + }, + } + for name, mutate := range cases { + graph := testGraph(nil) + mutate(&graph) + if _, err := PrepareGraph(graph); err == nil || !strings.Contains(err.Error(), "tenant_id") { + t.Errorf("%s: error = %v, want a tenant_id rejection", name, err) + } + } + graph := testGraph(nil) + graph.Scope.ProjectID = "project_0123456789" + if _, err := PrepareGraph(graph); err != nil { + t.Fatalf("project-scoped graph rejected: %v", err) + } +} + +func TestPrepareGraphEnforcesAttributeBounds(t *testing.T) { + longSensitiveKey := strings.Repeat("k", 55) + "_model" // 61 chars, 68 after _sha256 + if _, err := PrepareGraph(testGraph(map[string]string{longSensitiveKey: "x"})); err == nil || !strings.Contains(err.Error(), "1 to 64") { + t.Fatalf("long key error = %v", err) + } + if _, err := PrepareGraph(testGraph(map[string]string{"note": strings.Repeat("v", maxGraphAttributeValue+1)})); err == nil { + t.Fatal("oversized attribute value accepted") + } + many := map[string]string{} + for i := 0; i <= maxGraphAttributes; i++ { + many["attr_"+strings.Repeat("x", i%5)+string(rune('a'+i%26))+string(rune('a'+i/26))] = "v" + } + if _, err := PrepareGraph(testGraph(many)); err == nil || !strings.Contains(err.Error(), "attributes") { + t.Fatalf("too many attributes error = %v", err) + } +} diff --git a/internal/platform/cloud/testdata/graph_attribute_policy.json b/internal/platform/cloud/testdata/graph_attribute_policy.json new file mode 100644 index 00000000..3d30b725 --- /dev/null +++ b/internal/platform/cloud/testdata/graph_attribute_policy.json @@ -0,0 +1,21 @@ +{ + "description": "Shared GrayCode Cloud graph attribute policy. cloudSafe=true means the key may be uploaded as-is; false means rho must hash it behind _sha256 and the cloud rejects it verbatim. Keep identical to the GrayCode Cloud worker fixture.", + "cases": [ + { "key": "entity_type", "cloudSafe": true }, + { "key": "tool_name", "cloudSafe": true }, + { "key": "message_count", "cloudSafe": true }, + { "key": "FILE_COUNT", "cloudSafe": true }, + { "key": "Model_Tokens", "cloudSafe": true }, + { "key": "prompt_token_count", "cloudSafe": true }, + { "key": "file_Sha256", "cloudSafe": true }, + { "key": "evidence_DIGEST", "cloudSafe": true }, + { "key": "sast_source", "cloudSafe": true }, + { "key": "SAST_SOURCE", "cloudSafe": false }, + { "key": "model", "cloudSafe": false }, + { "key": "Provider", "cloudSafe": false }, + { "key": "source_path", "cloudSafe": false }, + { "key": "commit", "cloudSafe": false }, + { "key": "api-key", "cloudSafe": false }, + { "key": "file_count_hint", "cloudSafe": false } + ] +} From 2c0a2dffad761800a594a1790615d167e03fe86b Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:58:17 +0530 Subject: [PATCH 13/16] 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. Co-Authored-By: Claude Opus 5.5 --- cmd/cloud.go | 20 ++++++++++---------- cmd/cloud_graph.go | 8 ++++---- cmd/session_share.go | 2 +- internal/platform/cloud/client.go | 6 ++++-- internal/platform/cloud/graph.go | 10 +++++----- testdata/golden/help_root.txt | 2 +- 6 files changed, 25 insertions(+), 23 deletions(-) diff --git a/cmd/cloud.go b/cmd/cloud.go index 0870203d..24ec543c 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -24,12 +24,12 @@ const cloudEndpointFlagHelp = "GrayCode Cloud API endpoint (default: $" + cloud. // loadCloudClient loads the saved GrayCode Cloud connection; tests replace it. var loadCloudClient = cloud.LoadClient -var cloudCmd = &cobra.Command{Use: "cloud", Short: "Manage optional Rho Cloud synchronization"} +var cloudCmd = &cobra.Command{Use: "cloud", Short: "Manage optional GrayCode Cloud synchronization"} func newCloudConnectCmd() *cobra.Command { command := &cobra.Command{ Use: "connect", - Short: "Connect this Rho device to Rho Cloud", + Short: "Connect this Rho device to GrayCode Cloud with an existing device token", Long: `Save an existing GrayCode Cloud device connection. Prefer "rho cloud login", which needs no token. The device token is read with --token-stdin or from a hidden prompt, never from the command line: @@ -57,13 +57,13 @@ hidden prompt, never from the command line: return err } warnIfPlaintextTokenStore(cmd) - cmd.Println(auditTint("Rho Cloud connected. Usage synchronization is opt-in and fail-open.", doneGreen)) + cmd.Println(auditTint("GrayCode Cloud connected. Usage synchronization is opt-in and fail-open.", doneGreen)) return nil }, } command.Flags().String("endpoint", "", cloudEndpointFlagHelp) - command.Flags().String("device-id", "", "Rho Cloud device ID") - command.Flags().String("project-id", "", "Rho Cloud project ID") + command.Flags().String("device-id", "", "GrayCode Cloud device ID") + command.Flags().String("project-id", "", "GrayCode Cloud project ID") command.Flags().Bool("token-stdin", false, "Read the device token from standard input") command.Flags().String("token", "", "Device token (deprecated: exposed in shell history and process listings)") _ = command.Flags().MarkDeprecated("token", "it exposes the device token in shell history and process listings; use --token-stdin or the interactive prompt") @@ -138,7 +138,7 @@ func readDeviceToken(cmd *cobra.Command) (string, error) { func newCloudLoginCmd() *cobra.Command { command := &cobra.Command{ Use: "login", - Short: "Sign in to Rho Cloud in a browser", + Short: "Sign in to GrayCode Cloud in a browser", Long: `Start a browser device login against GrayCode Cloud and store the issued device token in the OS credential store. @@ -202,7 +202,7 @@ http:// on localhost, 127.0.0.1 and [::1].`, prog.CompleteStep(0) prog.Done() warnIfPlaintextTokenStore(cmd) - cmd.Println(auditTint("Rho Cloud connected for project ", doneGreen) + auditTint(poll.ProjectID, textPrimary) + auditTint(".", doneGreen)) + cmd.Println(auditTint("GrayCode Cloud connected for project ", doneGreen) + auditTint(poll.ProjectID, textPrimary) + auditTint(".", doneGreen)) return nil } }, @@ -214,7 +214,7 @@ http:// on localhost, 127.0.0.1 and [::1].`, func newCloudStatusCmd() *cobra.Command { return &cobra.Command{ - Use: "status", Short: "Show Rho Cloud connection status", + Use: "status", Short: "Show GrayCode Cloud connection status", RunE: func(cmd *cobra.Command, _ []string) error { client, cfg, err := loadCloudClient() if errors.Is(err, cloud.ErrNotConnected) { @@ -232,7 +232,7 @@ func newCloudStatusCmd() *cobra.Command { if plaintext { storageLine = auditTint("Device token: "+where+" (not an OS credential store)", warnAmber) } - cmd.Println(auditTint("Rho Cloud connected: ", doneGreen) + auditTint(cfg.Endpoint, textPrimary) + auditTint(fmt.Sprintf(" (device %s, project %s)", cfg.DeviceID, cfg.ProjectID), textMuted)) + cmd.Println(auditTint("GrayCode Cloud connected: ", doneGreen) + auditTint(cfg.Endpoint, textPrimary) + auditTint(fmt.Sprintf(" (device %s, project %s)", cfg.DeviceID, cfg.ProjectID), textMuted)) cmd.Println(storageLine) return nil }, @@ -242,7 +242,7 @@ func newCloudStatusCmd() *cobra.Command { func newCloudContextCmd() *cobra.Command { command := &cobra.Command{ Use: "context", - Short: "Sync repository context to Rho Cloud", + Short: "Sync repository context to GrayCode Cloud", RunE: func(cmd *cobra.Command, _ []string) error { client, cfg, err := loadCloudClient() if err != nil { diff --git a/cmd/cloud_graph.go b/cmd/cloud_graph.go index 8fbc64a5..3001d55a 100644 --- a/cmd/cloud_graph.go +++ b/cmd/cloud_graph.go @@ -22,15 +22,15 @@ func newCloudGraphCmd() *cobra.Command { var missionDir string syncCmd := &cobra.Command{ Use: "sync [session-id]", - Short: "Upload a privacy-normalized execution graph to Rho Cloud", + Short: "Upload a privacy-normalized execution graph to GrayCode Cloud", Long: `Build the same read-only execution graph as "rho graph export", hash -cloud-sensitive metadata, enforce Rho Cloud's upload bounds, and upload it +cloud-sensitive metadata, enforce GrayCode Cloud's upload bounds, and upload it with a deterministic idempotency key. Sync completed session snapshots: graph facts are immutable after acceptance. This is explicit and opt-in; local execution never depends on cloud synchronization.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - prog := NewCLIProgress("Graph sync", []string{"Building execution graph", "Uploading to Rho Cloud"}) + prog := NewCLIProgress("Graph sync", []string{"Building execution graph", "Uploading to GrayCode Cloud"}) defer prog.Abort() prog.StartStep(0) var export executiongraph.Export @@ -57,7 +57,7 @@ execution never depends on cloud synchronization.`, } prepared, err := cloud.PrepareGraph(export) if err != nil { - return fmt.Errorf("prepare graph for Rho Cloud: %w", err) + return fmt.Errorf("prepare graph for GrayCode Cloud: %w", err) } prog.CompleteStep(0) prog.StartStep(1) diff --git a/cmd/session_share.go b/cmd/session_share.go index da1f597c..b63c0cb8 100644 --- a/cmd/session_share.go +++ b/cmd/session_share.go @@ -56,7 +56,7 @@ func runSessionShare(_ *cobra.Command, args []string) error { return fmt.Errorf("could not generate a share link for session %q", s.ID) } fmt.Printf("Share deeplink: %s\n", link) - fmt.Printf("(Set RHO_SHARE_URL to a Rho Cloud share endpoint for a hosted URL.)\n") + fmt.Printf("(Set RHO_SHARE_URL to a share service that accepts POST /v1/shares for a hosted URL; GrayCode Cloud does not host session shares.)\n") return nil } diff --git a/internal/platform/cloud/client.go b/internal/platform/cloud/client.go index 192bf11f..51d6e7fb 100644 --- a/internal/platform/cloud/client.go +++ b/internal/platform/cloud/client.go @@ -1,4 +1,6 @@ -// Package cloud contains Rho's optional, fail-open HTTP integration with Rho Cloud. +// Package cloud contains Rho's optional HTTP integration with GrayCode Cloud, +// the opt-in hosted control plane at https://cloud.graycodeai.com. Automatic +// usage upload is fail-open; explicit commands report every failure. package cloud import ( @@ -220,7 +222,7 @@ func (c *Client) checkEndpoint() error { return c.endpointErr } if c.endpoint == "" { - return fmt.Errorf("rho cloud endpoint is not configured") + return fmt.Errorf("GrayCode Cloud endpoint is not configured") } return nil } diff --git a/internal/platform/cloud/graph.go b/internal/platform/cloud/graph.go index 37f10cf5..1100db4b 100644 --- a/internal/platform/cloud/graph.go +++ b/internal/platform/cloud/graph.go @@ -64,7 +64,7 @@ type GraphSyncResult struct { } // PrepareGraph converts a portable graph to deterministic JSON, hashes values -// behind cloud-sensitive attribute names, and enforces Rho Cloud's fact caps. +// behind cloud-sensitive attribute names, and enforces GrayCode Cloud's fact caps. // It does not mutate the caller's graph. func PrepareGraph(graph any) (PreparedGraph, error) { raw, err := json.Marshal(graph) @@ -92,7 +92,7 @@ func PrepareGraph(graph any) (PreparedGraph, error) { } facts := len(nodes) + len(edges) + len(events) if facts > 900 { - return PreparedGraph{}, fmt.Errorf("graph has %d facts; Rho Cloud accepts at most 900", facts) + return PreparedGraph{}, fmt.Errorf("graph has %d facts; GrayCode Cloud accepts at most 900", facts) } if err := rejectTenantScopes(document, nodes, edges, events); err != nil { return PreparedGraph{}, err @@ -132,7 +132,7 @@ func graphFacts(document map[string]any, field string, limit int) ([]any, error) return nil, fmt.Errorf("graph %s must be an array", field) } if len(value) > limit { - return nil, fmt.Errorf("graph has %d %s; Rho Cloud accepts at most %d", len(value), field, limit) + return nil, fmt.Errorf("graph has %d %s; GrayCode Cloud accepts at most %d", len(value), field, limit) } return value, nil } @@ -224,7 +224,7 @@ func (c *Client) SyncGraph(ctx context.Context, request GraphSyncRequest) (Graph return result, fmt.Errorf("marshal graph sync: %w", err) } if len(body) > MaxGraphSyncBodySize { - return result, fmt.Errorf("graph sync body is %d bytes; Rho Cloud accepts at most %d", len(body), MaxGraphSyncBodySize) + return result, fmt.Errorf("graph sync body is %d bytes; GrayCode Cloud accepts at most %d", len(body), MaxGraphSyncBodySize) } req, err := c.newJSONRequest(ctx, "/v1/graph/sync", body, true) if err != nil { @@ -243,7 +243,7 @@ func (c *Client) SyncGraph(ctx context.Context, request GraphSyncRequest) (Graph return result, err } if !result.Accepted { - return result, fmt.Errorf("rho cloud did not accept graph sync") + return result, fmt.Errorf("GrayCode Cloud did not accept the graph sync") } return result, nil } diff --git a/testdata/golden/help_root.txt b/testdata/golden/help_root.txt index 6881f67b..2e8443a6 100644 --- a/testdata/golden/help_root.txt +++ b/testdata/golden/help_root.txt @@ -60,7 +60,7 @@ Context & memory: taste Manage taste profile (learned coding style preferences) Configure: - cloud Manage optional Rho Cloud synchronization + cloud Manage optional GrayCode Cloud synchronization config Show or update settings credentials Manage secure API key storage (macOS Keychain / Linux secret service) features List and manage feature flags From 2f59875d2dcc0866e8a2cc14f02c98294ca31fa1 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:00:26 +0530 Subject: [PATCH 14/16] 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. Co-Authored-By: Claude Opus 5.5 --- .../cloud/contract_conformance_test.go | 150 ++++++ .../platform/cloud/contract_worker_test.go | 510 ++++++++++++++++++ 2 files changed, 660 insertions(+) create mode 100644 internal/platform/cloud/contract_conformance_test.go create mode 100644 internal/platform/cloud/contract_worker_test.go diff --git a/internal/platform/cloud/contract_conformance_test.go b/internal/platform/cloud/contract_conformance_test.go new file mode 100644 index 00000000..fb94bcad --- /dev/null +++ b/internal/platform/cloud/contract_conformance_test.go @@ -0,0 +1,150 @@ +package cloud + +import ( + "context" + "fmt" + "net/http" + "strings" + "testing" + "time" + + graphcontracts "github.com/GrayCodeAI/rho/internal/contracts/graph" + "github.com/GrayCodeAI/rho/internal/executiongraph" +) + +// The IDs GrayCode Cloud issues are 32 lowercase hex characters (newId()). +const ( + contractDeviceID = "0123456789abcdef0123456789abcdef" + contractProjectID = "fedcba9876543210fedcba9876543210" +) + +// TestContractWorkerIsStrict proves the stand-in Worker rejects what the real +// schemas reject, so the conformance tests below are meaningful. +func TestContractWorkerIsStrict(t *testing.T) { + worker := newContractWorker(t) + validUsage := `"eventId":"exec-1727400000000-0123456789abcdef","deviceId":"` + contractDeviceID + `","projectId":"` + contractProjectID + `","tokensUsed":1,"occurredAt":"2026-09-27T12:00:00Z"` + rejected := map[string][2]string{ + "start with legacy rhoVersion": {"/v1/auth/device/start", `{"label":"l","platform":"linux","rhoVersion":"0.3.0"}`}, + "start label too long": {"/v1/auth/device/start", `{"label":"` + strings.Repeat("x", 101) + `","platform":"linux","graycodeVersion":"0.3.0"}`}, + "poll unknown key": {"/v1/auth/device/poll", `{"deviceCode":"dc_0123456789abcdefghij","extra":1}`}, + "usage retired capability": {"/v1/usage", `{` + validUsage + `,"capability":"swift"}`}, + "usage duration over 24h": {"/v1/usage", `{` + validUsage + `,"capability":"rho","durationMs":86400001}`}, + "usage short opaque id": {"/v1/usage", strings.Replace(`{`+validUsage+`,"capability":"rho"}`, "exec-1727400000000-0123456789abcdef", "exec-1", 1)}, + "usage fractional tokens": {"/v1/usage", strings.Replace(`{`+validUsage+`,"capability":"rho"}`, `"tokensUsed":1`, `"tokensUsed":1.5`, 1)}, + "usage unknown key": {"/v1/usage", `{` + validUsage + `,"capability":"rho","rhoVersion":"0.3.0"}`}, + "delivery bad ci status": {"/v1/delivery-context", `{"projectId":"` + contractProjectID + `","repository":{"provider":"github","externalId":"1","name":"a/b"},"ciRun":{"provider":"github","externalId":"1","status":"passed"}}`}, + "graph tenant scope": {"/v1/graph/sync", `{"syncId":"graph_0123456789abcdef","projectId":"` + contractProjectID + `","graph":{"schema_version":"rho.graph/v1","generated_at":"2026-09-27T12:00:00Z","scope":{"tenant_id":"t"},"nodes":[],"edges":[],"events":[]}}`}, + "graph unknown key": {"/v1/graph/sync", `{"syncId":"graph_0123456789abcdef","projectId":"` + contractProjectID + `","graph":{"schema_version":"rho.graph/v1","generated_at":"2026-09-27T12:00:00Z","nodes":[],"edges":[],"events":[],"extra":true}}`}, + } + for name, request := range rejected { + if status := worker.postRaw(t, request[0], request[1]); status != http.StatusBadRequest { + t.Errorf("%s: status = %d, want 400", name, status) + } + } + if status := worker.postRaw(t, "/v1/usage", `{`+validUsage+`,"capability":"rho"}`); status != http.StatusAccepted { + t.Fatalf("valid usage status = %d, want 202", status) + } +} + +func TestDeviceLoginConformsToContract(t *testing.T) { + worker := newContractWorker(t) + client := worker.client() + for _, args := range [][3]string{ + {"lakshman-mbp.local", "darwin", "0.3.0"}, + {strings.Repeat("\U0001F680", 80), "linux", "v0.3.0-12-g13e05ceb-dirty+" + strings.Repeat("x", 60)}, + {"", "", ""}, + } { + start, err := client.StartDeviceLogin(context.Background(), args[0], args[1], args[2]) + if err != nil { + t.Fatalf("StartDeviceLogin(%q) = %v", args, err) + } + poll, err := client.PollDeviceLogin(context.Background(), start.DeviceCode) + if err != nil || poll.Status != DeviceLoginApproved { + t.Fatalf("PollDeviceLogin = %+v, %v", poll, err) + } + } +} + +func TestUsageEventConformsToContract(t *testing.T) { + worker := newContractWorker(t) + started := time.Date(2026, time.September, 27, 12, 0, 0, 0, time.UTC) + events := []UsageEvent{ + { + // Shape built by `rho exec` (cmd/exec.go). + EventID: fmt.Sprintf("exec-%d-%s", started.UnixMilli(), "0123456789abcdef"), DeviceID: contractDeviceID, ProjectID: contractProjectID, + SessionID: fmt.Sprintf("exec-%d-%s", started.UnixMilli(), "01234567"), Capability: CapabilityRho, + Model: "claude-sonnet-4-5", InputTokens: 1200, OutputTokens: 300, TokensUsed: 1500, + DurationMS: 42_000, Status: "completed", OccurredAt: started.Format(time.RFC3339), + }, + { + // A 30-hour mission with oversized counters and text. + EventID: "exec-1727400000000-fedcba9876543210", DeviceID: contractDeviceID, ProjectID: contractProjectID, + Model: strings.Repeat("m", 300), InputTokens: 50_000_000, TokensUsed: 60_000_000, + DurationMS: 30 * 60 * 60 * 1000, Status: "failed", ErrorCode: strings.Repeat("e", 200), + }, + } + for _, event := range events { + if err := worker.client().RecordUsage(context.Background(), event); err != nil { + t.Fatalf("RecordUsage(%s) = %v", event.EventID, err) + } + } +} + +func TestDeliveryContextConformsToContract(t *testing.T) { + worker := newContractWorker(t) + event := DeliveryContext{ProjectID: contractProjectID, Branch: "main", CommitSHA: strings.Repeat("a", 40)} + event.Repository.Provider, event.Repository.ExternalID, event.Repository.Name = "github", "GrayCodeAI/rho", "GrayCodeAI/rho" + event.CIRun = &CIRunContext{Provider: "github", ExternalID: "1234567890", Workflow: "CI", Status: "succeeded"} + event.Deployment = &DeploymentContext{Provider: "github", ExternalID: "deploy-1", Environment: "production", Status: "rolled_back"} + if err := worker.client().SendDeliveryContext(context.Background(), event); err != nil { + t.Fatal(err) + } + minimal := DeliveryContext{ProjectID: contractProjectID} + minimal.Repository.Provider, minimal.Repository.ExternalID, minimal.Repository.Name = "git", "local", "local" + if err := worker.client().SendDeliveryContext(context.Background(), minimal); err != nil { + t.Fatal(err) + } +} + +func TestGraphSyncConformsToContract(t *testing.T) { + worker := newContractWorker(t) + at := time.Date(2026, time.September, 27, 12, 0, 0, 0, time.UTC) + provenance := graphcontracts.Provenance{Producer: "rho", Version: "0.3.0"} + session := graphcontracts.Ref{Kind: graphcontracts.NodeExecution, ID: "rho/session/session-0123456789"} + tool := graphcontracts.Ref{Kind: graphcontracts.NodeExecution, ID: "rho/tool/tool-0123456789"} + export := executiongraph.Export{ + SchemaVersion: executiongraph.SchemaVersion, + GeneratedAt: at, + Scope: graphcontracts.Scope{ProjectID: contractProjectID, RepositoryID: "GrayCodeAI/rho"}, + Nodes: []graphcontracts.Node{ + {ID: session.ID, Kind: session.Kind, CreatedAt: at, Provenance: provenance, Attributes: map[string]string{ + "entity_type": "rho_session", "provider": "private-provider", "model": "private-model", + "MESSAGE_COUNT": "12", "sast_source": "true", "Prompt_Tokens": "1200", + }}, + {ID: tool.ID, Kind: tool.Kind, CreatedAt: at, Provenance: provenance, Attributes: map[string]string{ + "tool_name": "Bash", "command": "go test ./...", "file_path": "/home/dev/src/main.go", + }}, + }, + Edges: []graphcontracts.Edge{{ + ID: "rho/edge/contains-0123456789", Kind: graphcontracts.EdgeContains, From: session, To: tool, + CreatedAt: at, Provenance: provenance, Attributes: map[string]string{"reason": "tool call"}, + }}, + Events: []graphcontracts.Event{{ + ID: "rho/event/created-0123456789", Type: graphcontracts.EventCreated, Subject: session, + OccurredAt: at, Provenance: provenance, + }}, + } + prepared, err := PrepareGraph(export) + if err != nil { + t.Fatal(err) + } + result, err := worker.client().SyncGraph(context.Background(), GraphSyncRequest{SyncID: prepared.SyncID, ProjectID: contractProjectID, Graph: prepared.Graph}) + if err != nil || !result.Accepted { + t.Fatalf("SyncGraph = %+v, %v", result, err) + } + for _, leaked := range []string{"private-provider", "private-model", "go test ./...", "/home/dev/src/main.go", "tool call"} { + if strings.Contains(string(prepared.Graph), leaked) { + t.Fatalf("prepared graph leaked %q", leaked) + } + } +} diff --git a/internal/platform/cloud/contract_worker_test.go b/internal/platform/cloud/contract_worker_test.go new file mode 100644 index 00000000..6a088671 --- /dev/null +++ b/internal/platform/cloud/contract_worker_test.go @@ -0,0 +1,510 @@ +package cloud + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + "time" + "unicode/utf16" +) + +// contractWorker is an httptest stand-in for the GrayCode Cloud Worker that +// enforces the request schemas of the rho <-> GrayCode Cloud wire contract the +// way the Worker's zod schemas do: strict objects (unknown keys rejected), +// enums, string lengths in UTF-16 code units, integer bounds, RFC 3339 +// timestamps and the opaqueID pattern. A schema violation is a 400 with the +// violation in the error, so a test failure names the offending field. +type contractWorker struct { + t *testing.T + token string + server *httptest.Server +} + +func newContractWorker(t *testing.T) *contractWorker { + t.Helper() + w := &contractWorker{t: t, token: "hwc_contract_token"} + w.server = httptest.NewServer(http.HandlerFunc(w.serve)) + t.Cleanup(w.server.Close) + return w +} + +func (w *contractWorker) client() *Client { + return New(Config{Endpoint: w.server.URL, DeviceToken: w.token}) +} + +func (w *contractWorker) serve(rw http.ResponseWriter, r *http.Request) { + reply := func(status int, body any) { + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(status) + _ = json.NewEncoder(rw).Encode(body) + } + if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/json" { + reply(http.StatusBadRequest, map[string]string{"error": "expected a JSON POST"}) + return + } + authenticated := map[string]bool{"/v1/usage": true, "/v1/delivery-context": true, "/v1/graph/sync": true} + if authenticated[r.URL.Path] && r.Header.Get("Authorization") != "Bearer "+w.token { + reply(http.StatusUnauthorized, map[string]string{"error": "Unauthorized"}) + return + } + decoder := json.NewDecoder(r.Body) + decoder.UseNumber() + var body map[string]any + if err := decoder.Decode(&body); err != nil { + reply(http.StatusBadRequest, map[string]string{"error": "body is not a JSON object: " + err.Error()}) + return + } + var err error + switch r.URL.Path { + case "/v1/auth/device/start": + if err = validateDeviceStart(body); err == nil { + reply(http.StatusCreated, map[string]any{ + "deviceCode": "dc_0123456789abcdefghijklmnop", "userCode": "ABCD-EFGH", + "verificationUri": "https://graycodeai.com/cli/approve", "expiresIn": 600, "interval": 5, + }) + return + } + case "/v1/auth/device/poll": + if err = validateDevicePoll(body); err == nil { + reply(http.StatusOK, map[string]any{ + "status": "approved", "token": w.token, "deviceId": "0123456789abcdef0123456789abcdef", + "projectId": "fedcba9876543210fedcba9876543210", "principalId": "user_0123456789", + }) + return + } + case "/v1/usage": + if err = validateUsage(body); err == nil { + reply(http.StatusAccepted, map[string]any{"accepted": true}) + return + } + case "/v1/delivery-context": + if err = validateDeliveryContext(body); err == nil { + reply(http.StatusAccepted, map[string]any{"accepted": true, "repositoryId": "repo_0123456789abcdef"}) + return + } + case "/v1/graph/sync": + if err = validateGraphSync(body); err == nil { + reply(http.StatusOK, map[string]any{"accepted": true, "duplicate": false, "graphDigest": strings.Repeat("a", 64), "facts": 1}) + return + } + default: + err = fmt.Errorf("unknown route %s", r.URL.Path) + } + reply(http.StatusBadRequest, map[string]string{"error": "contract violation: " + err.Error()}) +} + +// --- zod-equivalent schema helpers --- + +type field struct { + obj map[string]any + path string +} + +func (f field) sub(key string) string { return f.path + "." + key } + +// strict rejects keys outside required+optional and requires the required +// keys, like z.object({...}).strict(). +func (f field) strict(required, optional []string) error { + allowed := map[string]bool{} + for _, key := range append(append([]string{}, required...), optional...) { + allowed[key] = true + } + for key := range f.obj { + if !allowed[key] { + return fmt.Errorf("%s: unrecognized key %q", f.path, key) + } + } + for _, key := range required { + if _, ok := f.obj[key]; !ok { + return fmt.Errorf("%s: required", f.sub(key)) + } + } + return nil +} + +func units(s string) int { return len(utf16.Encode([]rune(s))) } + +// str checks z.string().min(lo).max(hi); trim mirrors .trim() before checks. +func (f field) str(key string, lo, hi int, trim bool) error { + value, present := f.obj[key] + if !present { + return nil + } + text, ok := value.(string) + if !ok { + return fmt.Errorf("%s: expected string", f.sub(key)) + } + if trim { + text = strings.TrimSpace(text) + } + if n := units(text); n < lo || n > hi { + return fmt.Errorf("%s: length %d outside %d..%d", f.sub(key), n, lo, hi) + } + return nil +} + +var workerOpaqueID = regexp.MustCompile(`^[A-Za-z0-9._:-]+$`) + +func (f field) opaque(key string) error { + if err := f.str(key, 16, 128, false); err != nil { + return err + } + if value, ok := f.obj[key].(string); ok && !workerOpaqueID.MatchString(value) { + return fmt.Errorf("%s: invalid opaque ID %q", f.sub(key), value) + } + return nil +} + +func (f field) integer(key string, lo, hi int64) error { + value, present := f.obj[key] + if !present { + return nil + } + number, ok := value.(json.Number) + if !ok { + return fmt.Errorf("%s: expected number", f.sub(key)) + } + n, err := number.Int64() + if err != nil { + return fmt.Errorf("%s: expected integer, got %s", f.sub(key), number) + } + if n < lo || n > hi { + return fmt.Errorf("%s: %d outside %d..%d", f.sub(key), n, lo, hi) + } + return nil +} + +func (f field) enum(key string, values ...string) error { + value, present := f.obj[key] + if !present { + return nil + } + for _, allowed := range values { + if value == allowed { + return nil + } + } + return fmt.Errorf("%s: %v is not one of %v", f.sub(key), value, values) +} + +func (f field) datetime(key string) error { + value, present := f.obj[key] + if !present { + return nil + } + text, ok := value.(string) + if !ok { + return fmt.Errorf("%s: expected datetime string", f.sub(key)) + } + if _, err := time.Parse(time.RFC3339Nano, text); err != nil { + return fmt.Errorf("%s: invalid datetime %q", f.sub(key), text) + } + return nil +} + +func (f field) regex(key string, pattern *regexp.Regexp) error { + value, present := f.obj[key] + if !present { + return nil + } + text, ok := value.(string) + if !ok || !pattern.MatchString(text) { + return fmt.Errorf("%s: %v does not match %s", f.sub(key), value, pattern) + } + return nil +} + +func (f field) object(key string) (field, bool, error) { + value, present := f.obj[key] + if !present { + return field{}, false, nil + } + obj, ok := value.(map[string]any) + if !ok { + return field{}, false, fmt.Errorf("%s: expected object", f.sub(key)) + } + return field{obj: obj, path: f.sub(key)}, true, nil +} + +func (f field) array(key string, maxItems int) ([]field, error) { + value, ok := f.obj[key].([]any) + if !ok { + return nil, fmt.Errorf("%s: expected array", f.sub(key)) + } + if len(value) > maxItems { + return nil, fmt.Errorf("%s: %d items, max %d", f.sub(key), len(value), maxItems) + } + out := make([]field, 0, len(value)) + for i, item := range value { + obj, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s[%d]: expected object", f.sub(key), i) + } + out = append(out, field{obj: obj, path: fmt.Sprintf("%s[%d]", f.sub(key), i)}) + } + return out, nil +} + +func firstError(errs ...error) error { + for _, err := range errs { + if err != nil { + return err + } + } + return nil +} + +// --- endpoint schemas (graycode-platform apps/worker/src) --- + +func validateDeviceStart(body map[string]any) error { + f := field{obj: body, path: "deviceStart"} + return firstError( + f.strict([]string{"label", "platform", "graycodeVersion"}, nil), + f.str("label", 1, 100, false), + f.str("platform", 1, 50, false), + f.str("graycodeVersion", 1, 50, false), + ) +} + +func validateDevicePoll(body map[string]any) error { + f := field{obj: body, path: "devicePoll"} + return firstError(f.strict([]string{"deviceCode"}, nil), f.str("deviceCode", 20, 128, false)) +} + +func validateUsage(body map[string]any) error { + f := field{obj: body, path: "usage"} + errs := []error{ + f.strict( + []string{"eventId", "deviceId", "projectId", "capability", "tokensUsed", "occurredAt"}, + []string{"sessionId", "provider", "model", "inputTokens", "outputTokens", "cachedInputTokens", "reasoningTokens", "tokensSaved", "estimatedCostMicros", "durationMs", "status", "errorCode"}, + ), + f.opaque("eventId"), f.opaque("deviceId"), f.opaque("projectId"), f.opaque("sessionId"), + f.enum("capability", "rho", "graycode"), + f.str("provider", 0, 100, false), f.str("model", 0, 100, false), f.str("errorCode", 0, 100, false), + f.integer("tokensUsed", 0, 10_000_000), + f.integer("durationMs", 0, 86_400_000), + f.integer("estimatedCostMicros", 0, 10_000_000_000), + f.enum("status", "completed", "failed", "cancelled"), + f.datetime("occurredAt"), + } + for _, counter := range []string{"inputTokens", "outputTokens", "cachedInputTokens", "reasoningTokens", "tokensSaved"} { + errs = append(errs, f.integer(counter, 0, 10_000_000)) + } + return firstError(errs...) +} + +func validateDeliveryContext(body map[string]any) error { + f := field{obj: body, path: "deliveryContext"} + if err := firstError( + f.strict([]string{"projectId", "repository"}, []string{"branch", "commitSha", "ciRun", "deployment"}), + f.opaque("projectId"), f.str("branch", 1, 200, true), f.str("commitSha", 1, 128, true), + ); err != nil { + return err + } + repository, _, err := f.object("repository") + if err != nil { + return err + } + if err := firstError( + repository.strict([]string{"provider", "externalId", "name"}, []string{"url", "defaultBranch"}), + repository.str("provider", 1, 50, true), repository.str("externalId", 1, 200, true), + repository.str("name", 1, 300, true), repository.str("defaultBranch", 1, 200, true), + ); err != nil { + return err + } + optionalRun := []string{"url", "startedAt", "completedAt"} + if ciRun, ok, err := f.object("ciRun"); err != nil { + return err + } else if ok { + if err := firstError( + ciRun.strict([]string{"provider", "externalId", "status"}, append([]string{"workflow"}, optionalRun...)), + ciRun.str("provider", 1, 50, true), ciRun.str("externalId", 1, 200, true), ciRun.str("workflow", 1, 200, true), + ciRun.enum("status", "queued", "running", "succeeded", "failed", "cancelled"), + ); err != nil { + return err + } + } + if deployment, ok, err := f.object("deployment"); err != nil { + return err + } else if ok { + return firstError( + deployment.strict([]string{"provider", "externalId", "environment", "status"}, optionalRun), + deployment.str("provider", 1, 50, true), deployment.str("externalId", 1, 200, true), deployment.str("environment", 1, 100, true), + deployment.enum("status", "queued", "running", "succeeded", "failed", "cancelled", "rolled_back"), + ) + } + return nil +} + +var ( + workerSchemaVersion = regexp.MustCompile(`^[a-z0-9-]+\.graph/v1$`) + workerSHA256 = regexp.MustCompile(`^[a-f0-9]{64}$`) + workerSensitive = regexp.MustCompile(`(?i)(?:content|prompt|secret|credential|password|api[_-]?key|query|reason|url|path|command|provider|model|repository|branch|commit|source|target|message|evidence|element|file|fix)`) + workerSafeSuffix = regexp.MustCompile(`(?i)(?:_sha256|_digest|_count|_tokens?|token_count)$`) +) + +func validateGraphSync(body map[string]any) error { + f := field{obj: body, path: "graphSync"} + if err := firstError( + f.strict([]string{"syncId", "projectId", "graph"}, []string{"sessionId"}), + f.opaque("syncId"), f.opaque("projectId"), f.opaque("sessionId"), + ); err != nil { + return err + } + graph, _, err := f.object("graph") + if err != nil { + return err + } + if err := firstError( + graph.strict([]string{"schema_version", "generated_at", "nodes", "edges", "events"}, []string{"query_sha256", "scope"}), + graph.regex("schema_version", workerSchemaVersion), graph.datetime("generated_at"), graph.regex("query_sha256", workerSHA256), + validateScope(graph), + ); err != nil { + return err + } + nodes, err := graph.array("nodes", 250) + if err != nil { + return err + } + edges, err := graph.array("edges", 500) + if err != nil { + return err + } + events, err := graph.array("events", 500) + if err != nil { + return err + } + if len(nodes)+len(edges)+len(events) > 900 { + return fmt.Errorf("graph exceeds the 900 fact limit") + } + nodeKinds := []string{"system", "knowledge", "execution", "policy", "quality", "operations"} + for _, node := range nodes { + if err := firstError( + node.strict([]string{"id", "kind", "created_at", "provenance"}, []string{"scope", "effective_at", "attributes"}), + node.str("id", 1, 256, true), node.enum("kind", nodeKinds...), node.datetime("created_at"), node.datetime("effective_at"), + validateScope(node), validateProvenance(node), validateAttributes(node), + ); err != nil { + return err + } + } + for _, edge := range edges { + if err := firstError( + edge.strict([]string{"id", "kind", "from", "to", "created_at", "provenance"}, []string{"scope", "effective_at", "attributes"}), + edge.str("id", 1, 256, true), + edge.enum("kind", "contains", "depends_on", "references", "produced", "governed_by", "validated_by"), + validateRef(edge, "from", nodeKinds), validateRef(edge, "to", nodeKinds), + edge.datetime("created_at"), edge.datetime("effective_at"), + validateScope(edge), validateProvenance(edge), validateAttributes(edge), + ); err != nil { + return err + } + } + for _, event := range events { + if err := firstError( + event.strict([]string{"id", "type", "subject", "occurred_at", "provenance"}, []string{"scope", "correlation_id", "causation_id", "idempotency_key"}), + event.str("id", 1, 256, true), event.enum("type", "created", "updated", "transitioned", "observed", "deleted"), + validateRef(event, "subject", nodeKinds), event.datetime("occurred_at"), + event.str("correlation_id", 0, 256, false), event.str("causation_id", 0, 256, false), event.str("idempotency_key", 0, 256, false), + validateScope(event), validateProvenance(event), + ); err != nil { + return err + } + } + return nil +} + +func validateScope(parent field) error { + scope, ok, err := parent.object("scope") + if err != nil || !ok { + return err + } + if tenant, present := scope.obj["tenant_id"]; present && tenant != "" { + return fmt.Errorf("%s: tenant-scoped graphs are rejected", scope.path) + } + return firstError( + scope.strict(nil, []string{"tenant_id", "project_id", "repository_id"}), + scope.str("tenant_id", 0, 128, false), scope.str("project_id", 0, 128, false), scope.str("repository_id", 0, 256, false), + ) +} + +func validateRef(parent field, key string, kinds []string) error { + ref, ok, err := parent.object(key) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("%s: required", parent.sub(key)) + } + return firstError(ref.strict([]string{"kind", "id"}, nil), ref.enum("kind", kinds...), ref.str("id", 1, 256, true)) +} + +func validateProvenance(parent field) error { + provenance, _, err := parent.object("provenance") + if err != nil { + return err + } + if err := firstError( + provenance.strict([]string{"producer"}, []string{"version", "source_id", "evidence"}), + provenance.str("producer", 1, 100, true), provenance.str("version", 0, 100, false), provenance.str("source_id", 0, 256, false), + ); err != nil { + return err + } + if _, present := provenance.obj["evidence"]; !present { + return nil + } + evidence, err := provenance.array("evidence", 16) + if err != nil { + return err + } + for _, item := range evidence { + if err := firstError( + item.strict([]string{"uri"}, []string{"digest", "media_type"}), + item.str("uri", 1, 2048, false), item.str("digest", 0, 256, false), item.str("media_type", 0, 128, false), + ); err != nil { + return err + } + } + return nil +} + +func validateAttributes(parent field) error { + attributes, ok, err := parent.object("attributes") + if err != nil || !ok { + return err + } + if len(attributes.obj) > 64 { + return fmt.Errorf("%s: too many graph attributes", attributes.path) + } + for key, value := range attributes.obj { + text, isString := value.(string) + if n := units(key); n < 1 || n > 64 || !isString || units(text) > 512 { + return fmt.Errorf("%s: attribute %q violates the record schema", attributes.path, key) + } + if workerSensitive.MatchString(key) && key != "sast_source" && !workerSafeSuffix.MatchString(key) { + return fmt.Errorf("%s: graph contains non-portable or sensitive metadata (%q)", attributes.path, key) + } + } + return nil +} + +// postRaw sends a hand-written body to the contract worker. +func (w *contractWorker) postRaw(t *testing.T, path, body string) int { + t.Helper() + req, err := http.NewRequest(http.MethodPost, w.server.URL+path, bytes.NewReader([]byte(body))) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+w.token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + return resp.StatusCode +} From 327a4e8e6750b35e8bb9d20763e8f59214b83cc2 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:01:41 +0530 Subject: [PATCH 15/16] 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. Co-Authored-By: Claude Opus 5.5 --- README.md | 43 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8a90fd0d..d58e14cc 100644 --- a/README.md +++ b/README.md @@ -144,10 +144,7 @@ rho cloud graph sync rho cloud graph sync --mission-dir /path/to/mission ``` -Cloud commands require an endpoint. There is no default: pass `--endpoint` or -set `RHO_CLOUD_URL` to your Rho Cloud worker URL before running -`rho cloud login`. `https://api.graycodeai.com` is the browser BFF and -will reject a device token. +Syncing needs a [GrayCode Cloud](#graycode-cloud-optional) connection. The export contains metadata and hashes, not prompts, tool arguments/results, policy reasons, verification evidence, or runtime output. Persisted chat @@ -156,6 +153,44 @@ and `VerifyPlanExecution` summaries for subsequent graph exports. Mission runs also persist a portable `mission-graph.json`; the mission form is validated and synchronized explicitly with the `--mission-dir` variants above. +### GrayCode Cloud (optional) + +GrayCode Cloud is the optional, opt-in hosted control plane for device login, +usage, delivery context and graph sync. Rho works fully without it and sends +nothing until you connect. It is alpha and still being rolled out: if +`rho cloud login` cannot reach `cloud.graycodeai.com`, the hosted service is +not available to you yet, and nothing else in Rho is affected. + +```bash +rho cloud login # browser device login +rho cloud status # endpoint, device, project, token storage +rho cloud context # sync repository, branch, CI and deployment context +rho cloud graph sync # upload a privacy-normalized execution graph +``` + +- **Endpoint.** New connections use `https://cloud.graycodeai.com` unless you + pass `--endpoint` or set `RHO_CLOUD_URL` (for example a local Worker at + `http://127.0.0.1:8787`). Only `https://` is accepted, except plain + `http://` on `localhost`, `127.0.0.1` and `[::1]`. A saved connection keeps + the endpoint that issued its token. `https://api.graycodeai.com` is the + browser API and rejects device tokens. +- **What is sent.** Once connected, each `rho exec` run reports one usage + event (model, token counts, duration, status). This is fail-open: it never + changes the run's result, and rho waits at most 3 seconds for it after the + output is written. `rho cloud context` and `rho cloud graph sync` are + explicit, and any rejection is reported as an error. +- **Device token.** rho keeps the token in the macOS Keychain or the Windows + Credential Manager. Linux and other platforms have no credential-store + integration yet, so the token is written to `.tokens` in the Rho config + directory (`~/.config/rho/.tokens` by default, or `$RHO_CONFIG_DIR/.tokens`) + as plaintext with mode `0600`. `rho cloud login`/`connect` print a warning + when that happens and `rho cloud status` shows where the token lives. +- **Manual connection.** + `rho cloud connect --device-id --project-id --token-stdin < token.txt` + saves an existing device; without `--token-stdin` it prompts for the token + without echo. `--token ` still works but is + deprecated because it exposes the token in shell history and `ps`. + ### Multi-Agent Mission Mode (optional) For larger tasks, decompose work into parallel feature branches (power-user / future team workflows): From bf558d731bc3f6408e5e643031cac389de0023f9 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:15:43 +0530 Subject: [PATCH 16/16] 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). Co-Authored-By: Claude Opus 5.5 --- internal/platform/cloud/client.go | 8 +++++++- internal/platform/cloud/client_test.go | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/internal/platform/cloud/client.go b/internal/platform/cloud/client.go index 51d6e7fb..808321bb 100644 --- a/internal/platform/cloud/client.go +++ b/internal/platform/cloud/client.go @@ -199,12 +199,18 @@ type Client struct { // request body to another origin or downgrade the connection to plain HTTP. var errRedirect = errors.New("GrayCode Cloud responded with a redirect; refusing to follow it") +// DefaultRequestTimeout bounds one GrayCode Cloud request when Config has no +// HTTPClient. It is sized for explicit commands (a 1 MiB graph upload or a +// first TLS handshake on a slow link); the automatic usage upload passes its +// own shorter context deadline, so it stays bounded regardless. +const DefaultRequestTimeout = 15 * time.Second + func New(cfg Config) *Client { var client http.Client if cfg.HTTPClient != nil { client = *cfg.HTTPClient } else { - client.Timeout = 3 * time.Second + client.Timeout = DefaultRequestTimeout } client.CheckRedirect = func(*http.Request, []*http.Request) error { return errRedirect } c := &Client{token: cfg.DeviceToken, http: &client} diff --git a/internal/platform/cloud/client_test.go b/internal/platform/cloud/client_test.go index fc86f0fe..b7a642c2 100644 --- a/internal/platform/cloud/client_test.go +++ b/internal/platform/cloud/client_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" ) func TestRecordUsageSendsAggregateEvent(t *testing.T) { @@ -74,3 +75,14 @@ func TestSendDeliveryContextIncludesCIRunAndDeployment(t *testing.T) { t.Fatalf("deployment = %+v", body.Deployment) } } + +func TestNewUsesDefaultRequestTimeout(t *testing.T) { + if got := New(Config{Endpoint: DefaultEndpoint}).http.Timeout; got != DefaultRequestTimeout { + t.Fatalf("timeout = %v, want %v", got, DefaultRequestTimeout) + } + custom := &http.Client{Timeout: time.Second} + client := New(Config{Endpoint: DefaultEndpoint, HTTPClient: custom}) + if client.http.Timeout != time.Second || client.http == custom || custom.CheckRedirect != nil { + t.Fatal("a caller-supplied HTTP client must be copied, not mutated") + } +}