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): diff --git a/cmd/cloud.go b/cmd/cloud.go index 0b09bbd3..24ec543c 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -2,189 +2,343 @@ package cmd 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" ) -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 - }, +// 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 + ")" + +// loadCloudClient loads the saved GrayCode Cloud connection; tests replace it. +var loadCloudClient = cloud.LoadClient + +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 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: + + 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) + if err != nil { + return err + } + deviceID, _ := cmd.Flags().GetString("device-id") + projectID, _ := cmd.Flags().GetString("project-id") + 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 := saveCloudDevice(cloud.DeviceConfig{Endpoint: endpoint, DeviceID: deviceID, ProjectID: projectID}, token); err != nil { + return err + } + warnIfPlaintextTokenStore(cmd) + 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", "", "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") + 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) +// maxDeviceTokenInput bounds how much is read for a device token. +const maxDeviceTokenInput = 4 << 10 + +// Credential seams; tests replace them. +var ( + 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 +// 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 err + return "", fmt.Errorf("read device token from stdin: %w", 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)) + if len(data) > maxDeviceTokenInput { + return "", errors.New("device token on stdin is too long") } - interval := time.Duration(start.Interval) * time.Second - if interval < time.Second { - interval = 5 * time.Second + 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) } - 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): + 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", + 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. + +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 { + 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() + } + 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 + } + 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(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 || interval > 30*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 } - 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 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): + } + continue } - if err := cloud.SaveDeviceConfig(cloud.DeviceConfig{Endpoint: endpoint, DeviceID: poll.DeviceID, ProjectID: poll.ProjectID}, poll.Token); err != nil { + // PollDeviceLogin reports expired, consumed, unknown and + // incomplete states as errors, so this is a complete approval. + 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() - cmd.Println(auditTint("Rho Cloud connected for project ", doneGreen) + auditTint(poll.ProjectID, textPrimary) + auditTint(".", doneGreen)) + warnIfPlaintextTokenStore(cmd) + cmd.Println(auditTint("GrayCode 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", "", cloudEndpointFlagHelp) + 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 GrayCode Cloud connection status", + RunE: func(cmd *cobra.Command, _ []string) error { + 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 + } + 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("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 - } - 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 GrayCode Cloud", + RunE: func(cmd *cobra.Command, _ []string) error { + client, cfg, err := loadCloudClient() + if err != nil { + return err } - 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 err := checkFlagChoice("--ci-status", ciStatus, cloud.CIRunStatuses); err != nil { + return err } - 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 := checkFlagChoice("--deployment-status", deploymentStatus, cloud.DeploymentStatuses); err != nil { + return err + } + 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" + } + 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} + } + if err := client.SendDeliveryContext(cmd.Context(), event); err != nil { + return err + } + cmd.Println(auditTint("Repository context synced to GrayCode 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: "+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: "+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 { @@ -197,23 +351,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) } diff --git a/cmd/cloud_graph.go b/cmd/cloud_graph.go index 8be34a96..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 @@ -51,13 +51,13 @@ 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 { - 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/cloud_test.go b/cmd/cloud_test.go new file mode 100644 index 00000000..cdc503a4 --- /dev/null +++ b/cmd/cloud_test.go @@ -0,0 +1,346 @@ +package cmd + +import ( + "bytes" + "context" + "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" +) + +// 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) + } +} + +// 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()) + } +} + +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) + } +} + +// 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) + } +} + +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/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 85455875..6aa32533 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -370,14 +370,16 @@ 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 client.RecordUsage(context.Background(), cloud.UsageEvent{ + // 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: "rho", + Capability: cloud.CapabilityRho, Model: effectiveModel, InputTokens: totalIn, OutputTokens: totalOut, @@ -385,8 +387,9 @@ func runExec(_ *cobra.Command, args []string) error { 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 { 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/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/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/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 84d1f2d6..808321bb 100644 --- a/internal/platform/cloud/client.go +++ b/internal/platform/cloud/client.go @@ -1,14 +1,20 @@ -// 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 ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/http" + "net/url" "strings" "time" + "unicode" + "unicode/utf16" ) type Config struct { @@ -69,6 +75,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"` @@ -77,6 +100,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"` @@ -85,41 +125,159 @@ 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 - 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") + +// 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 { - 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 = DefaultRequestTimeout } - return &Client{endpoint: strings.TrimRight(cfg.Endpoint, "/"), token: cfg.DeviceToken, http: client} + 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.token != "" } +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 + } + if c.endpoint == "" { + return fmt.Errorf("GrayCode Cloud endpoint is not configured") + } + return nil +} -func (c *Client) startDeviceLogin(ctx context.Context, label, platform, rhoVersion string) (DeviceLoginStart, error) { +// 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, 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 } - 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 } 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) + 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) { @@ -128,74 +286,56 @@ 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 } 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 } - return result, json.NewDecoder(resp.Body).Decode(&result) -} - -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 := decodeResponse("device login poll", resp, &result); err != nil { + return result, 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") + switch result.Status { + 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 + case DeviceLoginConsumed: + return result, ErrDeviceLoginConsumed + default: + return result, fmt.Errorf("GrayCode Cloud returned an unknown device login status %q", sanitizeServerText(result.Status)) } - 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 := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/v1/usage", bytes.NewReader(body)) - 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() +// 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, version) } -// 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 := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/v1/delivery-context", bytes.NewReader(body)) - 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() +func (c *Client) PollDeviceLogin(ctx context.Context, deviceCode string) (DeviceLoginPoll, error) { + if err := c.checkEndpoint(); err != nil { + return DeviceLoginPoll{}, err } + return c.pollDeviceLogin(ctx, deviceCode) } diff --git a/internal/platform/cloud/client_test.go b/internal/platform/cloud/client_test.go index 9c1a22cc..b7a642c2 100644 --- a/internal/platform/cloud/client_test.go +++ b/internal/platform/cloud/client_test.go @@ -3,9 +3,11 @@ package cloud import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" + "time" ) func TestRecordUsageSendsAggregateEvent(t *testing.T) { @@ -19,17 +21,21 @@ 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 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 +44,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 +65,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) } @@ -65,3 +75,14 @@ func TestRecordDeliveryContextIncludesCIRunAndDeployment(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") + } +} diff --git a/internal/platform/cloud/config.go b/internal/platform/cloud/config.go index f90a3561..6748d532 100644 --- a/internal/platform/cloud/config.go +++ b/internal/platform/cloud/config.go @@ -2,8 +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" @@ -14,6 +17,27 @@ 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) } + +// 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"` @@ -32,7 +56,12 @@ func LoadDeviceConfig() (DeviceConfig, error) { } func SaveDeviceConfig(cfg DeviceConfig, token string) error { - if err := auth.NewSecureStorage(tokenService).Set(tokenAccount, token); err != nil { + endpoint, err := NormalizeEndpoint(cfg.Endpoint) + if err != nil { + return err + } + cfg.Endpoint = endpoint + if err := newTokenStore().Set(tokenAccount, token); err != nil { return err } b, err := json.Marshal(cfg) @@ -45,14 +74,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 _, 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/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/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 +} diff --git a/internal/platform/cloud/delivery.go b/internal/platform/cloud/delivery.go new file mode 100644 index 00000000..8c257d78 --- /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 ErrNotConnected + } + 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) + } + } +} 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 new file mode 100644 index 00000000..5b1b0708 --- /dev/null +++ b/internal/platform/cloud/endpoint.go @@ -0,0 +1,89 @@ +package cloud + +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 +// 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 "", 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") + } + 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 nil + } + return fmt.Errorf("%w: got http://%s", ErrInsecureEndpoint, u.Host) + default: + return fmt.Errorf("%w: got scheme %q", ErrInsecureEndpoint, u.Scheme) + } +} + +// 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..1b6c30da --- /dev/null +++ b/internal/platform/cloud/endpoint_test.go @@ -0,0 +1,182 @@ +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() + 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") + } + 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) + } +} + +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) + } +} diff --git a/internal/platform/cloud/graph.go b/internal/platform/cloud/graph.go index 76eec330..1100db4b 100644 --- a/internal/platform/cloud/graph.go +++ b/internal/platform/cloud/graph.go @@ -7,22 +7,40 @@ import ( "encoding/hex" "encoding/json" "fmt" - "io" - "net/http" "regexp" - "strings" ) -const ( - MaxGraphSyncBodySize = 1 << 20 - maxGraphResponseSize = 64 << 10 -) +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 { @@ -46,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) @@ -74,7 +92,10 @@ 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 } for _, collection := range [][]any{nodes, edges} { @@ -111,11 +132,38 @@ 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 } +// 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 { @@ -133,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 } @@ -156,50 +213,37 @@ 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 { 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) - } - req, err := http.NewRequestWithContext( - ctx, - http.MethodPost, - c.endpoint+"/v1/graph/sync", - bytes.NewReader(body), - ) + 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 { 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) } 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") + return result, fmt.Errorf("GrayCode Cloud did not accept the graph sync") } return result, 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 } + ] +} 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) + } +} 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