From a5e11ec33984f07766608acd2f3b78becdecc85a Mon Sep 17 00:00:00 2001 From: Stuart Douglas Date: Wed, 14 Oct 2026 13:30:21 +1100 Subject: [PATCH 1/4] feat: add Blox workstation commands to bl --- Cargo.lock | 18 + Cargo.toml | 3 +- README.md | 90 +- crates/blox/Cargo.toml | 24 + crates/blox/README.md | 47 + crates/blox/build.rs | 27 + .../blox/orchestrator/v1/orchestrator.proto | 1640 +++++++++++++++ crates/blox/src/client.rs | 1070 ++++++++++ crates/blox/src/lib.rs | 92 + src/bl/blox.rs | 1862 +++++++++++++++++ src/bl/mod.rs | 1 + src/bl/runner.rs | 1 + src/bl/skills_api.rs | 4 +- src/lib.rs | 7 +- tests/blox_e2e.rs | 147 ++ 15 files changed, 5026 insertions(+), 7 deletions(-) create mode 100644 crates/blox/Cargo.toml create mode 100644 crates/blox/README.md create mode 100644 crates/blox/build.rs create mode 100644 crates/blox/protos/xyz/block/blox/orchestrator/v1/orchestrator.proto create mode 100644 crates/blox/src/client.rs create mode 100644 crates/blox/src/lib.rs create mode 100644 src/bl/blox.rs create mode 100644 tests/blox_e2e.rs diff --git a/Cargo.lock b/Cargo.lock index 4b8cb93..fbb6b7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -175,6 +175,23 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blox" +version = "0.1.0" +dependencies = [ + "anyhow", + "pbjson", + "pbjson-build", + "prost", + "protoc-bin-vendored", + "reqwest", + "serde", + "serde_json", + "tiny_http", + "tonic-prost-build", + "url", +] + [[package]] name = "builderlab-auth" version = "0.1.0" @@ -1733,6 +1750,7 @@ name = "sq-kgoose" version = "0.0.1-beta.1" dependencies = [ "anyhow", + "blox", "builderlab-auth", "clap", "clap_complete", diff --git a/Cargo.toml b/Cargo.toml index c7c2323..d0bc046 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/builderlab-auth"] +members = ["crates/blox", "crates/builderlab-auth"] resolver = "2" [package] @@ -20,6 +20,7 @@ path = "src/bin/bl.rs" [dependencies] anyhow = "1" +blox = { path = "crates/blox" } builderlab-auth = { path = "crates/builderlab-auth", features = ["blocking-client"] } clap = { version = "4.5", features = ["env"] } clap_complete = "4.5" diff --git a/README.md b/README.md index 7a0a51c..09b9f97 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # BuilderLab CLI `bl` is the command-line interface for BuilderLab. It provides authenticated -access to BuilderLab skills, agents, workspaces, and apps, along with the -`bl tools` command for discovering configured tool extensions. +access to BuilderLab skills, agents, workspaces, apps, and Blox workstations, +along with the `bl tools` command for discovering configured tool extensions. The CLI is written in Rust and is built from this repository. Run `bl --help` to see the current command surface: @@ -11,6 +11,7 @@ to see the current command surface: auth Manage BuilderLab marketplace authentication workspace Manage BuilderLab workspaces apps Manage apps through Apps Platform +blox Manage Blox workstations config Get and set bl preferences skills Manage BuilderLab skills agents Manage BuilderLab marketplace agents @@ -82,6 +83,89 @@ from KGoose for the workstation owner and injects it on the wire. Any other URL, including an explicit `--base-url` outside that origin, must be an approved HTTPS ingress and requires `bl auth login`. +## Blox workstations + +Use your existing `bl auth login` session to create and inspect Blox workstations: + +```bash +bl blox skus +bl blox create my-workstation --multiple 2 +bl blox list +bl blox get +bl blox list --include-deleted --limit 20 --offset 20 --json +bl blox exec my-workstation -- uname -a +bl blox suspend my-workstation +bl blox resume my-workstation +bl blox delete my-workstation +``` + +The Blox endpoint defaults to the deployment associated with your BuilderLab +login: Block staging, public staging, or public production. Block production +does not currently deploy Blox. Override the endpoint with +`bl blox --base-url list` or `BL_BLOX_CONTROL_PLANE_URL`. The CLI accepts +only the approved HTTPS ingress origins `blox.test.blockstaging.build`, +`blox.public.builderlabstaging.xyz`, and `blox.public.builderlab.xyz`. + +The CLI verifies the same stored session used by the other BuilderLab commands +and sends it as `Authorization: BBIdentity` to Blox ingress. No additional +login or token configuration is required. Redirects are disabled. + +`list` returns your workstations, omits connection details, and defaults to 100 +results. Use `--limit 0` for all results; a nonzero `--offset` requires a +nonzero limit. `get` forwards your selector to the orchestrator, which resolves +an active canonical name before a numeric ID. Display labels are not selectors; +use the full ID for archived workstations. JSON output retains the generated +protobuf response shape, including string-encoded 64-bit counts. Help and shell +completions work offline. + +`create [NAME]` always requests a new workstation. Its default SKU is +`DEFAULT_AMD64`, the SKU available in public BuilderLab staging; `--multiple` +defaults to 1 and must be positive. Use `bl blox skus` to see the codes and +maximum multiples available to your account, and override with `--sku CODE` +when needed. The deployment validates SKU availability and multiples. The CLI +accepts no CPU, memory, repository, or prompt options. It requests no project +repositories; the deployment may still bootstrap your configured home repository. + +Creation returns the initial workstation ID and status without waiting for +readiness. Inspect it with `bl blox get `. If the response is lost or cannot +be read, the CLI reports an unknown result and does not retry automatically. +Inspect `bl blox list` or `bl blox get ` before trying again to avoid +creating a second workstation. Preserve any `--base-url` override for these checks. + +`suspend`, `resume`, and `delete` accept a canonical workstation name or full ID. +The CLI resolves the target once and sends one transition request. It reports +the server's response without waiting for the final state; use `bl blox get ` +to inspect progress. Suspension can take several minutes while capturing a +snapshot, then resource teardown may continue after the response. A resume +response does not establish readiness, and deletion may continue asynchronously. + +Deletion archives the workstation. Resume after deletion depends on whether the +deployment retained a usable snapshot; use its full ID because archived names +are not resolved. If a transition result is uncertain, inspect that exact ID +using the same Blox endpoint before trying again. Lifecycle requests are never +retried automatically. + +`exec -- [ARG...]` runs an executable with literal +arguments, streams its stdout and stderr, and exits with the remote process's +exit status. Use `--workdir PATH` before `--` to choose a remote directory; +otherwise Blox selects the workstation's configured directory. Shell expansion +and pipelines require an explicit shell, for example: + +```bash +bl blox exec my-workstation -- sh -c 'printf hello; uname -a' +``` + +Execution is noninteractive: stdin and PTY allocation are unsupported. Commands +can run longer than 30 seconds. Interrupting the CLI or losing its connection +may leave the durable command running. The CLI never retries execution; if a +result is uncertain, inspect the command in Blox before starting another copy. + +With `--json`, execution prints JSONL events with `workstation_id`, `command_id`, +and `output` containing the generated protobuf stream response (its `data` bytes +are base64). A final event contains the process's `exit_code`. Local or service +failures retain the normal JSON error envelope on stderr; a remote nonzero exit +does not add an error envelope. + ## Local development Install the repository tools and git hooks once: @@ -124,6 +208,7 @@ The most useful configuration options are: | `BL_SKILLS_PROFILE` | Select a skills configuration profile. | | `BL_AUTH_STORAGE` | Select authentication storage, including `file` for local testing. | | `BL_AUTH_STORAGE_FILE` | Path used when file-backed auth storage is selected. | +| `BL_BLOX_CONTROL_PLANE_URL` | Override the approved Blox control-plane ingress. | | `KGOOSE_BASE_URL` | Override the backend base URL for local development. | | `BL_KGOOSE_PLAYPEN` | Route backend requests through a named development playpen. | @@ -143,6 +228,7 @@ development without modifying keyring state. - `src/bl/` contains the `bl` command implementations. - `crates/builderlab-auth/` contains browser login, session storage, workspace, and organization-routing support. +- `crates/blox/` contains generated orchestrator protocol types and its Connect client. - `src/lib.rs` wires the CLI runtime and test harness together. - `tests/bl_e2e.rs` contains the offline and mock-service end-to-end coverage. - `docker/acceptance/` contains the isolated skills acceptance harness. diff --git a/crates/blox/Cargo.toml b/crates/blox/Cargo.toml new file mode 100644 index 0000000..459c108 --- /dev/null +++ b/crates/blox/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "blox" +version = "0.1.0" +edition = "2021" +rust-version = "1.88.0" +license = "Apache-2.0" +publish = false + +[dependencies] +anyhow = "1" +pbjson = "0.9" +prost = "0.14" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +url = "2.5" + +[build-dependencies] +pbjson-build = "0.9" +protoc-bin-vendored = "3" +tonic-prost-build = "0.14" + +[dev-dependencies] +tiny_http = "0.12" diff --git a/crates/blox/README.md b/crates/blox/README.md new file mode 100644 index 0000000..8680db1 --- /dev/null +++ b/crates/blox/README.md @@ -0,0 +1,47 @@ +# Blox client + +This crate contains Blox orchestrator protocol messages and a synchronous +Connect JSON client. Its client methods list, retrieve, and create workstations, +and discover the caller's available compute SKUs. +Durable process execution uses `execute_process`, followed by +`stream_command_output` to deliver typed stdout/stderr messages and exit codes. +Lifecycle methods request suspension, resumption, and deletion once and return +the server's typed response. Callers must inspect lifecycle status and deletion +success; an accepted request does not imply that asynchronous teardown or startup +has finished. +Callers supply the endpoint and credentials; the crate does not perform login +or select a deployment. + +Unary requests time out after 30 seconds, except lifecycle operations, which +allow up to 12 minutes for snapshot creation and provider teardown. Responses +are limited to 2 MiB, and requests do not +follow redirects or retry requests. Error messages redact reflected session +credentials. Creation has no idempotency key: after a transport error or an +invalid response, inspect existing workstations before attempting another create. + +Output streaming uses Connect JSON envelopes with a 2 MiB per-envelope limit +and no overall time limit. It requires a successful protocol end-stream message; +transport EOF is an error. Stream callbacks can stop reading without stopping +the remote process. The caller must capture a process exit code before reporting +success, and must not relaunch execution after an uncertain result. + +For BuilderLab ingress, `with_session_credential` sends an existing shared +login session using `Authorization: BBIdentity `. The caller must +select an ingress configured to accept this authentication scheme. + +## Protocol source + +`protos/xyz/block/blox/orchestrator/v1/orchestrator.proto` is an unchanged copy +of `open/protos/xyz/block/blox/orchestrator/v1/orchestrator.proto` from the Blox +repository (`squareup/blox`) at revision +`57bc2e40b162b43e1d1b2d0b860bd67e0a3ce3e7`. + +To sync the protocol, replace that file with the complete source file from the +chosen Blox revision and update this revision note in the same change. Run +`cargo test -p blox` in the Hermit environment to regenerate the Rust messages +and check binary and protobuf JSON encoding. Generated files stay in Cargo's +build output. The `go_package` option is retained as source metadata. + +JSON decoding ignores new fields from newer servers. Unknown enum variants +decode to their unspecified default; refresh the copied protocol to recognize +new workstation statuses. Known enum values retain their protobuf names. diff --git a/crates/blox/build.rs b/crates/blox/build.rs new file mode 100644 index 0000000..4a81be6 --- /dev/null +++ b/crates/blox/build.rs @@ -0,0 +1,27 @@ +use std::path::PathBuf; + +fn main() -> Result<(), Box> { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=protos"); + + let protoc = protoc_bin_vendored::protoc_bin_path()?; + std::env::set_var("PROTOC", protoc); + + let proto_root = PathBuf::from("protos"); + let service_proto = proto_root.join("xyz/block/blox/orchestrator/v1/orchestrator.proto"); + let descriptor_path = PathBuf::from(std::env::var("OUT_DIR")?).join("proto_descriptor.bin"); + + tonic_prost_build::configure() + .build_client(false) + .build_server(false) + .file_descriptor_set_path(&descriptor_path) + .compile_protos(&[service_proto], &[proto_root])?; + + let descriptor_set = std::fs::read(descriptor_path)?; + pbjson_build::Builder::new() + .register_descriptors(&descriptor_set)? + .ignore_unknown_fields() + .ignore_unknown_enum_variants() + .build(&[".xyz.block.blox.orchestrator.v1"])?; + Ok(()) +} diff --git a/crates/blox/protos/xyz/block/blox/orchestrator/v1/orchestrator.proto b/crates/blox/protos/xyz/block/blox/orchestrator/v1/orchestrator.proto new file mode 100644 index 0000000..f7872bf --- /dev/null +++ b/crates/blox/protos/xyz/block/blox/orchestrator/v1/orchestrator.proto @@ -0,0 +1,1640 @@ +syntax = "proto3"; + +package xyz.block.blox.orchestrator.v1; + +option go_package = "github.com/block/blox/protos/xyz/block/blox/orchestrator/v1;orchestratorpb"; + +// Main orchestrator service +service OrchestratorService { + rpc Ping(PingRequest) returns (PingResponse); + // Ping a specific workstation's bloxlet to measure end-to-end latency. + rpc PingWorkstation(PingWorkstationRequest) returns (PingWorkstationResponse); + + // Workstation presetup (creates namespace in staging, no-op locally) + rpc PresetupWorkstation(PresetupWorkstationRequest) returns (PresetupWorkstationResponse); + + // Workstation lifecycle + rpc StartWorkstation(StartWorkstationRequest) returns (StartWorkstationResponse); + rpc DeleteWorkstation(DeleteWorkstationRequest) returns (DeleteWorkstationResponse); + rpc ListWorkstations(ListWorkstationsRequest) returns (ListWorkstationsResponse); + rpc GetWorkstation(GetWorkstationRequest) returns (GetWorkstationResponse); + rpc GetLatestDeletedWorkstation(GetLatestDeletedWorkstationRequest) returns (GetLatestDeletedWorkstationResponse); + // buf:lint:ignore RPC_REQUEST_RESPONSE_UNIQUE + rpc GetWorkstationStatus(GetWorkstationStatusRequest) returns (GetWorkstationStatusResponse); // buf:lint:ignore + // buf:lint:ignore RPC_REQUEST_RESPONSE_UNIQUE + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + rpc WatchWorkstationStatus(WatchWorkstationStatusRequest) returns (stream GetWorkstationStatusResponse); // buf:lint:ignore + rpc ShutdownWorkstation(ShutdownWorkstationRequest) returns (ShutdownWorkstationResponse); + rpc SuspendWorkstation(SuspendWorkstationRequest) returns (SuspendWorkstationResponse); + rpc ResumeWorkstation(ResumeWorkstationRequest) returns (ResumeWorkstationResponse); + rpc ApproveWorkstation(ApproveWorkstationRequest) returns (ApproveWorkstationResponse); + rpc UpdateWorkstationIdleTimeout(UpdateWorkstationIdleTimeoutRequest) returns (UpdateWorkstationIdleTimeoutResponse); + rpc ResizeWorkstationResources(ResizeWorkstationResourcesRequest) returns (ResizeWorkstationResourcesResponse); + rpc GetWorkstationResourceResize(GetWorkstationResourceResizeRequest) returns (GetWorkstationResourceResizeResponse); + rpc UpdateWorkstationDisplayName(UpdateWorkstationDisplayNameRequest) returns (UpdateWorkstationDisplayNameResponse); + // Pin or unpin a workstation at the top of its owner's sidebar. + rpc SetWorkstationPinned(SetWorkstationPinnedRequest) returns (SetWorkstationPinnedResponse); + // Switch the GitHub token identity of a service-account workstation. Only the + // GitHub credentials the workstation receives are affected; ownership and UI + // visibility are unchanged. Gated behind per-service-account config. The + // override does not survive a workstation restart. + rpc SwitchWorkstationIdentity(SwitchWorkstationIdentityRequest) returns (SwitchWorkstationIdentityResponse); + + // Process execution + rpc ExecuteProcess(ExecuteProcessRequest) returns (ExecuteProcessResponse); + rpc StreamCommandOutput(StreamCommandOutputRequest) returns (stream StreamCommandOutputResponse); + // ACP-aware replay of a recorded agent process. The orchestrator parses the + // recorded JSON-RPC stream and replays the frames that carry protocol state + // followed by the most recent prompt turns, instead of the whole recording. + // Replay only: follow live output via StreamCommandOutput from the final + // resume_id. + rpc StreamACPOutput(StreamACPOutputRequest) returns (stream StreamACPOutputResponse); + rpc SendProcessInput(SendProcessInputRequest) returns (SendProcessInputResponse); + rpc GetCommandOutput(GetCommandOutputRequest) returns (GetCommandOutputResponse); + rpc StopProcess(StopProcessRequest) returns (StopProcessResponse); + rpc ExecuteTransientProcess(ExecuteTransientProcessRequest) returns (ExecuteTransientProcessResponse); + + rpc GetWorkstationCommands(GetWorkstationCommandsRequest) returns (GetWorkstationCommandsResponse); + + // Update tags on an existing command. Sets or deletes arbitrary key:value pairs. + rpc UpdateCommandTags(UpdateCommandTagsRequest) returns (UpdateCommandTagsResponse); + + // File operations + rpc GetFileContents(GetFileContentsRequest) returns (GetFileContentsResponse); + rpc StreamFileContents(StreamFileContentsRequest) returns (stream StreamFileContentsResponse); + rpc PutFileContents(PutFileContentsRequest) returns (PutFileContentsResponse); + rpc ListFiles(ListFilesRequest) returns (ListFilesResponse); + + // Git operations + rpc GetGitDiff(GetGitDiffRequest) returns (GetGitDiffResponse); + rpc GetGitFileContents(GetGitFileContentsRequest) returns (GetGitFileContentsResponse); + rpc GetWorkstationRepoState(GetWorkstationRepoStateRequest) returns (GetWorkstationRepoStateResponse); + + // Run targets declared in each checked-out repo's .blox/workstation.yaml, + // read live from the working tree so mid-session edits are picked up. + rpc GetWorkstationRunTargets(GetWorkstationRunTargetsRequest) returns (GetWorkstationRunTargetsResponse); + + // Artifact operations + rpc GetCommandArtifacts(GetCommandArtifactsRequest) returns (GetCommandArtifactsResponse); + rpc GetWorkstationArtifacts(GetWorkstationArtifactsRequest) returns (GetWorkstationArtifactsResponse); + rpc DeleteArtifact(DeleteArtifactRequest) returns (DeleteArtifactResponse); + // Upload a file to attach to a prompt (stored in S3, materialized on the + // workstation before the prompt command runs — see + // ExecuteProcessRequest.attachment_artifact_ids). + rpc UploadPromptAttachment(UploadPromptAttachmentRequest) returns (UploadPromptAttachmentResponse); + + // Workspace name generation (deprecated: use AnalyzePrompt instead) + rpc GenerateWorkspaceName(GenerateWorkspaceNameRequest) returns (GenerateWorkspaceNameResponse); + + // Current authenticated user profile for UI display. + rpc GetCurrentUser(GetCurrentUserRequest) returns (GetCurrentUserResponse); + + // Preview whether creating a workstation now would be queued (over quota) + rpc GetWorkstationQuota(GetWorkstationQuotaRequest) returns (GetWorkstationQuotaResponse); + + // Analyze prompt to recommend agent and optionally generate workspace name + rpc AnalyzePrompt(AnalyzePromptRequest) returns (AnalyzePromptResponse); + + // List git repository suggestions for the current user based on their recent workstations + rpc ListRepoSuggestions(ListRepoSuggestionsRequest) returns (ListRepoSuggestionsResponse); + + // List the compute providers users may select when starting a workstation. + rpc ListComputeProviders(ListComputeProvidersRequest) returns (ListComputeProvidersResponse); + + // Terminal operations + rpc StartTerminal(StartTerminalRequest) returns (StartTerminalResponse); + rpc ListTerminals(ListTerminalsRequest) returns (ListTerminalsResponse); + // Unary RPCs for terminal I/O + rpc SendTerminalInput(SendTerminalInputRequest) returns (SendTerminalInputResponse); + rpc ResizeTerminal(ResizeTerminalRequest) returns (ResizeTerminalResponse); + rpc GetTerminalOutput(GetTerminalOutputRequest) returns (GetTerminalOutputResponse); + // Server-streaming RPC for real-time terminal output (lower latency than polling) + rpc StreamTerminalOutput(StreamTerminalOutputRequest) returns (stream StreamTerminalOutputResponse); + rpc CloseTerminal(CloseTerminalRequest) returns (CloseTerminalResponse); + rpc RenameTerminal(RenameTerminalRequest) returns (RenameTerminalResponse); +} + +// Internal pod-to-pod service for operations that should not be exposed on the +// public API surface. Registered only on the internal gRPC port. +service InternalOrchestratorService { + // RegisterPostStartClone records a git clone performed outside the bootstrap + // flow and triggers contract providers. Forwarded here by the originating pod + // when the bloxlet stream lives on a different orchestrator pod. + rpc RegisterPostStartClone(RegisterPostStartCloneRequest) returns (RegisterPostStartCloneResponse); + + // DeliverPendingBootstrap delivers a workstation's pending bootstrap commands + // over its bloxlet stream. Forwarded here by the pod handling a warm-pool + // claim when the bloxlet stream lives on a different orchestrator pod. + rpc DeliverPendingBootstrap(DeliverPendingBootstrapRequest) returns (DeliverPendingBootstrapResponse); + + // SnapshotWorkstation captures a workstation snapshot over the bloxlet + // stream and only returns once it has completed. Forwarded here by the pod + // handling a suspend when the bloxlet stream lives on a different + // orchestrator pod. + rpc SnapshotWorkstation(SnapshotWorkstationRequest) returns (SnapshotWorkstationResponse); +} + +// ============================================================================ +// Presetup Messages +// ============================================================================ + +message PresetupWorkstationRequest {} + +message PresetupWorkstationResponse { + string namespace = 1; + bool namespace_created = 2; +} + +// ============================================================================ +// Workstation Lifecycle Messages +// ============================================================================ + +message StartWorkstationRequest { + // Name of the workstation + optional string name = 1; + // The repositories to clone, either specified directly or via service name + repeated Project projects = 2; + + reserved 3; + reserved "resources"; + // Reserved: previously "prompt" (tag 5), which was added unintentionally and has been removed. + reserved 5; + reserved "prompt"; + + // Tags for webhook routing - events for this workstation will be sent to webhooks configured for these tags + repeated string tags = 6; + + // Optional idle timeout in minutes. If not set, defaults to the system-wide idle timeout (24 hours). + // The workstation will be automatically deleted after being idle for this duration. + optional int32 idle_timeout_minutes = 7; + + // Optional compute provider name (matching one returned by ListComputeProviders). + // If unset, the user's configured default compute provider is used. + optional string compute_provider = 8; + + // Optional CPU architecture for Kubernetes workstations ("amd64" or "arm64"). + // If unset, .blox/workstation.yaml may specify architecture; otherwise the + // compute provider's default architecture is used. + optional string architecture = 9; + + // Provider-specific workstation parameters. Valid keys, types, descriptions, + // and allowed values are returned by ListComputeProviders for the selected + // compute provider. Values are sent as strings and validated by the + // orchestrator against the provider's parameter metadata. + map workstation_parameters = 10; + + // Optional number of CPU cores to request. Overrides .blox/workstation.yaml + // cpu value. Clamped to the user's per-user max or the global maximum (92). + // When set, this value is used as the Kubernetes CPU limit. The built-in + // Kubernetes template requests half the limit; custom templates may differ. + optional int32 cpu = 11; + + // Optional memory in GiB to request. Overrides .blox/workstation.yaml + // memory value. Requests below the minimum supported (4) or above the + // global maximum (360) are rejected; values within range are clamped to + // the user's per-user max. When set, this value is used as both the + // Kubernetes memory request and limit, ensuring Karpenter provisions a + // node large enough to deliver the requested memory. + optional int32 memory_gb = 12; + + // Client that initiated creation. Used only for product analytics; it does + // not affect authorization or workstation behavior. + // Values are lowercase identifiers containing letters, digits, underscores, + // periods, or hyphens. Empty and invalid values are reported as "unknown". + string creation_client = 13; + + // Disable creation and restoration of workstation snapshots. This is useful + // for ephemeral workstations that must not persist state between boots. + // Requests are rejected when persistent home volumes are enabled. + bool disable_snapshots = 14; + + // Selects an entry from the deployment's compute SKU catalog. Required when + // the catalog has entries. + // The request must omit cpu and memory_gb. If compute_provider or architecture + // is present, its value must match the selected catalog entry. + ComputeSkuSelection compute_sku = 15; +} + +message ComputeSkuSelection { + // Must exactly match one case-sensitive code in the deployment's catalog. + string code = 1; + + // Unitless count of the entry's base resources. Valid values are 1 through + // the entry's configured max_multiple, inclusive. + int32 multiple = 2; +} + +message Project { + oneof project { + Repository repository = 1; + } + reserved 2; + reserved "service"; +} + +message Repository { + // Source URL. Examples: + // github.com/org/repo + // github.com/org/repo?ref=main + // github.com/org/repo//subdir?ref=v1.0.0&depth=1 + // git+https://github.com/org/repo.git?ref=feature-branch&depth=100 + // Query parameters: + // ref= - git reference to checkout + // depth= - shallow clone depth (0 or omitted for full clone) + // Subdir suffix: + // //path/to/subdir - subdirectory within the repo + string source = 1; + // If true, clone to a temporary directory and sync contents to the user's + // home directory, excluding paths like ".git". + bool sync_to_home = 2; + // Absolute checkout directory on the workstation, once the project has been + // cloned. Authoritative over any path derived from the source (the two can + // differ for subdir sources and post-start clones). Empty until checkout. + string resolved_dir = 3; +} + +message StartWorkstationResponse { + string workstation_id = 1; + WorkstationStatus status = 2; + string message = 3; +} + +message DeleteWorkstationRequest { + string workstation_id = 1; +} + +message DeleteWorkstationResponse { + string workstation_id = 1; + bool success = 2; + string message = 3; +} + +message GetWorkstationStatusRequest { + string workstation_id = 1; + // Request the running instance's image identity from the compute provider. + // An unavailable identity fails the request instead of returning stale data. + bool include_image_identity = 2; +} + +message WatchWorkstationStatusRequest { + string workstation_id = 1; +} + +message GetWorkstationStatusResponse { + string workstation_id = 1; + WorkstationStatus status = 2; + WorkstationInfo info = 3; + string message = 4; +} + +enum WorkstationStatus { + WORKSTATION_STATUS_UNSPECIFIED = 0; + WORKSTATION_STATUS_CREATING = 1; + WORKSTATION_STATUS_STARTING = 2; + WORKSTATION_STATUS_RUNNING = 3; + WORKSTATION_STATUS_ERROR = 4; + WORKSTATION_STATUS_SHUTTING_DOWN = 5; + WORKSTATION_STATUS_SUSPENDED = 6; + WORKSTATION_STATUS_DELETED = 7; + WORKSTATION_STATUS_DEGRADED = 8; + WORKSTATION_STATUS_PENDING = 9; + WORKSTATION_STATUS_QUEUED = 10; + // Transient teardown states: provider resources (and the shutdown snapshot) + // are still draining. Resume is blocked until they settle to the terminal + // SUSPENDED / DELETED state. These replace the former shutting_down boolean. + WORKSTATION_STATUS_SUSPENDING = 11; + WORKSTATION_STATUS_DELETING = 12; +} + +message WorkstationInfo { + string created_at = 1; + string ready_at = 2; + repeated ProcessInfo running_processes = 4; + // The running workspace container's image ID and pod UID, when requested. + string image_id = 5; + string instance_id = 6; +} + +message ShutdownWorkstationRequest { + string workstation_id = 1; +} + +message ShutdownWorkstationResponse { + string workstation_id = 1; + bool success = 2; + string message = 3; +} + +message ResumeWorkstationRequest { + string workstation_id = 1; +} + +message ResumeWorkstationResponse { + string workstation_id = 1; + WorkstationStatus status = 2; + string message = 3; +} + +message SuspendWorkstationRequest { + string workstation_id = 1; +} + +message SuspendWorkstationResponse { + string workstation_id = 1; + WorkstationStatus status = 2; + string message = 3; +} + +message ApproveWorkstationRequest { + string workstation_id = 1; +} + +message ApproveWorkstationResponse { + string workstation_id = 1; + WorkstationStatus status = 2; + string message = 3; +} + +message UpdateWorkstationIdleTimeoutRequest { + string workstation_id = 1; + // Idle timeout in minutes. Set to 0 to revert to the system default. + int32 idle_timeout_minutes = 2; +} + +message UpdateWorkstationIdleTimeoutResponse { + string workstation_id = 1; + // The idle timeout that was set, in minutes. 0 means system default. + int32 idle_timeout_minutes = 2; +} + +message ResizeWorkstationResourcesRequest { + string workstation_id = 1; + // CPU cores for the workspace container. Omit to leave CPU unchanged. + optional int32 cpu = 2; + // Memory in whole GiB for the workspace container. Omit to leave memory unchanged. + optional int32 memory_gb = 3; +} + +message GetWorkstationResourceResizeRequest { + string workstation_id = 1; +} + +enum WorkstationResourceResizeStatus { + WORKSTATION_RESOURCE_RESIZE_STATUS_UNSPECIFIED = 0; + WORKSTATION_RESOURCE_RESIZE_STATUS_APPLIED = 1; + WORKSTATION_RESOURCE_RESIZE_STATUS_PENDING = 2; + WORKSTATION_RESOURCE_RESIZE_STATUS_NEEDS_REPROVISION = 3; + WORKSTATION_RESOURCE_RESIZE_STATUS_FAILED = 4; +} + +message ResizeWorkstationResourcesResponse { + string workstation_id = 1; + int32 desired_cpu = 2; + int32 desired_memory_gb = 3; + WorkstationResourceResizeStatus status = 4; + string message = 5; + int32 applied_cpu = 6; + int32 applied_memory_gb = 7; +} + +message GetWorkstationResourceResizeResponse { + string workstation_id = 1; + int32 desired_cpu = 2; + int32 desired_memory_gb = 3; + WorkstationResourceResizeStatus status = 4; + string message = 5; + int32 applied_cpu = 6; + int32 applied_memory_gb = 7; +} + +message UpdateWorkstationDisplayNameRequest { + string workstation_id = 1; + // Human-readable display label. Unset or empty clears the override. + optional string display_name = 2; +} + +message UpdateWorkstationDisplayNameResponse { + string workstation_id = 1; + optional string display_name = 2; +} + +message SetWorkstationPinnedRequest { + string workstation_id = 1; + bool pinned = 2; +} + +message SetWorkstationPinnedResponse { + string workstation_id = 1; + // RFC3339; empty when unpinned. + string pinned_at = 2; +} + +message UpdateCommandTagsRequest { + string workstation_id = 1; + string command_id = 2; + // Tags to set. An empty value deletes the tag. + map tags = 3; +} + +message UpdateCommandTagsResponse { + string command_id = 1; + // The full set of tags on the command after the update. + map tags = 2; +} + +message SwitchWorkstationIdentityRequest { + string workstation_id = 1; + // Username whose GitHub credentials the workstation should use for pushes. + // Unset or empty resets the workstation to its owner's own identity. + optional string identity = 2; +} + +message SwitchWorkstationIdentityResponse { + string workstation_id = 1; + // The effective GitHub identity after the switch (the owner when reset). + string effective_identity = 2; + string message = 3; +} + +message ListWorkstationsRequest { + // Include deleted workstations in the response + optional bool include_deleted = 1; + // Maximum number of workstations to return. 0 means no limit. + int32 page_limit = 2; + // Number of workstations to skip for pagination. + int32 page_offset = 3; + // Omit connection URIs and SSH configuration. This avoids resolving + // provider access details when callers only need workstation metadata. + bool skip_access_details = 4; +} + +message ListWorkstationsResponse { + repeated WorkstationSummary workstations = 1; + // Total number of workstations matching the query (for pagination). + int64 total_count = 2; + // Number of recently deleted workstations (last 14 days). Always populated + // so the UI can show "Show N archived" without a separate query. + int64 recently_deleted_count = 3; +} + +message GetWorkstationRequest { + // Workstation name or ID + string workstation = 1; +} + +message GetWorkstationResponse { + WorkstationSummary workstation = 1; +} + +message GetLatestDeletedWorkstationRequest { + string name = 1; +} + +message GetLatestDeletedWorkstationResponse { + WorkstationSummary workstation = 1; +} + +message WorkstationURI { + // Type of URI (e.g., "code-server", "jupyter", "rstudio") + string type = 1; + // The actual URI + string uri = 2; +} + +message WorkstationSSHConfigOption { + // OpenSSH config option key, e.g. "StrictHostKeyChecking". + string key = 1; + // OpenSSH config option value, e.g. "no". + string value = 2; +} + +message WorkstationSSHConfig { + // Host alias to pass to ssh and write in the generated Host block. + string host = 1; + // SSH username for the workstation. + string user = 2; + // Direct SSH target hostname or IP. Empty when access is purely via ProxyCommand. + string hostname = 3; + // Direct SSH target port. Zero means omit the Port directive. + int32 port = 4; + // ProxyCommand argv. The CLI shell-quotes these when writing OpenSSH config. + repeated string proxy_command = 5; + // Additional OpenSSH config options. + repeated WorkstationSSHConfigOption options = 6; + // The workstation is reachable only through the orchestrator's SSH relay. + // When set (and proxy_command is empty), the CLI supplies its own + // `ws ssh-proxy --tunnel` invocation as the ProxyCommand. + bool tunnel = 7; +} + +message PullRequest { + // Pull request number + int32 number = 1; + // Pull request title + string title = 2; + // Pull request URL + string url = 3; + // Pull request state (e.g., "open", "closed", "merged") + string state = 4; + // Repository name (e.g., "owner/repo") + string repository = 5; +} + +message WorkstationSummary { + string workstation_id = 1; + string name = 2; + WorkstationStatus status = 3; + string created_at = 4; + string message = 5; + int64 running_commands_count = 6; + repeated WorkstationURI uris = 7; + string last_used_at = 8; + repeated Project projects = 9; + repeated PullRequest pull_requests = 10; + // Compute provider type (e.g., "kube") + string provider = 11; + // Effective idle timeout in minutes — either the per-workstation override or the system default. + // Absent only when the orchestrator has no configured default. + optional int32 idle_timeout_minutes = 12; + // Whether a snapshot artifact exists for this workstation + bool has_snapshot = 13; + // Registered preview link ports + repeated PreviewLink preview_links = 14; + // Human-readable display label. Absent means clients should show name. + optional string display_name = 15; + // Agent activity derived from commands tagged blox:role=acp or blox:role=prompt. + AgentActivityStatus agent_activity_status = 16; + // SSH access configuration for the workstation, if SSH is available. + WorkstationSSHConfig ssh_config = 17; + // Number of EXECUTE_PROCESS commands that are neither bootstrap commands nor + // agent chat runs (blox:role=acp/prompt) — i.e. process runs started via the + // orchestrator API or CLI outside a chat session. + int32 api_run_count = 18; + // RFC3339 timestamp of the most recent command creation or status change on + // this workstation; empty when it has no commands. A cheap change marker: + // clients that already poll the summary can invalidate cached command + // history when it moves instead of polling the command list themselves. + string last_command_change = 19; + // True when the workstation runs in a per-workstation VM that must boot + // before bloxlet can execute commands (EC2 Tart placements). + bool vm_placement = 20; + // Latest estimated agent usage reported by the workstation, when available. + AgentCostUsageSummary agent_cost_usage = 21; + // Tags set at creation (StartWorkstationRequest.tags), sorted. Lets API + // clients find the workstations they created, e.g. by a session identifier. + repeated string tags = 22; + string user_name = 23; + string updated_at = 24; + string provider_id = 25; + string memory = 26; + int32 cpu = 27; + optional bool kvm = 28; + string image = 29; + string sidecar_memory = 30; + string first_started_at = 31; + string provisioning_started_at = 32; + string connected_orchestrator_address = 33; + // RFC3339; empty when not pinned to the top of the owner's sidebar. + string pinned_at = 34; + // Saved selection and resolved shape at creation, independent of today's catalog. + SavedComputeSku compute_sku = 35; + // Uploaded command_result artifacts; excludes snapshots and prompt attachments. + int32 artifact_count = 36; + // RFC3339 time of the newest change to those artifacts; empty when none. + string last_artifact_change = 37; + // Host-side VM base image the workstation's VM was cloned from (EC2 VM + // placements); empty when not recorded. Unrelated to `image`, the requested + // workspace image alias. + string base_image = 38; + // True when no online host still serves base_image, so the next VM for this + // workstation (e.g. after suspend and resume) boots a newer image. + bool base_image_outdated = 39; +} + +message SavedComputeSku { + string code = 1; + int32 multiple = 2; + int32 cpu = 3; + int32 memory_gb = 4; + string architecture = 5; + string compute_provider = 6; +} + +// Latest cumulative agent usage for display in the workstation UI. The cost is +// an estimate based on the pricing snapshot, not invoiced spend. +message AgentCostUsageSummary { + double total_cost_usd = 1; + int64 input_tokens = 2; + int64 output_tokens = 3; + int64 cache_creation_tokens = 4; + int64 cache_read_tokens = 5; + string updated_at = 6; + // False while one or more reported models are waiting for pricing. + bool pricing_complete = 7; +} + +enum AgentActivityStatus { + AGENT_ACTIVITY_STATUS_UNSPECIFIED = 0; + AGENT_ACTIVITY_STATUS_IDLE = 1; + AGENT_ACTIVITY_STATUS_WORKING = 2; +} + +message PreviewLink { + // Port number for the preview service + int32 port = 1; + // Full preview URI for this port + string uri = 2; +} + +// ============================================================================ +// Process Execution Messages +// ============================================================================ + +message ExecuteProcessRequest { + string workstation_id = 1; + + // Command to execute + string command = 2; + repeated string args = 3; + + // Working directory + string working_dir = 4; + + // Environment variables + map env_vars = 5; + + // If true, bloxlet will buffer output and only emit complete JSON objects as chunks + // (one object per chunk, like JSONL lines or JSON-RPC invocations) + // If the output is malformed, it will be sent as-is + bool json_output = 6; + + // If this is true then we can stream stdin into the process + bool stream_stdin = 7; + + // Command IDs that must complete before this command starts + repeated string dependencies = 8; + + // If true, command.output webhook events will be sent for this command. + // Only one command.output event will be queued at a time per command. + bool notify_hooks_on_output = 9; + + // Result files to collect after process completes + repeated ResultSpec results = 10; + + // Arbitrary key:value tags to associate with the command (e.g. "blox:role=prompt") + map tags = 11; + + // When true, the workstation owner receives a Slack DM when this command finishes. + bool notification_required = 12; + + // Artifact IDs of previously uploaded prompt attachments (see + // UploadPromptAttachment). The bloxlet downloads them onto the workstation + // before the process starts; the process fails if any download fails. + repeated string attachment_artifact_ids = 13; + + // When set on an ACP agent process (stream_stdin + json_output), the bloxlet + // bootstraps the session itself: it performs the ACP handshake and sends the + // initial prompt, so the prompt is delivered even if the launching client + // disconnects before the process starts. + ACPBootstrap acp_bootstrap = 14; + // Optional precondition from a prior image identity read. Both values are + // required together; the command fails if the running instance changed. + string expected_image_id = 15; + string expected_instance_id = 16; +} + +// Server-side ACP session bootstrap (see ExecuteProcessRequest.acp_bootstrap). +message ACPBootstrap { + // Initial user prompt, sent as the first session/prompt turn. + string initial_prompt = 1; + // Session mode id granting unrestricted permissions, applied via + // session/set_mode after the session is created (e.g. "bypassPermissions"). + string unrestricted_mode_id = 2; + // For agents that expose permission bypass as a session config option + // instead of a mode: applied via session/set_config_option. + string unrestricted_config_id = 3; + string unrestricted_config_value = 4; + // Requested model, applied best-effort through the session's model config + // option (covers agents whose model can't be pinned via launch env vars). + string model = 5; +} + +message UploadPromptAttachmentRequest { + string workstation_id = 1; + // Base filename of the attachment (no directory components). + string filename = 2; + // MIME type of the content, e.g. "image/png". + string mime_type = 3; + // Raw file content. + bytes content = 4; +} + +message UploadPromptAttachmentResponse { + // Artifact ID to pass in ExecuteProcessRequest.attachment_artifact_ids. + string artifact_id = 1; + // Home-relative path the attachment will have on the workstation + // (e.g. ".blox/attachments/42-screenshot.png"). + string workstation_path = 2; +} + +message ExecuteTransientProcessRequest { + string workstation_id = 1; + + // Command to execute + string command = 2; + repeated string args = 3; + + // Working directory + string working_dir = 4; + + // Environment variables + map env_vars = 5; + + bytes stdin = 6; + + // Optional identity precondition for a process that must run on this image and pod. + // Set both fields together after reading GetWorkstationStatus with include_image_identity. + string expected_image_id = 7; + string expected_instance_id = 8; +} + +message ExecuteTransientProcessResponse { + int32 exit_code = 1; + string stdout = 2; + string stderr = 3; +} +message ExecuteProcessResponse { + string command_id = 1; +} + +message ProcessInfo { + string process_id = 1; + string command = 2; + optional int32 exit_code = 3; + string started_at = 4; +} + +message StreamCommandOutputRequest { + string workstation_id = 1; + string command_id = 2; + + // start from a specific offset + optional uint64 resume_id = 3; + + // follow mode (like tail -f) + bool follow = 4; + + reserved 5; + reserved "include_timestamps"; + + // When true, stdin data is included in the stream alongside stdout/stderr. + // Used by ACP clients to replay the user side of conversations. + bool include_input = 6; + + // When true, output entries are returned without timestamp-based merging, + // preserving accurate per-line timestamps. Used by the command output view. + bool preserve_timestamp = 7; + + // When true, the stream reports the current end of the recorded output + // instead of replaying it: the orchestrator walks the history in-cluster and + // returns a single empty response carrying the tail resume_id. A client + // reattaching to a long-lived process (`blox acp`) needs only that cursor, so + // this keeps reattach cost independent of how much output the process has + // already produced. Only honored when follow is false. + bool start_at_tail = 8; +} + +message StreamCommandOutputResponse { + enum StreamType { + // Used when there is just an exit code with no output. Sent as the final + // message of a stream whose command produced no output of its own (e.g. a + // process that failed before it spawned): data is empty and exit_code + // carries how the command ended. + STREAM_TYPE_UNSPECIFIED = 0; + STREAM_TYPE_STDOUT = 1; + STREAM_TYPE_STDERR = 2; + STREAM_TYPE_STDIN = 3; + } + + StreamType stream = 1; + bytes data = 2; + // The exit code, only set when the process has exited + optional int32 exit_code = 3; + // The id of this output chunk. Used for resuming streams + uint64 resume_id = 4; + // True when this stream is done + bool eof = 5; + // Timestamp of this chunk in nanoseconds since Unix epoch + int64 timestamp = 6; +} + +message StreamACPOutputRequest { + string workstation_id = 1; + string command_id = 2; + // Continuation cursor from an interrupted stream. When set, recorded output + // after it is replayed verbatim, with no state digest or turn windowing. + optional uint64 resume_id = 3; + // Most-recent prompt turns to replay. 0 replays the full transcript. + uint32 tail_turns = 4; +} + +// A response with no data and no resume_id is a keep-alive sent while the +// server analyzes the recording; clients should ignore it. +message StreamACPOutputResponse { + enum StreamType { + STREAM_TYPE_UNSPECIFIED = 0; + STREAM_TYPE_STDOUT = 1; + STREAM_TYPE_STDERR = 2; + STREAM_TYPE_STDIN = 3; + } + StreamType stream = 1; + // State-digest messages each carry one whole ACP frame (newline-terminated); + // transcript messages carry raw recorded output. + bytes data = 2; + // The exit code, only set when the process has exited + optional int32 exit_code = 3; + // Cursor for resuming. Absent on state-digest messages, which are re-derived + // on a fresh call rather than resumed. + optional uint64 resume_id = 4; + // True when this stream is done + bool eof = 5; + // Timestamp of this chunk in nanoseconds since Unix epoch + int64 timestamp = 6; + // True on the first transcript message when older turns were omitted. The + // state-digest messages before it carry the omitted range's protocol state. + bool history_truncated = 7; +} + +message SendProcessInputRequest { + string workstation_id = 1; + string command_id = 2; + optional bytes data = 3; + bool eof = 4; +} + +message SendProcessInputResponse {} + +message GetCommandOutputRequest { + string workstation_id = 1; + string command_id = 2; + reserved 3; + reserved "include_timestamps"; +} + +enum OutputStreamType { + OUTPUT_STREAM_TYPE_UNSPECIFIED = 0; + OUTPUT_STREAM_TYPE_STDOUT = 1; + OUTPUT_STREAM_TYPE_STDERR = 2; + OUTPUT_STREAM_TYPE_STDIN = 3; +} + +message OutputFragment { + OutputStreamType stream_type = 1; + string content = 2; + uint64 sequence = 3; + // Timestamp in nanoseconds since epoch (UTC) + int64 timestamp = 4; +} + +message GetCommandOutputResponse { + // Output fragments in order + repeated OutputFragment fragments = 1; + // Exit code if command completed + optional int32 exit_code = 2; + // Command status: "running" while the command is not finished, then + // "completed" or "failed". The terminal statuses mirror the command record + // returned by GetWorkstationCommands, so a poller can rely on this field + // alone even for commands that never produced output (e.g. a process that + // failed before it spawned). + string status = 3; + // Status message from the command record, set when the command failed + // without producing output (e.g. "working directory does not exist: /foo"). + optional string message = 4; +} + +message StopProcessRequest { + string workstation_id = 1; + string command_id = 2; + bool force = 3; +} + +message StopProcessResponse { + bool success = 1; + string message = 2; +} + +message GetWorkstationCommandsRequest { + string workstation_id = 1; + // Optional: limit the number of commands returned (default: 50) + optional int32 limit = 2; + // Optional: only return commands with specific status. The special value + // "active" returns running and idle commands that are not stopping. + optional string status = 3; +} + +enum CommandStatus { + COMMAND_STATUS_UNSPECIFIED = 0; + COMMAND_STATUS_PENDING = 1; + COMMAND_STATUS_RUNNING = 2; + COMMAND_STATUS_SUCCEEDED = 3; + COMMAND_STATUS_FAILED = 4; + COMMAND_STATUS_IDLE = 5; +} + +enum CommandType { + COMMAND_TYPE_UNSPECIFIED = 0; + COMMAND_TYPE_CHECKOUT = 1; + COMMAND_TYPE_EXECUTE_PROCESS = 2; + COMMAND_TYPE_PROJECT_BOOTSTRAP = 3; + COMMAND_TYPE_PROVISION_WORKSPACE = 4; + COMMAND_TYPE_RESTORE_SNAPSHOT = 5; + COMMAND_TYPE_EXECUTE_BOOTSTRAP = 6; +} + +message CommandSummary { + string command_id = 1; + CommandType command_type = 2; + CommandStatus status = 3; + string message = 4; + bool is_bootstrap = 5; + string created_at = 6; + string updated_at = 7; + // For execute_process commands, this contains the command name + optional string process_command = 8; + // For execute_process commands, this contains the command args + repeated string process_args = 9; + // Command IDs that must complete before this command starts + repeated string dependencies = 10; + // When the command actually started running (as opposed to created_at which is when it entered the system) + optional string started_at = 11; + // Tags associated with the command (e.g. "blox:role" = "prompt") + map tags = 12; + // Server-provided operation label for provisioning commands. + optional string display_name = 13; +} + +message GetWorkstationCommandsResponse { + repeated CommandSummary commands = 1; +} + +// ============================================================================ +// File Operations Messages +// ============================================================================ +message GetFileContentsRequest { + string workstation_id = 1; + string file_path = 2; +} + +message GetFileContentsResponse { + bytes content = 1; +} + +message StreamFileContentsRequest { + string workstation_id = 1; + string file_path = 2; +} + +message StreamFileContentsResponse { + bytes chunk = 1; +} + +message PutFileContentsRequest { + string workstation_id = 1; + string file_path = 2; + bytes content = 3; +} + +message PutFileContentsResponse {} + +message ListFilesRequest { + string workstation_id = 1; + // Directory path to list (defaults to home directory if empty) + string path = 2; + // If true, list files recursively + bool recursive = 3; + // If true, include git modification status for each file + bool include_git_status = 4; + // Maximum depth to recurse. 0 means unlimited depth. + int32 depth = 5; +} + +message ListFilesResponse { + repeated FileInfo files = 1; +} + +message FileInfo { + // File or directory name + string name = 1; + // Full path + string path = 2; + // File size in bytes + int64 size = 3; + // True if this is a directory + bool is_dir = 4; + // Modification time as Unix timestamp + int64 mod_time = 5; + // Git index (staging area) status + GitStatusCode git_index_status = 6; + // Git working tree status + GitStatusCode git_worktree_status = 7; +} + +// Git file status codes from git status --porcelain +enum GitStatusCode { + GIT_STATUS_CODE_UNSPECIFIED = 0; + GIT_STATUS_CODE_UNMODIFIED = 1; + GIT_STATUS_CODE_MODIFIED = 2; + GIT_STATUS_CODE_TYPE_CHANGED = 3; + GIT_STATUS_CODE_ADDED = 4; + GIT_STATUS_CODE_DELETED = 5; + GIT_STATUS_CODE_RENAMED = 6; + GIT_STATUS_CODE_COPIED = 7; + GIT_STATUS_CODE_UNMERGED = 8; + GIT_STATUS_CODE_UNTRACKED = 9; + GIT_STATUS_CODE_IGNORED = 10; +} + +// ============================================================================ +// Git Operations Messages +// ============================================================================ + +// Reads one diff side, resolving Git LFS pointers. Limited to 10 MiB. +message GetGitFileContentsRequest { + string workstation_id = 1; + string project_source = 2; + // Repository-relative path, including the original path for a renamed base file. + string file_path = 3; + // Commit to read. Empty reads the working tree, including uncommitted changes. + string revision = 4; +} + +message GetGitFileContentsResponse { + bytes content = 1; +} + +message GetGitDiffRequest { + string workstation_id = 1; + + // Diff from launch time (default) or specific commit + string base_commit = 2; + + // Optional: specific paths to diff + repeated string paths = 3; + + // Optional: source of the project to diff (e.g. "github.com/org/repo?ref=main"), + // matching a Project.repository.source on the workstation. When empty, the + // primary (first non-sync-to-home) project is diffed. + string project_source = 4; +} + +message GetGitDiffResponse { + string diff = 1; + string base_commit = 2; + string current_commit = 3; + repeated string changed_files = 4; + bool truncated = 5; + repeated GitDiffHunkContext hunk_contexts = 6; + + // Absolute checkout directory of the diffed project on the workstation. + // Diff paths are relative to this directory. + string project_dir = 7; +} + +message GitDiffHunkContext { + string file_path = 1; + int32 old_start = 2; + int32 old_lines = 3; + int32 new_start = 4; + int32 new_lines = 5; + string header_context = 6; + repeated string enclosing_elements = 7; +} + +message GetWorkstationRepoStateRequest { + string workstation_id = 1; +} + +message GetWorkstationRepoStateResponse { + repeated WorkstationRepoState repos = 1; +} + +message WorkstationRepoState { + string path = 1; + string repository = 2; + string remote_url = 3; + string branch = 4; + string head_sha = 5; + bool detached = 6; + int32 additions = 7; + int32 deletions = 8; + bool has_uncommitted_changes = 9; + string configured_source = 10; + string configured_ref = 11; + bool registered_project = 12; + string error = 13; + int32 untracked_files = 14; +} + +message GetWorkstationRunTargetsRequest { + string workstation_id = 1; +} + +message GetWorkstationRunTargetsResponse { + repeated RunTarget targets = 1; + // Human-readable notes about declarations that were skipped (invalid entry, + // duplicate name, unparseable config), for surfacing config mistakes. + repeated string warnings = 2; +} + +// A named command declared under `run:` in a repo's .blox/workstation.yaml. +message RunTarget { + string name = 1; + // Shell command line, executed with bash -lc. + string command = 2; + // Port the target serves on; 0 when the target has no preview. + int32 port = 3; + // Absolute working directory the command runs in. + string dir = 4; +} + +// ============================================================================ +// Workspace Name Generation Messages (Deprecated) +// ============================================================================ + +message GenerateWorkspaceNameRequest { + // The prompt/task description from the user + string prompt = 1; + // Optional: repo name for context + optional string repo_name = 2; +} + +message GenerateWorkspaceNameResponse { + // Generated workspace name (kebab-case, concise) + string name = 1; +} + +// ============================================================================ +// Prompt Analysis Messages +// ============================================================================ + +message AnalyzePromptRequest { + // The prompt/task description from the user + string prompt = 1; + // Optional: repo name for workspace name context + optional string repo_name = 2; + // Whether to generate a workspace name (default: true) + optional bool generate_workspace_name = 3; +} + +message AnalyzePromptResponse { + // Recommended agent: "opus", "sonnet", "haiku", or "codex" + string agent = 1; + // Brief explanation of why this agent was recommended + string agent_reason = 2; + // Generated workspace name (only if generate_workspace_name was true) + optional string workspace_name = 3; +} + +// ============================================================================ +// Git Repository Suggestions Messages +// ============================================================================ + +message ListRepoSuggestionsRequest { + // Unused — kept for proto wire compatibility. The backend returns the + // authenticated user's recently used repos regardless of this field. + optional string repo_prefix = 1; +} + +message ListRepoSuggestionsResponse { + // Repository sources the authenticated user has used recently, ordered by usage + // count descending. Capped at 10 entries drawn from workstations created in the + // last month. + repeated string repos = 1; +} + +// ============================================================================ +// Compute Provider Messages +// ============================================================================ + +message ListComputeProvidersRequest {} + +message ListComputeProvidersResponse { + // The compute providers users may select when starting a workstation. + repeated ComputeProvider providers = 1; + // The default compute provider name used when no provider is selected. + string default_provider = 2; + // True when all new starts require compute_sku, even if this user has no eligible SKUs. + bool compute_sku_required = 3; + repeated ComputeSku compute_skus = 4; +} + +message ComputeSku { + string code = 1; + string display_name = 2; + int32 cpu = 3; + int32 memory_gb = 4; + string architecture = 5; + string compute_provider = 6; + // Maximum multiple allowed for this user by both catalog and resource caps. + int32 max_multiple = 7; +} + +message ComputeProvider { + // The provider name (e.g. "kube"). Pass this in StartWorkstationRequest.compute_provider. + string name = 1; + // Human-readable label suitable for display in UIs. + string label = 2; + // Operating system of workstations created by this provider. Lets clients + // pick the right paths/conventions without string-matching on name. Names + // are operator-configurable (e.g. a kube provider may be named "kitt"), so + // clients should rely on this field rather than the name. + ComputeProviderOS os = 3; + // CPU architectures this provider can run when StartWorkstationRequest.architecture + // or .blox/workstation.yaml requests one. + repeated string supported_architectures = 4; + // Provider-specific workstation parameters users may set when starting a + // workstation on this provider. + repeated WorkstationParameter parameters = 5; +} + +enum ComputeProviderOS { + COMPUTE_PROVIDER_OS_UNSPECIFIED = 0; + COMPUTE_PROVIDER_OS_LINUX = 1; + COMPUTE_PROVIDER_OS_MACOS = 2; +} + +message WorkstationParameter { + // Stable parameter key. Pass this as a key in StartWorkstationRequest.workstation_parameters. + string name = 1; + // Parameter value type. Values are encoded as strings in StartWorkstationRequest. + WorkstationParameterType type = 2; + // Human-readable description suitable for display in UIs and CLI help. + string description = 3; + // Allowed string values. When empty, any value of the declared type is accepted. + repeated string allowed_values = 4; +} + +enum WorkstationParameterType { + WORKSTATION_PARAMETER_TYPE_UNSPECIFIED = 0; + WORKSTATION_PARAMETER_TYPE_STRING = 1; + WORKSTATION_PARAMETER_TYPE_INT = 2; + WORKSTATION_PARAMETER_TYPE_BOOL = 3; +} + +message PingRequest {} +message PingResponse { + // If present, the service is not ready to accept requests and this is the + // reason. + optional string not_ready = 1; +} + +message PingWorkstationRequest { + string workstation_id = 1; +} + +message PingWorkstationResponse { + // Round-trip time from orchestrator to workstation bloxlet, in milliseconds. + int64 rtt_ms = 1; +} + +// ============================================================================ +// User Onboarding Status Messages +// ============================================================================ + +message GetCurrentUserRequest {} + +message GetCurrentUserResponse { + reserved 3; + + // The authenticated Blox username from the trusted identity. + string user_name = 1; + // Human-readable display name when available; otherwise the username. + string display_name = 2; + // Whether this request has admin privileges. + bool is_admin = 4; +} + +// ============================================================================ +// Workstation Quota Messages +// ============================================================================ + +message GetWorkstationQuotaRequest { + // The provider the workstation would run on. Empty uses the user's default. + string compute_provider = 1; +} + +message GetWorkstationQuotaResponse { + // Whether creating a workstation now would be queued rather than started immediately. + bool would_queue = 1; + // Global per-user limit and current runnable usage (across all providers). + int32 limit = 2; + int32 used = 3; + // Whether the resolved provider is in the ec2 family; the ec2_* fields apply only then. + bool ec2 = 4; + // ec2-family sub-limit and current ec2-family runnable usage for the resolved provider. + int32 ec2_limit = 5; + int32 ec2_used = 6; + // Human-readable label of the resolved provider (e.g. "macOS"), for messaging. + string provider_label = 7; + // Per-user cap on queued workstations and the user's current queued count. + // StartWorkstation rejects with ResourceExhausted once queued_used reaches queued_limit. + int32 queued_limit = 8; + int32 queued_used = 9; + // True when a start now would be rejected (not queued) because the user is at + // their queued cap. The create UI uses this to warn before submit. + bool would_reject_queued_limit = 10; +} + +// ============================================================================ +// Terminal Messages +// ============================================================================ + +message StartTerminalRequest { + string workstation_id = 1; + // Shell to use (e.g., "/bin/bash", "/bin/zsh"). Defaults to user's default shell. + string shell = 2; + // Initial terminal dimensions + uint32 cols = 3; + uint32 rows = 4; + // Environment variables to set + map env_vars = 5; + // Working directory (optional, defaults to project directory or home) + string working_dir = 6; + // The session id of the resulting terminal + string session_id = 7; +} + +message StartTerminalResponse { + // Unique identifier for this terminal + string terminal_id = 1; +} + +message ListTerminalsRequest { + string workstation_id = 1; +} + +message ListTerminalsResponse { + repeated TerminalInfo terminals = 1; +} + +message TerminalInfo { + // Unique identifier for this terminal session + string terminal_id = 1; + // Shell being used + string shell = 2; + // When the session was created (Unix timestamp) + int64 created_at = 3; + // When the session was last active (Unix timestamp) + int64 last_activity = 4; + // User-assigned name for this terminal session + string name = 5; +} + +message RenameTerminalRequest { + string workstation_id = 1; + string terminal_id = 2; + string name = 3; +} + +message RenameTerminalResponse {} + +// Bidirectional streaming for terminal I/O +message StreamTerminalRequest { + string workstation_id = 1; + string terminal_id = 2; + oneof message { + // Keyboard input to send to the terminal + bytes input = 3; + // Resize the terminal + StreamTerminalResize resize = 4; + } +} + +message StreamTerminalResize { + uint32 cols = 1; + uint32 rows = 2; +} + +message StreamTerminalResponse { + string terminal_id = 1; + oneof message { + // Output from the terminal (includes ANSI codes) + bytes output = 2; + // Terminal session has closed + StreamTerminalClosed closed = 3; + } +} + +message StreamTerminalClosed { + // Exit code of the shell process + optional int32 exit_code = 1; +} + +message CloseTerminalRequest { + string workstation_id = 1; + string terminal_id = 2; +} + +message CloseTerminalResponse { + bool success = 1; +} + +// Unary RPC messages for terminal I/O (non-streaming) +message SendTerminalInputRequest { + string workstation_id = 1; + string terminal_id = 2; + bytes input = 3; +} + +message SendTerminalInputResponse {} + +message ResizeTerminalRequest { + string workstation_id = 1; + string terminal_id = 2; + uint32 cols = 3; + uint32 rows = 4; +} + +message ResizeTerminalResponse {} + +message GetTerminalOutputRequest { + string workstation_id = 1; + string terminal_id = 2; + // If true, block until new output is available (long polling) + bool wait_for_output = 4; + // Terminal session ID for tracking + string session_id = 5; +} + +message GetTerminalOutputResponse { + // Output chunks since last fetch + repeated bytes output = 1; + // Terminal status + bool is_closed = 3; + // Exit code if terminal closed + optional int32 exit_code = 4; +} + +// Server-streaming terminal output (lower latency than polling) +message StreamTerminalOutputRequest { + string workstation_id = 1; + string terminal_id = 2; + // Terminal session ID + string session_id = 4; +} + +message StreamTerminalOutputResponse { + // Output data from the terminal + bytes output = 1; + // True when terminal has closed + bool is_closed = 3; + // Exit code if terminal closed + optional int32 exit_code = 4; +} + +// ============================================================================ +// Artifact Messages +// ============================================================================ + +message ResultSpec { + // Human-readable name for this result + string name = 1; + // Path on the workstation to collect (file or directory) + string path = 2; + // If true, path is a directory and will be archived as .tar.gz + bool is_directory = 3; +} + +message GetCommandArtifactsRequest { + string workstation_id = 1; + string command_id = 2; +} + +message GetCommandArtifactsResponse { + repeated Artifact artifacts = 1; +} + +message Artifact { + string artifact_id = 1; + string name = 2; + // Pre-signed S3 GET URL. Empty if the result was not generated/uploaded. + optional string download_url = 3; + bool is_archive = 4; + bool uploaded = 5; + // Original filename on the workstation + optional string filename = 6; + // Size of the uploaded file in bytes + optional int64 size_bytes = 7; + // The type of artifact (e.g. "command_result", "workstation_snapshot") + string artifact_type = 8; + // ID of the command that produced this artifact, if any. + optional string command_id = 9; + // RFC3339 timestamp of when the artifact was created. + string created_at = 10; + // Compute provider the source workstation ran on (e.g. "kube", "ec2-macos"). + // Set for snapshots; empty for artifacts created before this was recorded. + optional string source_provider_name = 11; +} + +message GetWorkstationArtifactsRequest { + string workstation_id = 1; +} + +message GetWorkstationArtifactsResponse { + repeated Artifact artifacts = 1; +} + +message DeleteArtifactRequest { + string workstation_id = 1; + string artifact_id = 2; +} + +message DeleteArtifactResponse {} + +// ErrorReason qualifies a Connect error with a domain-level reason, beyond +// the generic Connect code. It lets clients distinguish backend-issued errors +// from HTTP errors produced by upstream proxies (Cloudflare Access, WARP, +// etc.) which connect-es maps to the same Connect code. +enum ErrorReason { + ERROR_REASON_UNSPECIFIED = 0; + + // The caller is authenticated but the workstation they requested is owned + // by a different user. Paired with Connect code PermissionDenied. + ERROR_REASON_WORKSTATION_OWNERSHIP_DENIED = 1; + + // The caller is authenticated but lacks admin privileges required for the + // requested operation. Paired with Connect code PermissionDenied. + ERROR_REASON_ADMIN_ACCESS_DENIED = 2; + + // The caller's GitHub OAuth connection is missing, expired, or revoked and + // must be re-established via onboarding/settings. Paired with Connect code + // FailedPrecondition. + ERROR_REASON_GITHUB_AUTH_REQUIRED = 3; + + // The caller's STS delegation is missing or expired and must be re-approved + // via settings. Paired with Connect code FailedPrecondition. + ERROR_REASON_STS_AUTH_REQUIRED = 4; + + // The caller has not completed Blox onboarding (user record or initial + // GitHub authentication missing) and must run the onboarding flow. Paired + // with Connect code FailedPrecondition. + ERROR_REASON_ONBOARDING_REQUIRED = 5; + + // Snapshot policy disables snapshots for this workstation. Paired with + // Connect code FailedPrecondition; shutdown snapshots may safely skip. + ERROR_REASON_SNAPSHOTS_DISABLED = 6; +} + +// BloxErrorInfo is attached as a Connect error detail to carry a typed reason +// alongside the generic Connect code. See ErrorReason. +message BloxErrorInfo { + ErrorReason reason = 1; +} + +// ============================================================================ +// Post-Start Clone Registration (pod-to-pod routing target) +// ============================================================================ + +message RegisterPostStartCloneRequest { + int64 workstation_id = 1; + string working_dir = 2; + string source = 3; + string git_commit = 4; + bool cachew_used = 5; + optional int32 requested_cpu = 6; + optional int32 requested_memory_gb = 7; +} + +enum PostStartCloneResizeStatus { + POST_START_CLONE_RESIZE_STATUS_UNSPECIFIED = 0; + POST_START_CLONE_RESIZE_STATUS_NOT_REQUESTED = 1; + POST_START_CLONE_RESIZE_STATUS_APPLIED = 2; + POST_START_CLONE_RESIZE_STATUS_PENDING = 3; + POST_START_CLONE_RESIZE_STATUS_NEEDS_REPROVISION = 4; + POST_START_CLONE_RESIZE_STATUS_FAILED = 5; +} + +message RegisterPostStartCloneResponse { + PostStartCloneResizeStatus resize_status = 1; + string resize_message = 2; + int32 desired_cpu = 3; + int32 desired_memory_gb = 4; + int32 applied_cpu = 5; + int32 applied_memory_gb = 6; +} + +message DeliverPendingBootstrapRequest { + int64 workstation_id = 1; +} + +message DeliverPendingBootstrapResponse {} + +message SnapshotWorkstationRequest { + int64 workstation_id = 1; + // Human-readable reason for the snapshot, used only for logging. + string reason = 2; + // Preserved when forwarding so timeout and callback reports share an alert group. + string attempt_id = 3; +} + +message SnapshotWorkstationResponse {} diff --git a/crates/blox/src/client.rs b/crates/blox/src/client.rs new file mode 100644 index 0000000..bc91365 --- /dev/null +++ b/crates/blox/src/client.rs @@ -0,0 +1,1070 @@ +use std::fmt; +use std::io::Read; +use std::time::Duration; + +use anyhow::{Context, Result}; +use reqwest::blocking::Client as HttpClient; +use reqwest::header::{HeaderMap, HeaderValue}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use url::Url; + +use crate::proto::{ + DeleteWorkstationRequest, DeleteWorkstationResponse, ExecuteProcessRequest, + ExecuteProcessResponse, GetWorkstationRequest, GetWorkstationResponse, + ListComputeProvidersRequest, ListComputeProvidersResponse, ListWorkstationsRequest, + ListWorkstationsResponse, ResumeWorkstationRequest, ResumeWorkstationResponse, + StartWorkstationRequest, StartWorkstationResponse, StreamCommandOutputRequest, + StreamCommandOutputResponse, SuspendWorkstationRequest, SuspendWorkstationResponse, +}; + +const MAX_RESPONSE_BYTES: u64 = 2 * 1024 * 1024; + +const SERVICE: &str = "xyz.block.blox.orchestrator.v1.OrchestratorService"; + +/// An orchestrator Connect unary client. Credentials are supplied by the caller. +pub struct Client { + base_url: Url, + http: HttpClient, + streaming_http: HttpClient, + headers: HeaderMap, +} + +/// A Connect RPC error, retaining the server's machine-readable code. +#[derive(Debug, Deserialize)] +pub struct RpcError { + pub code: String, + pub message: String, +} + +impl fmt::Display for RpcError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for RpcError {} + +#[derive(Deserialize)] +struct EndStream { + #[serde(default)] + error: Option, +} + +impl Client { + /// Use an existing BuilderLab session at an ingress that accepts BBIdentity. + pub fn with_session_credential(mut self, credential: &str) -> Result { + anyhow::ensure!( + !credential.is_empty(), + "BuilderLab session credential must not be empty" + ); + let mut value = HeaderValue::from_str(&format!("BBIdentity {credential}")) + .context("invalid BuilderLab session credential")?; + value.set_sensitive(true); + self.headers.insert("authorization", value); + Ok(self) + } + + /// Build a client for an explicit HTTP(S) orchestrator endpoint. + pub fn new(base_url: &str) -> Result { + let mut base_url = Url::parse(base_url).context("parse Blox orchestrator URL")?; + anyhow::ensure!( + matches!(base_url.scheme(), "http" | "https") + && base_url.host_str().is_some() + && base_url.username().is_empty() + && base_url.password().is_none() + && base_url.query().is_none() + && base_url.fragment().is_none(), + "Blox orchestrator URL must be an HTTP(S) URL without credentials, query, or fragment" + ); + let prefix = format!("{}/", base_url.path().trim_end_matches('/')); + base_url.set_path(&prefix); + + let mut headers = HeaderMap::new(); + headers.insert("connect-protocol-version", HeaderValue::from_static("1")); + let http = HttpClient::builder() + .timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .retry(reqwest::retry::never()) + .build() + .context("build Blox HTTP client")?; + let streaming_http = HttpClient::builder() + .timeout(None) + .connect_timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .retry(reqwest::retry::never()) + .build() + .context("build Blox streaming HTTP client")?; + Ok(Self { + base_url, + http, + streaming_http, + headers, + }) + } + + /// List the caller's workstations using the supplied filters. + pub fn list_workstations( + &self, + request: &ListWorkstationsRequest, + ) -> Result { + self.call("ListWorkstations", request) + } + + /// Retrieve a workstation by its ID or name. + pub fn get_workstation(&self, id_or_name: &str) -> Result { + self.call( + "GetWorkstation", + &GetWorkstationRequest { + workstation: id_or_name.to_owned(), + }, + ) + } + + /// Create a workstation once, without retrying an ambiguous result. + pub fn start_workstation( + &self, + request: &StartWorkstationRequest, + ) -> Result { + self.call("StartWorkstation", request) + } + + /// Discover compute SKUs available to the caller. + pub fn list_compute_providers(&self) -> Result { + self.call("ListComputeProviders", &ListComputeProvidersRequest {}) + } + + /// Launch one durable process. An ambiguous result must never be retried. + pub fn execute_process( + &self, + request: &ExecuteProcessRequest, + ) -> Result { + self.call("ExecuteProcess", request) + } + + /// Request suspension once, returning the current lifecycle status. + pub fn suspend_workstation( + &self, + request: &SuspendWorkstationRequest, + ) -> Result { + self.call_with_timeout("SuspendWorkstation", request, Duration::from_secs(12 * 60)) + } + + /// Request resumption once, returning the current lifecycle status. + pub fn resume_workstation( + &self, + request: &ResumeWorkstationRequest, + ) -> Result { + self.call_with_timeout("ResumeWorkstation", request, Duration::from_secs(12 * 60)) + } + + /// Request deletion once. The caller must inspect the response's success. + pub fn delete_workstation( + &self, + request: &DeleteWorkstationRequest, + ) -> Result { + self.call_with_timeout("DeleteWorkstation", request, Duration::from_secs(12 * 60)) + } + + /// Follow output with bounded Connect envelopes and no overall time limit. + /// + /// Returns only after the protocol's end-stream envelope. A callback error + /// stops reading without stopping or restarting the remote process. + pub fn stream_command_output( + &self, + request: &StreamCommandOutputRequest, + mut on_output: impl FnMut(StreamCommandOutputResponse) -> Result<()>, + ) -> Result<()> { + let payload = serde_json::to_vec(request)?; + let mut envelope = Vec::with_capacity(payload.len() + 5); + envelope.push(0); + envelope.extend_from_slice(&u32::try_from(payload.len())?.to_be_bytes()); + envelope.extend_from_slice(&payload); + let url = self + .base_url + .join(&format!("{SERVICE}/StreamCommandOutput"))?; + let response = self + .streaming_http + .post(url) + .headers(self.headers.clone()) + .header("content-type", "application/connect+json") + .header("accept-encoding", "identity") + .header("connect-accept-encoding", "identity") + .body(envelope) + .send() + .context("call Blox StreamCommandOutput")?; + let status = response.status(); + if !status.is_success() { + let mut body = Vec::new(); + response + .take(MAX_RESPONSE_BYTES + 1) + .read_to_end(&mut body) + .context("read Blox StreamCommandOutput error")?; + anyhow::ensure!( + body.len() as u64 <= MAX_RESPONSE_BYTES, + "Blox StreamCommandOutput error exceeds 2 MiB" + ); + return Err(self.response_error(status, &body, "StreamCommandOutput")); + } + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + anyhow::ensure!( + content_type.split(';').next().is_some_and(|value| value + .trim() + .eq_ignore_ascii_case("application/connect+json")), + "Blox StreamCommandOutput returned an invalid content type" + ); + for header in ["content-encoding", "connect-content-encoding"] { + anyhow::ensure!( + response + .headers() + .get(header) + .is_none_or(|value| value == "identity"), + "Blox StreamCommandOutput returned unsupported compression" + ); + } + self.read_output_stream(response, &mut on_output) + } + + fn read_output_stream( + &self, + mut reader: impl Read, + on_output: &mut impl FnMut(StreamCommandOutputResponse) -> Result<()>, + ) -> Result<()> { + loop { + let mut header = [0; 5]; + reader + .read_exact(&mut header) + .context("Blox output stream ended before its end-stream envelope")?; + anyhow::ensure!( + header[0] == 0 || header[0] == 2, + "Blox output stream has unsupported envelope flags" + ); + let length = u32::from_be_bytes(header[1..].try_into()?) as u64; + anyhow::ensure!( + length <= MAX_RESPONSE_BYTES, + "Blox output envelope exceeds 2 MiB" + ); + let mut payload = vec![0; length as usize]; + reader + .read_exact(&mut payload) + .context("Blox output envelope is truncated")?; + if header[0] == 2 { + let end: EndStream = + self.decode_response(&payload, "StreamCommandOutput end-stream")?; + if let Some(error) = end.error { + return Err(error.into()); + } + return Ok(()); + } + let output: StreamCommandOutputResponse = + self.decode_response(&payload, "StreamCommandOutput")?; + let data = String::from_utf8_lossy(&output.data); + anyhow::ensure!( + self.redact(&data) == data, + "Blox output contains reflected authentication credentials" + ); + on_output(output)?; + } + } + + fn call(&self, method: &str, request: &T) -> Result { + self.call_with_timeout(method, request, Duration::from_secs(30)) + } + + fn call_with_timeout( + &self, + method: &str, + request: &T, + timeout: Duration, + ) -> Result { + let url = self.base_url.join(&format!("{SERVICE}/{method}"))?; + let response = self + .http + .post(url) + .headers(self.headers.clone()) + .json(request) + .timeout(timeout) + .send() + .with_context(|| format!("call Blox {method}"))?; + let status = response.status(); + let mut body = Vec::new(); + response + .take(MAX_RESPONSE_BYTES + 1) + .read_to_end(&mut body) + .with_context(|| format!("read Blox {method} response"))?; + anyhow::ensure!( + body.len() as u64 <= MAX_RESPONSE_BYTES, + "Blox {method} response exceeds 2 MiB" + ); + if !status.is_success() { + return Err(self.response_error(status, &body, method)); + } + self.decode_response(&body, method) + } + + fn response_error( + &self, + status: reqwest::StatusCode, + body: &[u8], + method: &str, + ) -> anyhow::Error { + if let Ok(mut error) = serde_json::from_slice::(body) { + error.code = self.redact(&error.code); + error.message = self.redact(&error.message); + return error.into(); + } + let code = match status.as_u16() { + 401 => "unauthenticated", + 403 => "permission_denied", + 404 => "not_found", + 408 | 504 => "deadline_exceeded", + 429 => "resource_exhausted", + 502 | 503 => "unavailable", + _ => "unknown", + }; + RpcError { + code: code.to_owned(), + message: format!("Blox {method} failed with HTTP {status}"), + } + .into() + } + + fn decode_response(&self, body: &[u8], method: &str) -> Result { + let value: serde_json::Value = serde_json::from_slice(body).map_err(|error| { + anyhow::anyhow!( + "decode Blox {method} response: {}", + self.redact(&error.to_string()) + ) + })?; + // Reflected credentials must never reach typed fields or caller output. + anyhow::ensure!( + !self.contains_credential(&value), + "Blox {method} response contains reflected authentication credentials" + ); + serde_json::from_value(value).map_err(|error| { + anyhow::anyhow!( + "decode Blox {method} response: {}", + self.redact(&error.to_string()) + ) + }) + } + + fn redact(&self, message: &str) -> String { + let Some(value) = self + .headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + else { + return message.to_owned(); + }; + let mut redacted = message.replace(value, "[REDACTED]"); + if let Some(credential) = value.strip_prefix("BBIdentity ") { + redacted = redacted.replace(credential, "[REDACTED]"); + } + redacted + } + + fn contains_credential(&self, value: &serde_json::Value) -> bool { + match value { + serde_json::Value::String(value) => self.redact(value) != *value, + serde_json::Value::Array(values) => { + values.iter().any(|value| self.contains_credential(value)) + } + serde_json::Value::Object(values) => values + .iter() + .any(|(key, value)| self.redact(key) != *key || self.contains_credential(value)), + _ => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + use tiny_http::{Header, Response, Server}; + + fn frame(flags: u8, payload: &str) -> Vec { + let mut bytes = vec![flags]; + bytes.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + bytes.extend_from_slice(payload.as_bytes()); + bytes + } + + #[test] + fn lifecycle_calls_preserve_typed_results_and_use_one_canonical_id() { + let server = Server::http("127.0.0.1:0").unwrap(); + let client = Client::new(&format!("http://{}", server.server_addr())) + .unwrap() + .with_session_credential("session") + .unwrap(); + let worker = thread::spawn(move || { + for (method, response) in [ + ( + "SuspendWorkstation", + r#"{"workstationId":"ws-1","status":"WORKSTATION_STATUS_SUSPENDING","message":"draining"}"#, + ), + ( + "ResumeWorkstation", + r#"{"workstationId":"ws-1","status":"WORKSTATION_STATUS_ERROR","message":"resume failed"}"#, + ), + ( + "DeleteWorkstation", + r#"{"workstationId":"ws-1","success":false,"message":"deletion denied"}"#, + ), + ] { + let mut request = server.recv().unwrap(); + assert_eq!(request.method().as_str(), "POST"); + assert_eq!(request.url(), format!("/{SERVICE}/{method}")); + assert_eq!( + request + .headers() + .iter() + .find(|h| h.field.equiv("Authorization")) + .unwrap() + .value + .as_str(), + "BBIdentity session" + ); + let mut body = String::new(); + request.as_reader().read_to_string(&mut body).unwrap(); + assert_eq!( + serde_json::from_str::(&body).unwrap(), + serde_json::json!({"workstationId":"ws-1"}) + ); + request.respond(Response::from_string(response)).unwrap(); + } + }); + let response = client + .suspend_workstation(&SuspendWorkstationRequest { + workstation_id: "ws-1".to_owned(), + }) + .unwrap(); + assert_eq!( + response.status, + crate::proto::WorkstationStatus::Suspending as i32 + ); + assert_eq!(response.message, "draining"); + let response = client + .resume_workstation(&ResumeWorkstationRequest { + workstation_id: "ws-1".to_owned(), + }) + .unwrap(); + assert_eq!( + response.status, + crate::proto::WorkstationStatus::Error as i32 + ); + let response = client + .delete_workstation(&DeleteWorkstationRequest { + workstation_id: "ws-1".to_owned(), + }) + .unwrap(); + assert!(!response.success); + assert_eq!(response.message, "deletion denied"); + worker.join().unwrap(); + } + + #[test] + fn lifecycle_transport_loss_never_repeats_the_mutation() { + use std::io::BufRead; + use std::net::{Shutdown, TcpListener}; + for method in [ + "SuspendWorkstation", + "ResumeWorkstation", + "DeleteWorkstation", + ] { + let server = TcpListener::bind("127.0.0.1:0").unwrap(); + let client = Client::new(&format!("http://{}", server.local_addr().unwrap())).unwrap(); + let worker = thread::spawn(move || { + let (stream, _) = server.accept().unwrap(); + let mut reader = std::io::BufReader::new(stream.try_clone().unwrap()); + let mut length = 0; + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if line == "\r\n" { + break; + } + if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { + length = value.trim().parse::().unwrap(); + } + } + reader.read_exact(&mut vec![0; length]).unwrap(); + stream.shutdown(Shutdown::Both).unwrap(); + server.set_nonblocking(true).unwrap(); + thread::sleep(Duration::from_millis(100)); + assert!( + matches!(server.accept(), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock) + ); + }); + let result = match method { + "SuspendWorkstation" => client + .suspend_workstation(&SuspendWorkstationRequest { + workstation_id: "ws-1".to_owned(), + }) + .map(|_| ()), + "ResumeWorkstation" => client + .resume_workstation(&ResumeWorkstationRequest { + workstation_id: "ws-1".to_owned(), + }) + .map(|_| ()), + _ => client + .delete_workstation(&DeleteWorkstationRequest { + workstation_id: "ws-1".to_owned(), + }) + .map(|_| ()), + }; + assert!(result.is_err()); + worker.join().unwrap(); + } + } + + #[test] + fn execute_and_follow_use_exact_arguments_and_connect_framing() { + let server = Server::http("127.0.0.1:0").unwrap(); + let client = Client::new(&format!("http://{}", server.server_addr())) + .unwrap() + .with_session_credential("session") + .unwrap(); + let worker = thread::spawn(move || { + let mut request = server.recv().unwrap(); + assert_eq!(request.url(), format!("/{SERVICE}/ExecuteProcess")); + let mut body = String::new(); + request.as_reader().read_to_string(&mut body).unwrap(); + assert_eq!( + serde_json::from_str::(&body).unwrap(), + serde_json::json!({ + "workstationId":"ws-1", "command":"printf", "args":["%s", "literal $value; quoted"], "workingDir":"/tmp" + }) + ); + request + .respond(Response::from_string(r#"{"commandId":"cmd-1"}"#)) + .unwrap(); + + let mut request = server.recv().unwrap(); + assert_eq!(request.url(), format!("/{SERVICE}/StreamCommandOutput")); + for (header, expected) in [ + ("Content-Type", "application/connect+json"), + ("Accept-Encoding", "identity"), + ("Connect-Accept-Encoding", "identity"), + ("Authorization", "BBIdentity session"), + ] { + assert_eq!( + request + .headers() + .iter() + .find(|h| h.field.equiv(header)) + .unwrap() + .value + .as_str(), + expected + ); + } + let mut body = Vec::new(); + request.as_reader().read_to_end(&mut body).unwrap(); + assert_eq!(body[0], 0); + assert_eq!( + u32::from_be_bytes(body[1..5].try_into().unwrap()) as usize, + body.len() - 5 + ); + assert_eq!( + serde_json::from_slice::(&body[5..]).unwrap(), + serde_json::json!({"workstationId":"ws-1", "commandId":"cmd-1", "follow":true}) + ); + let body = [ + frame( + 0, + r#"{"stream":"STREAM_TYPE_STDOUT","data":"b3V0","resumeId":"1"}"#, + ), + frame( + 0, + r#"{"stream":"STREAM_TYPE_STDERR","data":"ZXJy","resumeId":"2"}"#, + ), + frame(0, r#"{"exitCode":7,"eof":true}"#), + frame(2, "{}"), + ] + .concat(); + request + .respond(Response::from_data(body).with_header( + Header::from_bytes("Content-Type", "application/connect+json").unwrap(), + )) + .unwrap(); + }); + let execution = client + .execute_process(&ExecuteProcessRequest { + workstation_id: "ws-1".to_owned(), + command: "printf".to_owned(), + args: vec!["%s".to_owned(), "literal $value; quoted".to_owned()], + working_dir: "/tmp".to_owned(), + ..Default::default() + }) + .unwrap(); + let mut chunks = Vec::new(); + client + .stream_command_output( + &StreamCommandOutputRequest { + workstation_id: "ws-1".to_owned(), + command_id: execution.command_id, + follow: true, + ..Default::default() + }, + |chunk| { + chunks.push(chunk); + Ok(()) + }, + ) + .unwrap(); + assert_eq!(chunks[0].data, b"out"); + assert_eq!(chunks[1].data, b"err"); + assert_eq!(chunks[2].exit_code, Some(7)); + worker.join().unwrap(); + } + + #[test] + fn frames_handle_short_reads_and_silent_terminal_processes() { + struct ShortReads(std::io::Cursor>); + impl Read for ShortReads { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + let length = buffer.len().min(1); + self.0.read(&mut buffer[..length]) + } + } + let client = Client::new("https://example.com").unwrap(); + let bytes = [frame(0, r#"{"exitCode":0}"#), frame(2, "{}")].concat(); + let mut exit = None; + client + .read_output_stream(ShortReads(std::io::Cursor::new(bytes)), &mut |chunk| { + assert!(chunk.data.is_empty()); + exit = chunk.exit_code; + Ok(()) + }) + .unwrap(); + assert_eq!(exit, Some(0)); + } + + #[test] + fn malformed_or_incomplete_streams_are_never_successful() { + let client = Client::new("https://example.com").unwrap(); + for bytes in [ + vec![], + vec![0, 0], + frame(0, r#"{"exitCode":0,"eof":true}"#), + vec![0, 0, 0, 0, 4, b'{'], + frame(1, "{}"), + frame(3, "{}"), + vec![0, 0, 32, 0, 1], + frame(2, "invalid"), + ] { + assert!(client + .read_output_stream(bytes.as_slice(), &mut |_| Ok(())) + .is_err()); + } + let bytes = [frame(0, "{}"), frame(2, "{}")].concat(); + let error = client + .read_output_stream(bytes.as_slice(), &mut |_| anyhow::bail!("output closed")) + .unwrap_err(); + assert_eq!(error.to_string(), "output closed"); + } + + #[test] + fn stream_trailer_errors_and_reflected_secrets_are_safe() { + let client = Client::new("https://example.com") + .unwrap() + .with_session_credential("secret-session") + .unwrap(); + let bytes = frame( + 2, + r#"{"error":{"code":"permission_denied","message":"denied"},"metadata":{"x-test":["value"]}}"#, + ); + let error = client + .read_output_stream(bytes.as_slice(), &mut |_| Ok(())) + .unwrap_err(); + assert_eq!( + error.downcast_ref::().unwrap().code, + "permission_denied" + ); + let bytes = frame( + 2, + r#"{"error":{"code":"unauthenticated","message":"secret-session"}}"#, + ); + let error = client + .read_output_stream(bytes.as_slice(), &mut |_| Ok(())) + .unwrap_err(); + assert!(!format!("{error:?}").contains("secret-session")); + let bytes = frame(0, r#"{"data":"c2VjcmV0LXNlc3Npb24="}"#); + let error = client + .read_output_stream(bytes.as_slice(), &mut |_| { + panic!("secret must not reach callback") + }) + .unwrap_err(); + assert!(!format!("{error:?}").contains("secret-session")); + } + + #[test] + fn streaming_http_errors_validate_auth_encoding_and_content_type() { + for (status, content_type, compression, body) in [ + ( + 401, + "application/json", + None, + r#"{"code":"unauthenticated","message":"secret-session"}"#, + ), + (200, "application/json", None, "{}"), + (200, "application/connect+json", Some("gzip"), "{}"), + ] { + let server = Server::http("127.0.0.1:0").unwrap(); + let client = Client::new(&format!("http://{}", server.server_addr())) + .unwrap() + .with_session_credential("secret-session") + .unwrap(); + let worker = thread::spawn(move || { + let mut response = Response::from_string(body) + .with_status_code(status) + .with_header(Header::from_bytes("Content-Type", content_type).unwrap()); + if let Some(compression) = compression { + response = response.with_header( + Header::from_bytes("Connect-Content-Encoding", compression).unwrap(), + ); + } + server.recv().unwrap().respond(response).unwrap(); + }); + let error = client + .stream_command_output(&StreamCommandOutputRequest::default(), |_| { + panic!("invalid response must not reach callback") + }) + .unwrap_err(); + if status == 401 { + assert_eq!( + error.downcast_ref::().unwrap().code, + "unauthenticated" + ); + } + assert!(!format!("{error:?}").contains("secret-session")); + worker.join().unwrap(); + } + } + + #[test] + fn connect_request_uses_typed_json_and_credentials() { + let server = Server::http("127.0.0.1:0").unwrap(); + let url = format!("http://{}/prefix", server.server_addr()); + let worker = thread::spawn(move || { + let mut request = server.recv().unwrap(); + assert_eq!(request.method().as_str(), "POST"); + assert_eq!(request.url(), format!("/prefix/{SERVICE}/ListWorkstations")); + for (name, expected) in [ + ("Content-Type", "application/json"), + ("Connect-Protocol-Version", "1"), + ("Authorization", "BBIdentity session"), + ] { + let value = request + .headers() + .iter() + .find(|h| h.field.equiv(name)) + .unwrap(); + assert_eq!(value.value.as_str(), expected); + } + let mut body = String::new(); + request.as_reader().read_to_string(&mut body).unwrap(); + assert_eq!( + serde_json::from_str::(&body).unwrap(), + serde_json::json!({"includeDeleted": false}) + ); + request.respond(Response::from_string(r#"{"totalCount":"9007199254740993","workstations":[{"name":"test","status":"WORKSTATION_STATUS_RUNNING"}]}"#) + .with_header(Header::from_bytes("Content-Type", "application/json").unwrap())).unwrap(); + }); + let client = Client::new(&url) + .unwrap() + .with_session_credential("session") + .unwrap(); + let response = client + .list_workstations(&ListWorkstationsRequest { + include_deleted: Some(false), + ..Default::default() + }) + .unwrap(); + assert_eq!(response.total_count, 9_007_199_254_740_993); + worker.join().unwrap(); + } + + #[test] + fn session_auth_uses_the_existing_builderlab_scheme() { + let server = Server::http("127.0.0.1:0").unwrap(); + let client = Client::new(&format!("http://{}", server.server_addr())) + .unwrap() + .with_session_credential("session") + .unwrap(); + let worker = thread::spawn(move || { + let request = server.recv().unwrap(); + assert_eq!( + request + .headers() + .iter() + .find(|h| h.field.equiv("Authorization")) + .unwrap() + .value + .as_str(), + "BBIdentity session" + ); + request.respond(Response::from_string("{}")).unwrap(); + }); + client.get_workstation("example").unwrap(); + worker.join().unwrap(); + } + + #[test] + fn connect_errors_keep_machine_readable_codes() { + let server = Server::http("127.0.0.1:0").unwrap(); + let client = Client::new(&format!("http://{}", server.server_addr())).unwrap(); + let worker = thread::spawn(move || { + let request = server.recv().unwrap(); + assert!(!request + .headers() + .iter() + .any(|h| h.field.equiv("X-Forwarded-Identity-Token"))); + request + .respond( + Response::from_string( + r#"{"code":"not_found","message":"workstation missing"}"#, + ) + .with_status_code(404), + ) + .unwrap(); + }); + let error = client.get_workstation("missing").unwrap_err(); + assert_eq!(error.downcast_ref::().unwrap().code, "not_found"); + worker.join().unwrap(); + } + + #[test] + fn rejects_urls_and_credentials_that_could_leak_headers() { + for url in [ + "file:///tmp/blox", + "https://user:password@example.com", + "https://example.com?token=x", + "https://example.com#fragment", + ] { + assert!(Client::new(url).is_err()); + } + assert!(Client::new("https://example.com") + .unwrap() + .with_session_credential("bad\ntoken") + .is_err()); + } + + fn response_error(status: u16, body: String) -> anyhow::Error { + let server = Server::http("127.0.0.1:0").unwrap(); + let client = Client::new(&format!("http://{}", server.server_addr())) + .unwrap() + .with_session_credential("secret-session") + .unwrap(); + let worker = thread::spawn(move || { + server + .recv() + .unwrap() + .respond(Response::from_string(body).with_status_code(status)) + .unwrap(); + }); + let error = client.get_workstation("example").unwrap_err(); + worker.join().unwrap(); + error + } + + #[test] + fn response_errors_redact_reflected_sessions_and_json_parse_details() { + let error = response_error(401, r#"{"code":"unauthenticated","message":"BBIdentity secret-session is invalid: secret-session"}"#.to_owned()); + assert_eq!( + error.downcast_ref::().unwrap().code, + "unauthenticated" + ); + assert!(!format!("{error:?}").contains("secret-session")); + let error = response_error( + 200, + r#"{"workstation":{"status":"secret-session"}}"#.to_owned(), + ); + assert!(!format!("{error:?}").contains("secret-session")); + } + + #[test] + fn non_json_auth_failures_keep_stable_codes() { + for (status, code) in [(401, "unauthenticated"), (403, "permission_denied")] { + let error = response_error(status, "secret-session".to_owned()); + assert_eq!(error.downcast_ref::().unwrap().code, code); + assert!(!format!("{error:?}").contains("secret-session")); + } + } + + #[test] + fn successful_fields_and_object_keys_cannot_reflect_credentials() { + for body in [ + r#"{"workstation":{"name":"secret-session"}}"#, + r#"{"secret-session":"ignored"}"#, + ] { + let error = response_error(200, body.to_owned()); + assert!(error + .to_string() + .contains("reflected authentication credentials")); + assert!(!format!("{error:?}").contains("secret-session")); + } + } + + #[test] + fn responses_are_bounded() { + let error = response_error(200, "x".repeat(MAX_RESPONSE_BYTES as usize + 1)); + assert!(error.to_string().contains("exceeds 2 MiB")); + } + + #[test] + fn redirects_do_not_forward_session_credentials() { + let target = Server::http("127.0.0.1:0").unwrap(); + let server = Server::http("127.0.0.1:0").unwrap(); + let client = Client::new(&format!("http://{}", server.server_addr())) + .unwrap() + .with_session_credential("secret-session") + .unwrap(); + let location = format!("http://{}/capture", target.server_addr()); + let worker = thread::spawn(move || { + server + .recv() + .unwrap() + .respond( + Response::empty(307) + .with_header(Header::from_bytes("Location", location).unwrap()), + ) + .unwrap(); + }); + assert!(client.get_workstation("example").is_err()); + worker.join().unwrap(); + assert!(target + .recv_timeout(Duration::from_millis(100)) + .unwrap() + .is_none()); + } + + #[test] + fn create_uses_sku_multiples_without_repository_or_resource_overrides() { + let server = Server::http("127.0.0.1:0").unwrap(); + let client = Client::new(&format!("http://{}", server.server_addr())) + .unwrap() + .with_session_credential("session") + .unwrap(); + let worker = thread::spawn(move || { + let mut request = server.recv().unwrap(); + assert_eq!(request.url(), format!("/{SERVICE}/StartWorkstation")); + assert_eq!( + request + .headers() + .iter() + .find(|h| h.field.equiv("Authorization")) + .unwrap() + .value + .as_str(), + "BBIdentity session" + ); + let mut body = String::new(); + request.as_reader().read_to_string(&mut body).unwrap(); + assert_eq!( + serde_json::from_str::(&body).unwrap(), + serde_json::json!({ + "name": "new-workstation", "computeSku": {"code": "DEFAULT_AMD64", "multiple": 3}, "creationClient": "builderlab_cli" + }) + ); + request + .respond(Response::from_string( + r#"{"workstationId":"ws-created","status":"WORKSTATION_STATUS_CREATING"}"#, + )) + .unwrap(); + }); + let response = client + .start_workstation(&StartWorkstationRequest { + name: Some("new-workstation".to_owned()), + compute_sku: Some(crate::proto::ComputeSkuSelection { + code: "DEFAULT_AMD64".to_owned(), + multiple: 3, + }), + creation_client: "builderlab_cli".to_owned(), + ..Default::default() + }) + .unwrap(); + assert_eq!(response.workstation_id, "ws-created"); + worker.join().unwrap(); + } + + #[test] + fn catalog_discovery_uses_empty_typed_request() { + let server = Server::http("127.0.0.1:0").unwrap(); + let client = Client::new(&format!("http://{}", server.server_addr())).unwrap(); + let worker = thread::spawn(move || { + let mut request = server.recv().unwrap(); + assert_eq!(request.url(), format!("/{SERVICE}/ListComputeProviders")); + let mut body = String::new(); + request.as_reader().read_to_string(&mut body).unwrap(); + assert_eq!(body, "{}"); + request + .respond(Response::from_string( + r#"{"computeSkus":[{"code":"DEFAULT_AMD64","maxMultiple":3}]}"#, + )) + .unwrap(); + }); + assert_eq!( + client.list_compute_providers().unwrap().compute_skus[0].max_multiple, + 3 + ); + worker.join().unwrap(); + } + + #[test] + fn ambiguous_create_response_is_not_retried() { + use std::io::BufRead; + use std::net::{Shutdown, TcpListener}; + let server = TcpListener::bind("127.0.0.1:0").unwrap(); + let client = Client::new(&format!("http://{}", server.local_addr().unwrap())).unwrap(); + let worker = thread::spawn(move || { + let (stream, _) = server.accept().unwrap(); + let mut reader = std::io::BufReader::new(stream.try_clone().unwrap()); + let mut length = 0; + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if line == "\r\n" { + break; + } + if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { + length = value.trim().parse::().unwrap(); + } + } + reader.read_exact(&mut vec![0; length]).unwrap(); + stream.shutdown(Shutdown::Both).unwrap(); + server.set_nonblocking(true).unwrap(); + thread::sleep(Duration::from_millis(100)); + assert!( + matches!(server.accept(), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock) + ); + }); + assert!(client + .start_workstation(&StartWorkstationRequest::default()) + .is_err()); + worker.join().unwrap(); + + let server = Server::http("127.0.0.1:0").unwrap(); + let client = Client::new(&format!("http://{}", server.server_addr())).unwrap(); + let worker = thread::spawn(move || { + server + .recv() + .unwrap() + .respond(Response::from_string("invalid-json")) + .unwrap(); + assert!(server + .recv_timeout(Duration::from_millis(100)) + .unwrap() + .is_none()); + }); + assert!(client + .start_workstation(&StartWorkstationRequest::default()) + .is_err()); + worker.join().unwrap(); + } +} diff --git a/crates/blox/src/lib.rs b/crates/blox/src/lib.rs new file mode 100644 index 0000000..d4d153a --- /dev/null +++ b/crates/blox/src/lib.rs @@ -0,0 +1,92 @@ +//! Blox orchestrator protocol types and client support. + +mod client; + +pub use client::{Client, RpcError}; + +/// Generated Blox orchestrator messages with standard protobuf JSON encoding. +pub mod proto { + #![allow(deprecated)] + #![allow(clippy::all)] + + include!(concat!( + env!("OUT_DIR"), + "/xyz.block.blox.orchestrator.v1.rs" + )); + include!(concat!( + env!("OUT_DIR"), + "/xyz.block.blox.orchestrator.v1.serde.rs" + )); +} + +#[cfg(test)] +mod tests { + use prost::Message; + use serde_json::json; + + use super::proto::{ + ListWorkstationsRequest, ListWorkstationsResponse, StartWorkstationRequest, + }; + + #[test] + fn protobuf_json_preserves_presence_and_uses_camel_case() { + let request = ListWorkstationsRequest { + include_deleted: Some(false), + ..Default::default() + }; + assert_eq!( + serde_json::to_value(request).unwrap(), + json!({"includeDeleted": false}) + ); + assert_eq!( + serde_json::from_value::(json!({"include_deleted": false})) + .unwrap(), + request + ); + assert_eq!( + serde_json::to_value(ListWorkstationsRequest::default()).unwrap(), + json!({}) + ); + } + + #[test] + fn protobuf_json_preserves_large_counts() { + let response = ListWorkstationsResponse { + total_count: 9_007_199_254_740_993, + ..Default::default() + }; + let value = serde_json::to_value(&response).unwrap(); + assert_eq!(value["totalCount"], "9007199254740993"); + assert_eq!( + serde_json::from_value::(value).unwrap(), + response + ); + } + + #[test] + fn generated_messages_round_trip_binary_and_optional_fields() { + let request = StartWorkstationRequest { + name: Some("example".to_owned()), + cpu: Some(0), + ..Default::default() + }; + let decoded = StartWorkstationRequest::decode(request.encode_to_vec().as_slice()).unwrap(); + assert_eq!(decoded, request); + assert_eq!(serde_json::to_value(&decoded).unwrap()["cpu"], 0); + } + + #[test] + fn newer_servers_can_add_fields_and_statuses() { + for status in [json!(99), json!("WORKSTATION_STATUS_FUTURE")] { + let response: ListWorkstationsResponse = serde_json::from_value(json!({ + "newResponseField": true, + "workstations": [{"name":"future", "status": status, "newWorkstationField": {"nested":1}}] + })).unwrap(); + assert_eq!(response.workstations[0].status, 0); + assert_eq!( + serde_json::to_value(&response).unwrap()["workstations"][0]["name"], + "future" + ); + } + } +} diff --git a/src/bl/blox.rs b/src/bl/blox.rs new file mode 100644 index 0000000..1a3be73 --- /dev/null +++ b/src/bl/blox.rs @@ -0,0 +1,1862 @@ +//! Blox workstation commands using the shared BuilderLab session. + +use std::io::Write; + +use anyhow::{Context, Result}; +use blox::proto::{ + ComputeSkuSelection, DeleteWorkstationRequest, ExecuteProcessRequest, ListWorkstationsRequest, + ResumeWorkstationRequest, StartWorkstationRequest, StreamCommandOutputRequest, + SuspendWorkstationRequest, WorkstationStatus, WorkstationSummary, +}; +use clap::{Arg, ArgAction, ArgMatches, Command}; +use serde::Serialize; +use serde_json::Value; + +use super::auth_login::verify_stored_session; +use super::auth_storage::default_session_storage; +use super::display::{print_json, terminal_safe_text}; +use super::runner; +use super::skills_api::{exit_codes, failure, SilentJsonExit}; +use super::skills_config::SkillsConfig; + +const ENDPOINT_ENV: &str = "BL_BLOX_CONTROL_PLANE_URL"; +const APPROVED_HOSTS: &[&str] = &[ + "blox.test.blockstaging.build", + "blox.public.builderlabstaging.xyz", + "blox.public.builderlab.xyz", +]; + +pub fn command() -> Command { + endpoint_arg(Command::new("blox") + .about("Manage Blox workstations") + .long_about("Manage your Blox workstations using your BuilderLab login. Names are resolved by the orchestrator; use the full ID to identify an archived workstation.") + .subcommand_required(true) + .arg_required_else_help(true) + .disable_help_subcommand(true) + .subcommand( + Command::new("list") + .about("List your workstations") + .arg(Arg::new("include-deleted").long("include-deleted").action(ArgAction::SetTrue).help("Include archived workstations")) + .arg(Arg::new("limit").long("limit").value_name("COUNT").value_parser(clap::value_parser!(i32).range(0..)).default_value("100").help("Maximum results (0 means all)")) + .arg(Arg::new("offset").long("offset").value_name("COUNT").value_parser(clap::value_parser!(i32).range(0..)).default_value("0").help("Number of results to skip")), + ) + .subcommand(Command::new("skus").about("List available workstation SKUs and maximum multiples")) + .subcommand( + Command::new("create") + .about("Create a new workstation with a SKU and multiple") + .long_about("Create a new workstation without requesting project repositories. The deployment may still bootstrap its configured home repository. Returns the initial ID and status; creation continues on the server.") + .arg(Arg::new("name").value_name("NAME").value_parser(clap::builder::NonEmptyStringValueParser::new()).help("Optional workstation name (otherwise generated by the server)")) + .arg(Arg::new("sku").long("sku").value_name("CODE").default_value("DEFAULT_AMD64").value_parser(clap::builder::NonEmptyStringValueParser::new()).help("SKU code from `bl blox skus`")) + .arg(Arg::new("multiple").long("multiple").value_name("COUNT").default_value("1").value_parser(clap::value_parser!(i32).range(1..)).help("SKU multiple")), + ) + .subcommand( + Command::new("exec") + .about("Run a command on a workstation and follow its output") + .long_about("Run a literal executable and arguments after --, stream stdout/stderr, and exit with the remote process status. Use explicit sh -c for shell expansion or pipelines. This is noninteractive: stdin and PTY are not supported. Interrupting or disconnecting may leave the command running. --json prints JSONL output events (protobuf bytes use base64), followed by an exit_code event.") + .arg(Arg::new("workstation").value_name("WORKSTATION").required(true).value_parser(clap::builder::NonEmptyStringValueParser::new())) + .arg(Arg::new("workdir").long("workdir").value_name("PATH").value_parser(clap::builder::NonEmptyStringValueParser::new()).help("Remote working directory (defaults to the workstation's configured directory)")) + .arg(Arg::new("command").value_name("COMMAND").num_args(1..).last(true).required(true).help("Executable and arguments passed literally to the remote process")), + ) + .subcommand(lifecycle_command("suspend", "Request workstation suspension")) + .subcommand(lifecycle_command("resume", "Request workstation resumption")) + .subcommand(lifecycle_command("delete", "Request workstation deletion")) + .subcommand( + Command::new("get") + .about("Get a workstation by canonical name or full ID") + .arg(Arg::new("workstation").value_name("WORKSTATION").required(true).value_parser(clap::builder::NonEmptyStringValueParser::new())), + )) +} + +fn lifecycle_command(name: &'static str, about: &'static str) -> Command { + Command::new(name).about(about) + .long_about("Request this transition for a workstation identified by canonical name or full ID. Reports the server response without waiting for the final state; inspect progress with `bl blox get `. Use the full ID for an archived workstation.") + .arg(Arg::new("workstation").value_name("WORKSTATION").required(true).value_parser(clap::builder::NonEmptyStringValueParser::new())) +} + +fn endpoint_arg(command: Command) -> Command { + command.arg( + Arg::new("blox-base-url") + .long("base-url") + .visible_alias("control-plane-url") + .env(ENDPOINT_ENV) + .global(true) + .value_name("URL") + .help( + "Approved BuilderLab Blox control-plane URL (defaults to your login environment)", + ), + ) +} + +pub fn describe_commands() -> Value { + super::description::describe_command_tree(&command()) +} + +pub fn run(matches: &ArgMatches) -> Result<()> { + runner::run(matches, dispatch) +} + +fn dispatch(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let (operation, args) = matches.subcommand().context("expected a blox subcommand")?; + if operation == "list" + && args.get_one::("limit") == Some(&0) + && args + .get_one::("offset") + .is_some_and(|offset| *offset > 0) + { + return Err(failure( + exit_codes::GENERAL, + "invalid_pagination", + "--offset requires a nonzero --limit", + )); + } + if operation == "exec" + && args + .get_many::("command") + .and_then(|mut command| command.next()) + .is_some_and(|command| command.is_empty()) + { + return Err(failure( + exit_codes::GENERAL, + "invalid_command", + "The remote executable must not be empty", + )); + } + let endpoint = args + .get_one::("blox-base-url") + .cloned() + .map(Ok) + .unwrap_or_else(|| default_endpoint(&config.kgoose_base_url))?; + validate_endpoint(&endpoint)?; + runner::ensure_org_configured(config)?; + let storage = default_session_storage(config)?; + let verified = verify_stored_session(config, storage.as_ref()) + .map_err(map_error)? + .ok_or_else(|| { + failure( + exit_codes::AUTH_REQUIRED, + "auth_required", + "BuilderLab CLI auth is required; run `bl auth login`", + ) + })?; + let secret = verified + .credential + .session_credential_header_value() + .ok_or_else(|| { + failure( + exit_codes::AUTH_REQUIRED, + "auth_required", + "BuilderLab CLI auth is required; run `bl auth login`", + ) + })?; + // Test-only remapping leaves production origin validation and auth intact. + #[cfg(test)] + let endpoint = std::env::var("BL_BLOX_E2E_ENDPOINT").unwrap_or(endpoint); + let client = blox::Client::new(&endpoint)?.with_session_credential(&secret)?; + config.style.verbose(&format!("Blox {operation}")); + match operation { + "exec" => run_execute(config, args, &client), + "suspend" | "resume" | "delete" => run_lifecycle(config, args, &client, operation), + "skus" => { + let response = client.list_compute_providers().map_err(map_error)?; + if config.json { + print_json(&response) + } else { + if response.compute_skus.is_empty() { + println!("No workstation SKUs are available for your account."); + } else { + println!("CODE\tNAME\tMAX MULTIPLE\tPROVIDER"); + for sku in &response.compute_skus { + println!( + "{}\t{}\t{}\t{}", + terminal_safe_text(&sku.code), + terminal_safe_text(&sku.display_name), + sku.max_multiple, + terminal_safe_text(&sku.compute_provider) + ); + } + } + Ok(()) + } + } + "create" => { + let request = StartWorkstationRequest { + name: args.get_one::("name").cloned(), + compute_sku: Some(ComputeSkuSelection { + code: args + .get_one::("sku") + .context("expected SKU code")? + .clone(), + multiple: *args + .get_one::("multiple") + .context("expected SKU multiple")?, + }), + creation_client: "builderlab_cli".to_string(), + ..Default::default() + }; + let response = client + .start_workstation(&request) + .map_err(map_create_error)?; + if response.workstation_id.is_empty() { + return Err(create_unknown_outcome()); + } + if config.json { + print_json(&response) + } else { + println!("ID: {}", terminal_safe_text(&response.workstation_id)); + println!("Status: {}", status_text(response.status)); + if !response.message.is_empty() { + println!("Message: {}", terminal_safe_text(&response.message)); + } + println!( + "Inspect with `bl blox get {}` (preserve any --base-url override).", + terminal_safe_text(&response.workstation_id) + ); + Ok(()) + } + } + "list" => { + let response = client + .list_workstations(&ListWorkstationsRequest { + include_deleted: Some(args.get_flag("include-deleted")), + page_limit: *args + .get_one::("limit") + .context("expected result limit")?, + page_offset: *args + .get_one::("offset") + .context("expected result offset")?, + skip_access_details: true, + }) + .map_err(map_error)?; + if config.json { + print_json(&response) + } else { + if response.workstations.is_empty() { + println!("No workstations found."); + } else { + println!("ID\tNAME\tSTATUS\tPROVIDER"); + for workstation in &response.workstations { + println!( + "{}\t{}\t{}\t{}", + terminal_safe_text(&workstation.workstation_id), + terminal_safe_text(&workstation.name), + status_text(workstation.status), + terminal_safe_text(&workstation.provider) + ); + } + println!( + "{} of {} workstations", + response.workstations.len(), + response.total_count + ); + } + Ok(()) + } + } + "get" => { + let identifier = args + .get_one::("workstation") + .context("expected workstation name or ID")?; + let response = client.get_workstation(identifier).map_err(map_error)?; + let workstation = response.workstation.as_ref().ok_or_else(|| { + failure( + exit_codes::GENERAL, + "invalid_response", + "Blox returned no workstation", + ) + })?; + if config.json { + print_json(&response) + } else { + print_workstation(workstation); + Ok(()) + } + } + _ => anyhow::bail!("expected a blox subcommand"), + } +} + +fn run_lifecycle( + config: &SkillsConfig, + args: &ArgMatches, + client: &blox::Client, + operation: &str, +) -> Result<()> { + let selector = args + .get_one::("workstation") + .context("expected workstation")?; + let workstation = client + .get_workstation(selector) + .map_err(map_error)? + .workstation + .filter(|workstation| !workstation.workstation_id.is_empty()) + .ok_or_else(|| { + failure( + exit_codes::GENERAL, + "invalid_response", + "Blox returned no workstation ID", + ) + })?; + let id = &workstation.workstation_id; + match operation { + "suspend" => { + let response = client + .suspend_workstation(&SuspendWorkstationRequest { + workstation_id: id.clone(), + }) + .map_err(|error| map_lifecycle_error(error, operation, id))?; + print_lifecycle_result( + config, + operation, + id, + LifecycleSummary { + workstation_id: &response.workstation_id, + status: Some(response.status), + success: true, + message: &response.message, + }, + &response, + ) + } + "resume" => { + let response = client + .resume_workstation(&ResumeWorkstationRequest { + workstation_id: id.clone(), + }) + .map_err(|error| map_lifecycle_error(error, operation, id))?; + print_lifecycle_result( + config, + operation, + id, + LifecycleSummary { + workstation_id: &response.workstation_id, + status: Some(response.status), + success: true, + message: &response.message, + }, + &response, + ) + } + "delete" => { + let response = client + .delete_workstation(&DeleteWorkstationRequest { + workstation_id: id.clone(), + }) + .map_err(|error| map_lifecycle_error(error, operation, id))?; + print_lifecycle_result( + config, + operation, + id, + LifecycleSummary { + workstation_id: &response.workstation_id, + status: None, + success: response.success, + message: &response.message, + }, + &response, + ) + } + _ => anyhow::bail!("expected a lifecycle operation"), + } +} + +struct LifecycleSummary<'a> { + workstation_id: &'a str, + status: Option, + success: bool, + message: &'a str, +} + +fn print_lifecycle_result( + config: &SkillsConfig, + operation: &str, + requested_id: &str, + summary: LifecycleSummary<'_>, + response: &T, +) -> Result<()> { + if summary.workstation_id != requested_id { + return Err(lifecycle_unknown( + operation, + requested_id, + Some(summary.message), + )); + } + if !summary.success + || matches!(summary.status, Some(status) if status == WorkstationStatus::Error as i32 || status == WorkstationStatus::Degraded as i32) + { + return Err(failure( + exit_codes::GENERAL, + "lifecycle_failed", + format!( + "Blox {operation} failed for workstation {}: {}", + terminal_safe_text(requested_id), + if summary.message.is_empty() { + "server reported an unsuccessful transition".to_string() + } else { + terminal_safe_text(summary.message) + } + ), + )); + } + if let Some(status) = summary.status { + let accepted = WorkstationStatus::try_from(status).is_ok_and(|status| match operation { + "suspend" => matches!( + status, + WorkstationStatus::Suspending + | WorkstationStatus::Suspended + | WorkstationStatus::Deleting + | WorkstationStatus::Deleted + ), + "resume" => matches!( + status, + WorkstationStatus::Queued + | WorkstationStatus::Creating + | WorkstationStatus::Starting + | WorkstationStatus::Running + ), + _ => false, + }); + if !accepted { + return Err(lifecycle_unknown( + operation, + requested_id, + Some(summary.message), + )); + } + } + if config.json { + return print_json(response); + } + println!("Request accepted: {operation}"); + println!("ID: {}", terminal_safe_text(summary.workstation_id)); + if let Some(status) = summary.status { + println!("Server status: {}", status_text(status)); + } + if !summary.message.is_empty() { + println!("Message: {}", terminal_safe_text(summary.message)); + } + println!( + "Inspect progress with `bl blox get {}` (preserve any --base-url override).", + terminal_safe_text(requested_id) + ); + Ok(()) +} + +fn lifecycle_unknown(operation: &str, id: &str, server_message: Option<&str>) -> anyhow::Error { + let mut message = format!("Blox {operation} result is unknown for workstation {}. Inspect `bl blox get {}` with the same Blox endpoint before trying again; the transition may already be underway. Preserve any --base-url override.", terminal_safe_text(id), terminal_safe_text(id)); + if let Some(server_message) = server_message.filter(|message| !message.is_empty()) { + message.push_str(&format!( + " Server message: {}", + terminal_safe_text(server_message) + )); + } + failure(exit_codes::NETWORK, "lifecycle_outcome_unknown", message) +} + +fn map_lifecycle_error(error: anyhow::Error, operation: &str, id: &str) -> anyhow::Error { + if let Some(rpc) = error.downcast_ref::() { + if !matches!( + rpc.code.as_str(), + "unavailable" | "deadline_exceeded" | "unknown" | "internal" | "data_loss" + ) { + return map_error(error); + } + } + lifecycle_unknown(operation, id, None) +} + +fn run_execute(config: &SkillsConfig, args: &ArgMatches, client: &blox::Client) -> Result<()> { + let selector = args + .get_one::("workstation") + .context("expected workstation")?; + let workstation = client + .get_workstation(selector) + .map_err(map_error)? + .workstation + .filter(|workstation| !workstation.workstation_id.is_empty()) + .ok_or_else(|| { + failure( + exit_codes::GENERAL, + "invalid_response", + "Blox returned no workstation ID", + ) + })?; + let mut command = args + .get_many::("command") + .context("expected command")?; + let executable = command.next().context("expected executable")?.clone(); + let response = client + .execute_process(&ExecuteProcessRequest { + workstation_id: workstation.workstation_id.clone(), + command: executable, + args: command.cloned().collect(), + working_dir: args + .get_one::("workdir") + .cloned() + .unwrap_or_default(), + ..Default::default() + }) + .map_err(|error| map_execute_start_error(error, &workstation.workstation_id))?; + if response.command_id.is_empty() { + return Err(execute_start_unknown(&workstation.workstation_id)); + } + let command_id = response.command_id; + config + .style + .verbose(&format!("Following Blox command {command_id}")); + let mut exit_code = None; + client + .stream_command_output( + &StreamCommandOutputRequest { + workstation_id: workstation.workstation_id.clone(), + command_id: command_id.clone(), + follow: true, + ..Default::default() + }, + |output| { + if config.json { + write_json_line(&serde_json::json!({ + "workstation_id":workstation.workstation_id, + "command_id":command_id, + "output":output, + }))?; + } else if !output.data.is_empty() { + match output.stream { + 1 => { + let mut stdout = std::io::stdout().lock(); + stdout.write_all(&output.data)?; + stdout.flush()?; + } + 2 => { + let mut stderr = std::io::stderr().lock(); + stderr.write_all(&output.data)?; + stderr.flush()?; + } + _ => anyhow::bail!("Blox returned output with an unsupported stream type"), + } + } + if let Some(code) = output.exit_code { + exit_code = Some(code); + } + Ok(()) + }, + ) + .map_err(|_| execute_follow_failed(&workstation.workstation_id, &command_id))?; + let exit_code = + exit_code.ok_or_else(|| execute_follow_failed(&workstation.workstation_id, &command_id))?; + if config.json { + write_json_line( + &serde_json::json!({"workstation_id":workstation.workstation_id, "command_id":command_id, "exit_code":exit_code}), + )?; + } + if exit_code != 0 { + return Err(anyhow::Error::new(SilentJsonExit(exit_code))); + } + Ok(()) +} + +fn write_json_line(value: &Value) -> Result<()> { + let mut stdout = std::io::stdout().lock(); + serde_json::to_writer(&mut stdout, value).context("write Blox JSONL output")?; + stdout + .write_all(b"\n") + .context("write Blox JSONL newline")?; + stdout.flush().context("flush Blox JSONL output") +} + +fn execute_start_unknown(workstation_id: &str) -> anyhow::Error { + failure(exit_codes::NETWORK, "execute_outcome_unknown", format!("Blox execution result is unknown on workstation {}. Inspect its commands in Blox before running it again; the command may already be running. Preserve any --base-url override.", terminal_safe_text(workstation_id))) +} + +fn map_execute_start_error(error: anyhow::Error, workstation_id: &str) -> anyhow::Error { + if let Some(rpc) = error.downcast_ref::() { + if !matches!( + rpc.code.as_str(), + "unavailable" | "deadline_exceeded" | "unknown" | "internal" | "data_loss" + ) { + return map_error(error); + } + } + execute_start_unknown(workstation_id) +} + +fn execute_follow_failed(workstation_id: &str, command_id: &str) -> anyhow::Error { + failure(exit_codes::NETWORK, "execute_output_incomplete", format!("Could not confirm the result of Blox command {} on workstation {}. The command may still be running; inspect it in Blox before running it again. Preserve any --base-url override.", terminal_safe_text(command_id), terminal_safe_text(workstation_id))) +} + +fn default_endpoint(auth_base_url: &str) -> Result { + let origin = url::Url::parse(auth_base_url) + .context("parse BuilderLab auth URL")? + .origin() + .ascii_serialization(); + let endpoint = match origin.as_str() { + "https://test.blockstaging.build" => "https://blox.test.blockstaging.build", + "https://app.builderlabstaging.xyz" => "https://blox.public.builderlabstaging.xyz", + "https://app.builderlab.xyz" => "https://blox.public.builderlab.xyz", + "https://block.builderlab.xyz" => return Err(failure(exit_codes::GENERAL, "blox_unavailable", "Blox is not deployed in this BuilderLab environment")), + _ => return Err(failure(exit_codes::GENERAL, "endpoint_required", "No Blox endpoint is configured for this BuilderLab environment; set an approved ingress with --base-url or BL_BLOX_CONTROL_PLANE_URL")), + }; + Ok(endpoint.to_string()) +} + +fn validate_endpoint(endpoint: &str) -> Result<()> { + let url = url::Url::parse(endpoint).context("parse Blox control-plane URL")?; + let approved = url.scheme() == "https" + && url.port_or_known_default() == Some(443) + && url.username().is_empty() + && url.password().is_none() + && url.path() == "/" + && url.query().is_none() + && url.fragment().is_none() + && url.host_str().is_some_and(|host| { + APPROVED_HOSTS + .iter() + .any(|approved| host.eq_ignore_ascii_case(approved)) + }); + if !approved { + return Err(failure( + exit_codes::GENERAL, + "invalid_endpoint", + "Blox control-plane URL must target an approved BuilderLab ingress over HTTPS", + )); + } + Ok(()) +} + +fn status_text(status: i32) -> String { + WorkstationStatus::try_from(status) + .map(|status| { + status + .as_str_name() + .trim_start_matches("WORKSTATION_STATUS_") + .to_ascii_lowercase() + }) + .unwrap_or_else(|_| format!("unknown({status})")) +} + +fn print_workstation(workstation: &WorkstationSummary) { + for (label, value) in [ + ("ID", workstation.workstation_id.as_str()), + ("Name", workstation.name.as_str()), + ("Provider", workstation.provider.as_str()), + ("Created", workstation.created_at.as_str()), + ("Last used", workstation.last_used_at.as_str()), + ("Message", workstation.message.as_str()), + ] { + println!("{label}: {}", terminal_safe_text(value)); + } + println!("Status: {}", status_text(workstation.status)); + if let Some(label) = &workstation.display_name { + println!("Display name: {}", terminal_safe_text(label)); + } +} + +fn create_unknown_outcome() -> anyhow::Error { + failure(exit_codes::NETWORK, "create_outcome_unknown", "Blox creation result is unknown. Inspect `bl blox list` or `bl blox get ` before trying again; the workstation may already exist. Preserve any --base-url override when checking.") +} + +fn map_create_error(error: anyhow::Error) -> anyhow::Error { + if let Some(rpc) = error.downcast_ref::() { + if !matches!( + rpc.code.as_str(), + "unavailable" | "deadline_exceeded" | "unknown" | "internal" | "data_loss" + ) { + return map_error(error); + } + } + create_unknown_outcome() +} + +fn map_error(error: anyhow::Error) -> anyhow::Error { + if let Some(rpc) = error.downcast_ref::() { + let exit_code = match rpc.code.as_str() { + "unauthenticated" => exit_codes::AUTH_REQUIRED, + "permission_denied" => exit_codes::FORBIDDEN, + "unavailable" | "deadline_exceeded" => exit_codes::NETWORK, + _ => exit_codes::GENERAL, + }; + let message = if rpc.code == "unauthenticated" { + "Blox authentication failed; run `bl auth login`".to_string() + } else { + format!("Blox {}: {}", rpc.code, terminal_safe_text(&rpc.message)) + }; + return failure(exit_code, &rpc.code, message); + } + if error.chain().any(|cause| cause.is::()) { + return failure(exit_codes::NETWORK, "network_error", format!("{error:#}")); + } + error +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::process::{Command as ProcessCommand, Output}; + use std::thread; + use std::time::Duration; + + use serde_json::json; + use sha2::{Digest, Sha256}; + use tiny_http::{Header, Response, Server}; + + use super::*; + + #[test] + fn defaults_follow_exact_login_origins() { + for (auth, blox) in [ + ( + "https://test.blockstaging.build", + "https://blox.test.blockstaging.build", + ), + ( + "https://app.builderlabstaging.xyz", + "https://blox.public.builderlabstaging.xyz", + ), + ( + "https://app.builderlab.xyz", + "https://blox.public.builderlab.xyz", + ), + ] { + assert_eq!(default_endpoint(auth).unwrap(), blox); + assert!(validate_endpoint(blox).is_ok()); + } + assert!(default_endpoint("https://block.builderlab.xyz") + .unwrap_err() + .to_string() + .contains("not deployed")); + for origin in [ + "http://app.builderlab.xyz", + "https://app.builderlab.xyz:8443", + "https://app.builderlab.xyz.evil.test", + ] { + assert!(default_endpoint(origin).is_err()); + } + } + + #[test] + fn session_origin_validation_rejects_unapproved_destinations() { + for endpoint in [ + "http://blox.public.builderlab.xyz", + "https://blox.public.builderlab.xyz:8443", + "https://blox.public.builderlab.xyz.evil.test", + "https://user@blox.public.builderlab.xyz", + "https://blox.public.builderlab.xyz/path", + "https://blox.public.builderlab.xyz/?q=1", + "https://blox.public.builderlab.xyz/#fragment", + "http://127.0.0.1:8895", + ] { + assert!(validate_endpoint(endpoint).is_err(), "accepted {endpoint}"); + } + } + + #[test] + fn unknown_and_transient_statuses_are_displayable() { + assert_eq!(status_text(999), "unknown(999)"); + assert_eq!(status_text(9), "pending"); + assert_eq!(status_text(10), "queued"); + assert_eq!(status_text(11), "suspending"); + assert_eq!(status_text(12), "deleting"); + } + + // Runs the CLI in its own process so environment-based auth is isolated + // from other unit tests and uses the real stored-session verification. + #[test] + fn blox_process_helper() { + let Ok(argv) = std::env::var("BL_BLOX_E2E_ARGS") else { + return; + }; + println!("BLOX_E2E_OUTPUT_BEGIN"); + let result = crate::run_bl_with_argv(serde_json::from_str(&argv).unwrap()); + if let Err(error) = result { + if let Some(silent) = error.downcast_ref::() { + std::process::exit(silent.0); + } + eprintln!("error: {error:#}"); + std::process::exit(super::super::skills_api::failure_info(&error).0); + } + println!("BLOX_E2E_OUTPUT_END"); + } + + type RecordedRequest = (String, BTreeMap, Value); + + enum MockReply { + Json(u16, Value), + Stream(Vec, Value), + } + + struct MockService { + url: String, + request: thread::JoinHandle>, + } + + impl MockService { + fn start(status: u16, value: Value) -> Self { + Self::steps(vec![MockReply::Json(status, value)]) + } + + fn steps(replies: Vec) -> Self { + let server = Server::http("127.0.0.1:0").unwrap(); + let url = format!("http://{}", server.server_addr()); + let request = thread::spawn(move || { + let mut requests = Vec::new(); + for reply in replies { + let mut request = server + .recv_timeout(Duration::from_secs(15)) + .unwrap() + .expect("mock request"); + let path = request.url().to_string(); + let headers = request + .headers() + .iter() + .map(|header| { + ( + header.field.to_string().to_ascii_lowercase(), + header.value.to_string(), + ) + }) + .collect(); + let mut body = Vec::new(); + request.as_reader().read_to_end(&mut body).unwrap(); + let body = if body.is_empty() { + Value::Null + } else if body[0] == 0 { + // Server-streaming Connect requests carry one framed JSON message. + serde_json::from_slice(&body[5..]).unwrap() + } else { + serde_json::from_slice(&body).unwrap() + }; + let (status, bytes, content_type) = match reply { + MockReply::Json(status, value) => ( + status, + serde_json::to_vec(&value).unwrap(), + "application/json", + ), + MockReply::Stream(outputs, terminal) => { + let mut bytes = Vec::new(); + for (flags, value) in outputs + .into_iter() + .map(|output| (0, output)) + .chain(std::iter::once((2, terminal))) + { + let body = serde_json::to_vec(&value).unwrap(); + bytes.push(flags); + bytes.extend_from_slice(&(body.len() as u32).to_be_bytes()); + bytes.extend_from_slice(&body); + } + (200, bytes, "application/connect+json") + } + }; + request + .respond( + Response::from_data(bytes) + .with_status_code(status) + .with_header( + Header::from_bytes("Content-Type", content_type).unwrap(), + ), + ) + .unwrap(); + requests.push((path, headers, body)); + } + requests + }); + Self { url, request } + } + + fn finish(self) -> RecordedRequest { + let mut requests = self.finish_all(); + assert_eq!(requests.len(), 1); + requests.pop().unwrap() + } + + fn finish_all(self) -> Vec { + self.request.join().unwrap() + } + } + + fn run_process(auth: &MockService, service: &MockService, args: &[&str]) -> Output { + let home = tempfile::tempdir().unwrap(); + std::fs::write(home.path().join("config.yaml"), "org: test\n").unwrap(); + let session_path = home.path().join("sessions.json"); + let service_url = format!("{}/api/goose", auth.url); + let mut hash = Sha256::new(); + hash.update(b"default"); + hash.update([0]); + hash.update(service_url.as_bytes()); + let key = hash + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + std::fs::write(&session_path, json!({ key: {"sessionCredential":"blox-test-only.session", "expiresAt":"2099-01-01T00:00:00Z"}}).to_string()).unwrap(); + let argv = std::iter::once("bl") + .chain(args.iter().copied()) + .map(str::to_string) + .collect::>(); + ProcessCommand::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "bl::blox::tests::blox_process_helper", + "--nocapture", + ]) + .env("BL_BLOX_E2E_ARGS", serde_json::to_string(&argv).unwrap()) + .env("BL_BLOX_E2E_ENDPOINT", &service.url) + .env("BL_HOME", home.path()) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", session_path) + .env("KGOOSE_BASE_URL", &auth.url) + .env("KGOOSE_SERVICE_PATH", "/api/goose") + .env_remove("BL_SKILLS_CONFIG") + .env_remove("BL_SKILLS_PROFILE") + .env_remove("BL_KGOOSE_PLAYPEN") + .env_remove("KGOOSE_PLAYPEN") + .env_remove(ENDPOINT_ENV) + .output() + .unwrap() + } + + fn auth_service() -> MockService { + MockService::start( + 200, + json!({"subject":"test", "email":"blox-test@example.test", "name":"Test", "expires_at":"2099-01-01T00:00:00Z", "workspaces":{"active":[]}}), + ) + } + + fn assert_shared_auth(auth: MockService, service: MockService) -> (String, Value) { + let (auth_path, auth_headers, _) = auth.finish(); + assert_eq!(auth_path, "/api/goose/v1/auth/me"); + assert_eq!( + auth_headers["x-bb-session-credential"], + "blox-test-only.session" + ); + let (path, headers, body) = service.finish(); + assert_eq!( + headers["authorization"], + "BBIdentity blox-test-only.session" + ); + for header in [ + "cookie", + "x-bb-session-credential", + "x-forwarded-identity-token", + "x-forwarded-user", + "x-blox-workstation-key", + ] { + assert!(!headers.contains_key(header), "unexpected {header}"); + } + (path, body) + } + + #[test] + fn list_uses_shared_session_and_preserves_protobuf_json() { + let auth = auth_service(); + let service = MockService::start( + 200, + json!({"workstations":[{"workstationId":"9223372036854775806", "name":"project", "status":999}], "totalCount":"9007199254740993", "recentlyDeletedCount":"7"}), + ); + let output = run_process( + &auth, + &service, + &[ + "blox", + "--base-url", + "https://blox.public.builderlab.xyz", + "list", + "--include-deleted", + "--limit", + "2", + "--offset", + "3", + "--json", + ], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("9007199254740993")); + assert!(stdout.contains("9223372036854775806")); + assert!(!stdout.contains("999")); // Forward enum variants become unspecified. + + let (path, body) = assert_shared_auth(auth, service); + assert!( + path.ends_with("/OrchestratorService/ListWorkstations") + || path.ends_with(".OrchestratorService/ListWorkstations") + ); + assert_eq!( + body, + json!({"includeDeleted":true, "pageLimit":2, "pageOffset":3, "skipAccessDetails":true}) + ); + } + + #[test] + fn get_forwards_the_selector_unchanged() { + let auth = auth_service(); + let service = MockService::start( + 200, + json!({"workstation":{"workstationId":"123", "name":"123", "status":"WORKSTATION_STATUS_SUSPENDING"}}), + ); + let output = run_process( + &auth, + &service, + &[ + "blox", + "get", + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("Status: suspending")); + let (_, body) = assert_shared_auth(auth, service); + assert_eq!(body, json!({"workstation":"123"})); + } + + #[test] + fn errors_use_shared_json_format_and_redact_the_session() { + let auth = auth_service(); + let service = MockService::start( + 403, + json!({"code":"permission_denied", "message":"denied blox-test-only.session"}), + ); + let output = run_process( + &auth, + &service, + &[ + "blox", + "get", + "project", + "--base-url", + "https://blox.test.blockstaging.build", + "--json", + ], + ); + assert_eq!(output.status.code(), Some(4)); + let stderr = String::from_utf8_lossy(&output.stderr); + let payload: Value = serde_json::from_str(stderr.trim()).unwrap(); + assert_eq!(payload["error"]["code"], "permission_denied"); + assert_eq!(payload["error"]["exit_code"], 4); + assert!(!stderr.contains("blox-test-only.session")); + assert_shared_auth(auth, service); + } + #[test] + fn rejected_shared_session_stops_before_blox_request() { + let auth = MockService::start(401, json!({"error":"expired"})); + let output = run_process( + &auth, + &auth, + &[ + "blox", + "list", + "--base-url", + "https://blox.test.blockstaging.build", + "--json", + ], + ); + assert_eq!(output.status.code(), Some(3)); + let payload: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(payload["error"]["code"], "auth_required"); + assert!(payload["error"]["message"] + .as_str() + .unwrap() + .contains("bl auth login")); + let (path, _, _) = auth.finish(); + assert_eq!(path, "/api/goose/v1/auth/me"); + } + + #[test] + fn missing_workstation_is_an_error() { + let auth = auth_service(); + let service = MockService::start(200, json!({})); + let output = run_process( + &auth, + &service, + &[ + "blox", + "get", + "project", + "--base-url", + "https://blox.test.blockstaging.build", + "--json", + ], + ); + assert_eq!(output.status.code(), Some(1)); + let payload: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(payload["error"]["code"], "invalid_response"); + assert_shared_auth(auth, service); + } + fn output_json(output: &Output) -> Value { + let stdout = String::from_utf8_lossy(&output.stdout); + let body = stdout + .split("BLOX_E2E_OUTPUT_BEGIN") + .nth(1) + .unwrap() + .split("BLOX_E2E_OUTPUT_END") + .next() + .unwrap(); + serde_json::from_str(body.trim()).unwrap() + } + + #[test] + fn create_sends_only_the_selected_sku_multiple_and_optional_name() { + let auth = auth_service(); + let service = MockService::start( + 200, + json!({"workstationId":"1234567890123456789", "status":"WORKSTATION_STATUS_PENDING", "message":"awaiting approval"}), + ); + let output = run_process( + &auth, + &service, + &[ + "blox", + "create", + "test-name", + "--sku", + "CUSTOM_SKU", + "--multiple", + "3", + "--base-url", + "https://blox.test.blockstaging.build", + "--json", + ], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + output_json(&output), + json!({"workstationId":"1234567890123456789", "status":"WORKSTATION_STATUS_PENDING", "message":"awaiting approval"}) + ); + let (path, request) = assert_shared_auth(auth, service); + assert!(path.ends_with(".OrchestratorService/StartWorkstation")); + assert_eq!( + request, + json!({"name":"test-name", "computeSku":{"code":"CUSTOM_SKU", "multiple":3}, "creationClient":"builderlab_cli"}) + ); + } + + #[test] + fn create_defaults_the_sku_and_multiple_and_leaves_name_to_the_server() { + let auth = auth_service(); + let service = MockService::start( + 200, + json!({"workstationId":"123", "status":"WORKSTATION_STATUS_QUEUED"}), + ); + let output = run_process( + &auth, + &service, + &[ + "blox", + "create", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("ID: 123")); + assert!(stdout.contains("Status: queued")); + assert!(!stdout.contains("ready")); + let (_, request) = assert_shared_auth(auth, service); + assert_eq!( + request, + json!({"computeSku":{"code":"DEFAULT_AMD64", "multiple":1}, "creationClient":"builderlab_cli"}) + ); + } + + #[test] + fn create_rejected_session_stops_before_the_mutation() { + let auth = MockService::start(401, json!({"error":"expired"})); + let output = run_process( + &auth, + &auth, + &[ + "blox", + "create", + "--base-url", + "https://blox.test.blockstaging.build", + "--json", + ], + ); + assert_eq!(output.status.code(), Some(3)); + let payload: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(payload["error"]["code"], "auth_required"); + let (path, _, _) = auth.finish(); + assert_eq!(path, "/api/goose/v1/auth/me"); + } + + #[test] + fn create_preserves_definite_server_rejections() { + let auth = auth_service(); + let service = MockService::start( + 400, + json!({"code":"invalid_argument", "message":"SKU multiple exceeds your maximum"}), + ); + let output = run_process( + &auth, + &service, + &[ + "blox", + "create", + "--multiple", + "9", + "--base-url", + "https://blox.test.blockstaging.build", + "--json", + ], + ); + assert_eq!(output.status.code(), Some(1)); + let payload: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(payload["error"]["code"], "invalid_argument"); + assert!(payload["error"]["message"] + .as_str() + .unwrap() + .contains("maximum")); + assert_shared_auth(auth, service); + } + + #[test] + fn create_ambiguous_results_require_inspection_before_retrying() { + for (status, response) in [ + ( + 503, + json!({"code":"unavailable", "message":"lost response"}), + ), + (200, json!("unexpected response")), + (200, json!({"status":"WORKSTATION_STATUS_CREATING"})), + ] { + let auth = auth_service(); + let service = MockService::start(status, response); + let output = run_process( + &auth, + &service, + &[ + "blox", + "create", + "--base-url", + "https://blox.test.blockstaging.build", + "--json", + ], + ); + assert_eq!(output.status.code(), Some(5)); + let payload: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(payload["error"]["code"], "create_outcome_unknown"); + let message = payload["error"]["message"].as_str().unwrap(); + assert!(message.contains("before trying again")); + assert!(message.contains("--base-url")); + assert_shared_auth(auth, service); + } + } + + #[test] + fn skus_discovery_preserves_catalog_json_and_prints_codes_with_maximums() { + for json_output in [false, true] { + let auth = auth_service(); + let catalog = json!({"computeSkuRequired":true, "computeSkus":[{"code":"DEFAULT_AMD64", "displayName":"Default", "cpu":1, "memoryGb":8, "maxMultiple":8, "computeProvider":"worker-cells"}]}); + let service = MockService::start(200, catalog.clone()); + let mut args = vec![ + "blox", + "skus", + "--base-url", + "https://blox.test.blockstaging.build", + ]; + if json_output { + args.push("--json"); + } + let output = run_process(&auth, &service, &args); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + if json_output { + assert_eq!(output_json(&output), catalog); + } else { + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("MAX MULTIPLE")); + assert!(stdout.contains("DEFAULT_AMD64\tDefault\t8\tworker-cells")); + } + let (path, request) = assert_shared_auth(auth, service); + assert!(path.ends_with(".OrchestratorService/ListComputeProviders")); + assert_eq!(request, json!({})); + } + } + fn process_stdout(output: &Output) -> String { + let stdout = String::from_utf8_lossy(&output.stdout); + let body = stdout.split("BLOX_E2E_OUTPUT_BEGIN\n").nth(1).unwrap(); + body.split("BLOX_E2E_OUTPUT_END") + .next() + .unwrap() + .to_string() + } + + fn execute_service(outputs: Vec, terminal: Value) -> MockService { + MockService::steps(vec![ + MockReply::Json( + 200, + json!({"workstation":{"workstationId":"resolved-id", "name":"my-workstation"}}), + ), + MockReply::Json(200, json!({"commandId":"command-id"})), + MockReply::Stream(outputs, terminal), + ]) + } + + fn assert_execute_requests(auth: MockService, service: MockService) -> Vec { + let (path, _, _) = auth.finish(); + assert_eq!(path, "/api/goose/v1/auth/me"); + let requests = service.finish_all(); + for (_, headers, _) in &requests { + assert_eq!( + headers["authorization"], + "BBIdentity blox-test-only.session" + ); + assert!(!headers.contains_key("x-forwarded-identity-token")); + } + assert_eq!(requests.len(), 3); + assert!(requests[0] + .0 + .ends_with(".OrchestratorService/GetWorkstation")); + assert!(requests[1] + .0 + .ends_with(".OrchestratorService/ExecuteProcess")); + assert!(requests[2] + .0 + .ends_with(".OrchestratorService/StreamCommandOutput")); + requests + } + + #[test] + fn execute_preserves_literal_arguments_and_raw_output_and_remote_exit_status() { + let auth = auth_service(); + let service = execute_service( + vec![ + json!({"stream":"STREAM_TYPE_STDOUT", "data":"SGVsbG8="}), + json!({"stream":"STREAM_TYPE_STDERR", "data":"c3RkZXJy", "exitCode":7, "eof":true}), + ], + json!({}), + ); + let output = run_process( + &auth, + &service, + &[ + "blox", + "exec", + "my-workstation", + "--workdir", + "/work/my directory", + "--base-url", + "https://blox.test.blockstaging.build", + "--", + "printf", + "a b", + "$HOME; exit 1", + "", + "--help", + "--json", + ], + ); + assert_eq!(output.status.code(), Some(7)); + assert_eq!(process_stdout(&output), "Hello"); + assert_eq!(String::from_utf8_lossy(&output.stderr), "stderr"); + let requests = assert_execute_requests(auth, service); + assert_eq!(requests[0].2, json!({"workstation":"my-workstation"})); + assert_eq!( + requests[1].2, + json!({"workstationId":"resolved-id", "command":"printf", "args":["a b","$HOME; exit 1","","--help","--json"], "workingDir":"/work/my directory"}) + ); + assert_eq!( + requests[2].2, + json!({"workstationId":"resolved-id", "commandId":"command-id", "follow":true}) + ); + } + + #[test] + fn execute_jsonl_events_end_with_remote_exit_without_an_extra_error() { + let auth = auth_service(); + let service = execute_service( + vec![ + json!({"stream":"STREAM_TYPE_STDOUT", "data":"SGVsbG8=", "resumeId":"1"}), + json!({"exitCode":7, "eof":true}), + ], + json!({}), + ); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + "exec", + "my-workstation", + "--base-url", + "https://blox.test.blockstaging.build", + "--", + "sh", + "-c", + "printf Hello; exit 7", + ], + ); + assert_eq!(output.status.code(), Some(7)); + assert!(output.stderr.is_empty()); + let events: Vec = process_stdout(&output) + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(events.len(), 3); + assert_eq!(events[0]["output"]["data"], "SGVsbG8="); + assert_eq!(events[0]["command_id"], "command-id"); + assert_eq!( + events[2], + json!({"command_id":"command-id", "workstation_id":"resolved-id", "exit_code":7}) + ); + assert_execute_requests(auth, service); + } + + #[test] + fn execute_silent_completion_requires_an_explicit_exit_code() { + for remote_code in [0, 1] { + let auth = auth_service(); + let service = + execute_service(vec![json!({"exitCode":remote_code, "eof":true})], json!({})); + let output = run_process( + &auth, + &service, + &[ + "blox", + "exec", + "my-workstation", + "--base-url", + "https://blox.test.blockstaging.build", + "--", + "true", + ], + ); + assert_eq!(output.status.code(), Some(remote_code)); + assert!(process_stdout(&output).is_empty()); + assert!(output.stderr.is_empty()); + assert_execute_requests(auth, service); + } + } + + #[test] + fn execute_incomplete_or_failed_stream_never_claims_success() { + for (outputs, terminal) in [ + (vec![json!({"eof":true})], json!({})), + ( + vec![json!({"exitCode":0, "eof":true})], + json!({"error":{"code":"unavailable", "message":"lost stream"}}), + ), + ] { + let auth = auth_service(); + let service = execute_service(outputs, terminal); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + "exec", + "my-workstation", + "--base-url", + "https://blox.test.blockstaging.build", + "--", + "true", + ], + ); + assert_eq!(output.status.code(), Some(5)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], "execute_output_incomplete"); + assert!(failure["error"]["message"] + .as_str() + .unwrap() + .contains("command-id")); + assert!(!process_stdout(&output).contains("exit_code")); + assert_execute_requests(auth, service); + } + } + + #[test] + fn execute_rejected_authentication_stops_before_any_workstation_request() { + let auth = MockService::start(401, json!({"error":"expired"})); + let output = run_process( + &auth, + &auth, + &[ + "--json", + "blox", + "exec", + "my-workstation", + "--base-url", + "https://blox.test.blockstaging.build", + "--", + "true", + ], + ); + assert_eq!(output.status.code(), Some(3)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], "auth_required"); + assert_eq!(auth.finish().0, "/api/goose/v1/auth/me"); + } + + #[test] + fn ordinary_marketplace_json_errors_still_have_one_error_envelope() { + let service = MockService::start( + 403, + json!({"error":{"code":"forbidden", "message":"not permitted"}}), + ); + let output = run_process(&service, &service, &["--json", "skills", "list"]); + assert_eq!(output.status.code(), Some(4)); + let payload: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(payload["error"]["code"], "forbidden"); + assert_eq!(payload["error"]["exit_code"], 4); + assert!(service.finish().0.contains("/v1/marketplace/skills")); + } + + #[test] + fn execute_start_rejection_or_unknown_result_does_not_retry() { + for (status, reply, expected_code) in [ + ( + 400, + json!({"code":"failed_precondition", "message":"workstation is suspended"}), + "failed_precondition", + ), + ( + 503, + json!({"code":"unavailable", "message":"lost acknowledgement"}), + "execute_outcome_unknown", + ), + (200, json!({}), "execute_outcome_unknown"), + ] { + let auth = auth_service(); + let service = MockService::steps(vec![ + MockReply::Json(200, json!({"workstation":{"workstationId":"resolved-id"}})), + MockReply::Json(status, reply), + ]); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + "exec", + "my-workstation", + "--base-url", + "https://blox.test.blockstaging.build", + "--", + "true", + ], + ); + assert!(!output.status.success()); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], expected_code); + assert_eq!(auth.finish().0, "/api/goose/v1/auth/me"); + let requests = service.finish_all(); + assert_eq!(requests.len(), 2); + assert!(requests[1] + .0 + .ends_with(".OrchestratorService/ExecuteProcess")); + } + } + fn lifecycle_service(target: &str, status: u16, response: Value) -> MockService { + MockService::steps(vec![ + MockReply::Json( + 200, + json!({"workstation":{"workstationId":target, "name":"123", "status":"WORKSTATION_STATUS_DELETED"}}), + ), + MockReply::Json(status, response), + ]) + } + + fn assert_lifecycle_requests( + auth: MockService, + service: MockService, + operation: &str, + target: &str, + ) { + assert_eq!(auth.finish().0, "/api/goose/v1/auth/me"); + let requests = service.finish_all(); + assert_eq!(requests.len(), 2); + assert!(requests[0] + .0 + .ends_with(".OrchestratorService/GetWorkstation")); + let rpc = match operation { + "suspend" => "SuspendWorkstation", + "resume" => "ResumeWorkstation", + "delete" => "DeleteWorkstation", + _ => panic!("unknown test operation"), + }; + assert!(requests[1] + .0 + .ends_with(&format!(".OrchestratorService/{rpc}"))); + assert_eq!(requests[1].2, json!({"workstationId":target})); + for (_, headers, _) in requests { + assert_eq!( + headers["authorization"], + "BBIdentity blox-test-only.session" + ); + assert!(!headers.contains_key("x-forwarded-identity-token")); + assert!(!headers.contains_key("cookie")); + } + } + + #[test] + fn lifecycle_resolves_names_once_and_preserves_each_typed_response() { + let target = "9223372036854775806"; + for (operation, reply) in [ + ( + "suspend", + json!({"workstationId":target, "status":"WORKSTATION_STATUS_SUSPENDED", "message":"suspension initiated"}), + ), + ( + "resume", + json!({"workstationId":target, "status":"WORKSTATION_STATUS_QUEUED", "message":"resume queued"}), + ), + ( + "delete", + json!({"workstationId":target, "success":true, "message":"deletion initiated"}), + ), + ] { + let auth = auth_service(); + let service = lifecycle_service(target, 200, reply.clone()); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + operation, + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(output_json(&output), reply); + assert_lifecycle_requests(auth, service, operation, target); + } + } + + #[test] + fn resume_accepts_initial_states_and_full_archived_ids_without_claiming_readiness() { + for status in [ + "WORKSTATION_STATUS_QUEUED", + "WORKSTATION_STATUS_CREATING", + "WORKSTATION_STATUS_STARTING", + "WORKSTATION_STATUS_RUNNING", + ] { + let auth = auth_service(); + let service = lifecycle_service( + "archived-id", + 200, + json!({"workstationId":"archived-id", "status":status, "message":"resume requested"}), + ); + let output = run_process( + &auth, + &service, + &[ + "blox", + "resume", + "archived-id", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = process_stdout(&output); + assert!(stdout.contains("Request accepted: resume")); + assert!(stdout.contains("Server status:")); + assert!(stdout.contains("bl blox get archived-id")); + assert!(!stdout.contains("ready")); + assert_lifecycle_requests(auth, service, "resume", "archived-id"); + } + } + + #[test] + fn lifecycle_false_error_and_degraded_results_fail_with_the_server_message() { + for (operation, response) in [ + ( + "delete", + json!({"workstationId":"target-id", "success":false, "message":"deletion rejected"}), + ), + ( + "suspend", + json!({"workstationId":"target-id", "status":"WORKSTATION_STATUS_ERROR", "message":"snapshot failed"}), + ), + ( + "resume", + json!({"workstationId":"target-id", "status":"WORKSTATION_STATUS_DEGRADED", "message":"bootstrap failed"}), + ), + ] { + let auth = auth_service(); + let service = lifecycle_service("target-id", 200, response.clone()); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + operation, + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert_eq!(output.status.code(), Some(1)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], "lifecycle_failed"); + assert!(failure["error"]["message"] + .as_str() + .unwrap() + .contains(response["message"].as_str().unwrap())); + assert_lifecycle_requests(auth, service, operation, "target-id"); + } + } + + #[test] + fn lifecycle_unknown_acknowledgements_keep_the_target_and_never_retry() { + for operation in ["suspend", "resume", "delete"] { + for (status, response) in [ + ( + 503, + json!({"code":"unavailable", "message":"lost acknowledgement"}), + ), + (200, json!("unexpected response")), + ( + 200, + json!({"workstationId":"wrong-id", "message":"transition initiated"}), + ), + (200, json!({"message":"transition initiated"})), + ] { + let auth = auth_service(); + let service = lifecycle_service("known-id", status, response); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + operation, + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert_eq!(output.status.code(), Some(5)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], "lifecycle_outcome_unknown"); + let message = failure["error"]["message"].as_str().unwrap(); + assert!(message.contains(operation)); + assert!(message.contains("bl blox get known-id")); + assert!(message.contains("--base-url")); + assert!(message.contains("before trying again")); + assert_lifecycle_requests(auth, service, operation, "known-id"); + } + } + } + + #[test] + fn lifecycle_unexpected_status_does_not_claim_acceptance() { + for (operation, status) in [ + ("suspend", 0), + ("suspend", 10), + ("resume", 999), + ("resume", 7), + ] { + let auth = auth_service(); + let service = lifecycle_service( + "known-id", + 200, + json!({"workstationId":"known-id", "status":status, "message":"inspect transition"}), + ); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + operation, + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert_eq!(output.status.code(), Some(5)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], "lifecycle_outcome_unknown"); + assert!(failure["error"]["message"] + .as_str() + .unwrap() + .contains("inspect transition")); + assert_lifecycle_requests(auth, service, operation, "known-id"); + } + } + + #[test] + fn lifecycle_rejected_authentication_stops_before_workstation_resolution() { + for operation in ["suspend", "resume", "delete"] { + let auth = MockService::start(401, json!({"error":"expired"})); + let output = run_process( + &auth, + &auth, + &[ + "--json", + "blox", + operation, + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert_eq!(output.status.code(), Some(3)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], "auth_required"); + assert_eq!(auth.finish().0, "/api/goose/v1/auth/me"); + } + } + + #[test] + fn lifecycle_definite_server_rejections_preserve_the_error_code() { + for operation in ["suspend", "resume", "delete"] { + let auth = auth_service(); + let service = lifecycle_service( + "known-id", + 400, + json!({"code":"failed_precondition", "message":"transition is not allowed"}), + ); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + operation, + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert_eq!(output.status.code(), Some(1)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], "failed_precondition"); + assert_lifecycle_requests(auth, service, operation, "known-id"); + } + } +} diff --git a/src/bl/mod.rs b/src/bl/mod.rs index ff1732e..2710213 100644 --- a/src/bl/mod.rs +++ b/src/bl/mod.rs @@ -5,6 +5,7 @@ pub mod apps; pub mod auth; pub mod auth_login; pub mod auth_storage; +pub mod blox; pub mod description; pub mod display; pub mod org_routing; diff --git a/src/bl/runner.rs b/src/bl/runner.rs index 457b485..095506e 100644 --- a/src/bl/runner.rs +++ b/src/bl/runner.rs @@ -42,6 +42,7 @@ fn run_resolved( ) -> Result<()> { match dispatch(config, matches) { Ok(()) => Ok(()), + Err(error) if error.downcast_ref::().is_some() => Err(error), Err(error) if config.json => { let (exit_code, payload) = failure_info(&error); eprintln!("{payload}"); diff --git a/src/bl/skills_api.rs b/src/bl/skills_api.rs index 840d499..c9b7984 100644 --- a/src/bl/skills_api.rs +++ b/src/bl/skills_api.rs @@ -75,8 +75,8 @@ pub fn failure(exit_code: i32, code: &str, message: impl Into) -> anyhow anyhow::Error::new(CliFailure::new(exit_code, code, message)) } -/// Marker error: the failure was already reported as structured JSON on -/// stderr; `bl_main` should exit with this code without printing again. +/// Marker for an already reported result or remote process exit status. +/// `bl_main` should exit with this code without printing again. #[derive(Debug)] pub struct SilentJsonExit(pub i32); diff --git a/src/lib.rs b/src/lib.rs index 268733d..de60a67 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -89,8 +89,8 @@ pub fn agent_tools_main() { pub fn bl_main() { if let Err(err) = run_bl() { - // `bl skills --json` failures already printed a structured error to - // stderr; exit silently with the recorded code. + // JSON failures and remote process results are already reported; + // exit silently with the recorded code. if let Some(silent) = err .chain() .find_map(|cause| cause.downcast_ref::()) @@ -132,6 +132,7 @@ fn run_bl_with_argv(argv: Vec) -> Result<()> { bl::skills::describe_auth_commands(), bl::workspace::describe_commands(), bl::apps::describe_commands(), + bl::blox::describe_commands(), bl::skills::describe_config_commands(), bl::skills::describe_commands(), bl::agents::describe_commands(), @@ -154,6 +155,7 @@ fn run_bl_with_argv(argv: Vec) -> Result<()> { Some(("auth", auth_matches)) => bl::skills::run_auth(auth_matches), Some(("workspace", workspace_matches)) => bl::workspace::run(workspace_matches), Some(("apps", apps_matches)) => bl::apps::run(apps_matches), + Some(("blox", blox_matches)) => bl::blox::run(blox_matches), Some(("config", config_matches)) => bl::skills::run_config(config_matches), Some(("completions", completions_matches)) => { let shell = completions_matches @@ -180,6 +182,7 @@ fn build_bl_command() -> Command { .subcommand(bl::skills::auth_command()) .subcommand(bl::workspace::command()) .subcommand(bl::apps::command()) + .subcommand(bl::blox::command()) .subcommand(bl::skills::config_command()) .subcommand(bl::skills::skills_command()) .subcommand(bl::agents::agents_command()) diff --git a/tests/blox_e2e.rs b/tests/blox_e2e.rs new file mode 100644 index 0000000..a67cdc4 --- /dev/null +++ b/tests/blox_e2e.rs @@ -0,0 +1,147 @@ +//! Offline checks of the compiled Blox command surface. +use std::process::{Command, Output}; + +use serde_json::Value; +use tempfile::TempDir; + +fn offline_command() -> (Command, TempDir) { + let home = tempfile::tempdir().expect("temporary bl home"); + let mut command = Command::new(env!("CARGO_BIN_EXE_bl")); + command + .env_clear() + .env("BL_HOME", home.path()) + .env("HOME", home.path()) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", home.path().join("session.json")) + .env("KGOOSE_BASE_URL", "https://app.builderlab.xyz"); + (command, home) +} + +fn text(output: &Output) -> (String, String) { + ( + String::from_utf8(output.stdout.clone()).expect("stdout UTF-8"), + String::from_utf8(output.stderr.clone()).expect("stderr UTF-8"), + ) +} + +#[test] +fn blox_help_is_available_without_authentication_or_network() { + for args in [ + vec!["blox", "--help"], + vec!["blox", "list", "--help"], + vec!["blox", "get", "--help"], + vec!["blox", "create", "--help"], + vec!["blox", "skus", "--help"], + vec!["blox", "exec", "--help"], + vec!["blox", "suspend", "--help"], + vec!["blox", "resume", "--help"], + vec!["blox", "delete", "--help"], + ] { + let (mut command, _home) = offline_command(); + let output = command.args(&args).output().expect("bl help"); + let (stdout, stderr) = text(&output); + assert!(output.status.success(), "{stderr}"); + assert!(stdout.contains("Usage: bl blox")); + assert!(!stdout.contains("--token")); + assert!(!stdout.contains("StartWorkstation")); + } +} + +#[test] +fn blox_is_registered_in_descriptions_and_shell_completions() { + let (mut command, _home) = offline_command(); + let output = command + .arg("--describe-commands") + .output() + .expect("describe bl"); + assert!(output.status.success()); + let description: Value = serde_json::from_slice(&output.stdout).expect("command description"); + let blox = description["commands"] + .as_array() + .unwrap() + .iter() + .find(|command| command["name"] == "blox") + .expect("blox namespace"); + let mut names: Vec<_> = blox["commands"] + .as_array() + .unwrap() + .iter() + .map(|command| command["name"].as_str().unwrap()) + .collect(); + names.sort_unstable(); + assert_eq!( + names, + ["create", "delete", "exec", "get", "list", "resume", "skus", "suspend"] + ); + + let (mut command, _home) = offline_command(); + let output = command + .args(["completions", "bash"]) + .output() + .expect("bl completions"); + assert!(output.status.success()); + assert!(text(&output).0.contains("blox")); +} + +#[test] +fn invalid_blox_inputs_fail_before_session_loading() { + for args in [ + vec!["blox", "list", "--limit=-1"], + vec!["blox", "list", "--offset=-1"], + vec!["blox", "get"], + vec!["blox", "execute"], + vec!["blox", "exec"], + vec!["blox", "suspend"], + vec!["blox", "resume"], + vec!["blox", "delete"], + vec!["blox", "exec", "my-workstation"], + vec!["blox", "create", "--multiple", "0"], + vec!["blox", "create", "--multiple=-1"], + vec!["blox", "create", "--repo", "https://example.com/repo"], + vec!["blox", "create", "--cpu", "2"], + vec!["blox", "create", "--memory", "4"], + vec!["blox", "create", "--provider", "kube"], + vec!["blox", "create", "--architecture", "amd64"], + vec![ + "blox", + "create", + "my-workstation", + "https://example.com/repo", + ], + ] { + let (mut command, _home) = offline_command(); + let output = command.args(&args).output().expect("invalid bl input"); + let (_, stderr) = text(&output); + assert_eq!(output.status.code(), Some(2), "{stderr}"); + assert!(!stderr.contains("auth login")); + } + + for (args, expected_code) in [ + ( + vec!["--json", "blox", "exec", "ws", "--", ""], + "invalid_command", + ), + ( + vec!["--json", "blox", "list", "--limit", "0", "--offset", "1"], + "invalid_pagination", + ), + ( + vec![ + "--json", + "blox", + "list", + "--base-url", + "https://unapproved.example", + ], + "invalid_endpoint", + ), + ] { + let (mut command, _home) = offline_command(); + let output = command.args(&args).output().expect("early validation"); + let (_, stderr) = text(&output); + assert_eq!(output.status.code(), Some(1), "{stderr}"); + let error: Value = serde_json::from_str(&stderr).expect("JSON failure"); + assert_eq!(error["error"]["code"], expected_code); + assert!(!stderr.contains("auth login")); + } +} From b544e0ba569e69ed8078b09458a8795d7d3af7f7 Mon Sep 17 00:00:00 2001 From: Stuart Douglas Date: Wed, 14 Oct 2026 13:47:02 +1100 Subject: [PATCH 2/4] refactor: split Blox CLI by command --- src/bl/blox.rs | 1862 ----------------------------------- src/bl/blox/context.rs | 200 ++++ src/bl/blox/create.rs | 234 +++++ src/bl/blox/delete.rs | 187 ++++ src/bl/blox/exec.rs | 409 ++++++++ src/bl/blox/get.rs | 141 +++ src/bl/blox/lifecycle.rs | 112 +++ src/bl/blox/list.rs | 162 +++ src/bl/blox/mod.rs | 60 ++ src/bl/blox/output.rs | 25 + src/bl/blox/resume.rs | 258 +++++ src/bl/blox/skus.rs | 74 ++ src/bl/blox/suspend.rs | 219 ++++ src/bl/blox/test_support.rs | 258 +++++ 14 files changed, 2339 insertions(+), 1862 deletions(-) delete mode 100644 src/bl/blox.rs create mode 100644 src/bl/blox/context.rs create mode 100644 src/bl/blox/create.rs create mode 100644 src/bl/blox/delete.rs create mode 100644 src/bl/blox/exec.rs create mode 100644 src/bl/blox/get.rs create mode 100644 src/bl/blox/lifecycle.rs create mode 100644 src/bl/blox/list.rs create mode 100644 src/bl/blox/mod.rs create mode 100644 src/bl/blox/output.rs create mode 100644 src/bl/blox/resume.rs create mode 100644 src/bl/blox/skus.rs create mode 100644 src/bl/blox/suspend.rs create mode 100644 src/bl/blox/test_support.rs diff --git a/src/bl/blox.rs b/src/bl/blox.rs deleted file mode 100644 index 1a3be73..0000000 --- a/src/bl/blox.rs +++ /dev/null @@ -1,1862 +0,0 @@ -//! Blox workstation commands using the shared BuilderLab session. - -use std::io::Write; - -use anyhow::{Context, Result}; -use blox::proto::{ - ComputeSkuSelection, DeleteWorkstationRequest, ExecuteProcessRequest, ListWorkstationsRequest, - ResumeWorkstationRequest, StartWorkstationRequest, StreamCommandOutputRequest, - SuspendWorkstationRequest, WorkstationStatus, WorkstationSummary, -}; -use clap::{Arg, ArgAction, ArgMatches, Command}; -use serde::Serialize; -use serde_json::Value; - -use super::auth_login::verify_stored_session; -use super::auth_storage::default_session_storage; -use super::display::{print_json, terminal_safe_text}; -use super::runner; -use super::skills_api::{exit_codes, failure, SilentJsonExit}; -use super::skills_config::SkillsConfig; - -const ENDPOINT_ENV: &str = "BL_BLOX_CONTROL_PLANE_URL"; -const APPROVED_HOSTS: &[&str] = &[ - "blox.test.blockstaging.build", - "blox.public.builderlabstaging.xyz", - "blox.public.builderlab.xyz", -]; - -pub fn command() -> Command { - endpoint_arg(Command::new("blox") - .about("Manage Blox workstations") - .long_about("Manage your Blox workstations using your BuilderLab login. Names are resolved by the orchestrator; use the full ID to identify an archived workstation.") - .subcommand_required(true) - .arg_required_else_help(true) - .disable_help_subcommand(true) - .subcommand( - Command::new("list") - .about("List your workstations") - .arg(Arg::new("include-deleted").long("include-deleted").action(ArgAction::SetTrue).help("Include archived workstations")) - .arg(Arg::new("limit").long("limit").value_name("COUNT").value_parser(clap::value_parser!(i32).range(0..)).default_value("100").help("Maximum results (0 means all)")) - .arg(Arg::new("offset").long("offset").value_name("COUNT").value_parser(clap::value_parser!(i32).range(0..)).default_value("0").help("Number of results to skip")), - ) - .subcommand(Command::new("skus").about("List available workstation SKUs and maximum multiples")) - .subcommand( - Command::new("create") - .about("Create a new workstation with a SKU and multiple") - .long_about("Create a new workstation without requesting project repositories. The deployment may still bootstrap its configured home repository. Returns the initial ID and status; creation continues on the server.") - .arg(Arg::new("name").value_name("NAME").value_parser(clap::builder::NonEmptyStringValueParser::new()).help("Optional workstation name (otherwise generated by the server)")) - .arg(Arg::new("sku").long("sku").value_name("CODE").default_value("DEFAULT_AMD64").value_parser(clap::builder::NonEmptyStringValueParser::new()).help("SKU code from `bl blox skus`")) - .arg(Arg::new("multiple").long("multiple").value_name("COUNT").default_value("1").value_parser(clap::value_parser!(i32).range(1..)).help("SKU multiple")), - ) - .subcommand( - Command::new("exec") - .about("Run a command on a workstation and follow its output") - .long_about("Run a literal executable and arguments after --, stream stdout/stderr, and exit with the remote process status. Use explicit sh -c for shell expansion or pipelines. This is noninteractive: stdin and PTY are not supported. Interrupting or disconnecting may leave the command running. --json prints JSONL output events (protobuf bytes use base64), followed by an exit_code event.") - .arg(Arg::new("workstation").value_name("WORKSTATION").required(true).value_parser(clap::builder::NonEmptyStringValueParser::new())) - .arg(Arg::new("workdir").long("workdir").value_name("PATH").value_parser(clap::builder::NonEmptyStringValueParser::new()).help("Remote working directory (defaults to the workstation's configured directory)")) - .arg(Arg::new("command").value_name("COMMAND").num_args(1..).last(true).required(true).help("Executable and arguments passed literally to the remote process")), - ) - .subcommand(lifecycle_command("suspend", "Request workstation suspension")) - .subcommand(lifecycle_command("resume", "Request workstation resumption")) - .subcommand(lifecycle_command("delete", "Request workstation deletion")) - .subcommand( - Command::new("get") - .about("Get a workstation by canonical name or full ID") - .arg(Arg::new("workstation").value_name("WORKSTATION").required(true).value_parser(clap::builder::NonEmptyStringValueParser::new())), - )) -} - -fn lifecycle_command(name: &'static str, about: &'static str) -> Command { - Command::new(name).about(about) - .long_about("Request this transition for a workstation identified by canonical name or full ID. Reports the server response without waiting for the final state; inspect progress with `bl blox get `. Use the full ID for an archived workstation.") - .arg(Arg::new("workstation").value_name("WORKSTATION").required(true).value_parser(clap::builder::NonEmptyStringValueParser::new())) -} - -fn endpoint_arg(command: Command) -> Command { - command.arg( - Arg::new("blox-base-url") - .long("base-url") - .visible_alias("control-plane-url") - .env(ENDPOINT_ENV) - .global(true) - .value_name("URL") - .help( - "Approved BuilderLab Blox control-plane URL (defaults to your login environment)", - ), - ) -} - -pub fn describe_commands() -> Value { - super::description::describe_command_tree(&command()) -} - -pub fn run(matches: &ArgMatches) -> Result<()> { - runner::run(matches, dispatch) -} - -fn dispatch(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { - let (operation, args) = matches.subcommand().context("expected a blox subcommand")?; - if operation == "list" - && args.get_one::("limit") == Some(&0) - && args - .get_one::("offset") - .is_some_and(|offset| *offset > 0) - { - return Err(failure( - exit_codes::GENERAL, - "invalid_pagination", - "--offset requires a nonzero --limit", - )); - } - if operation == "exec" - && args - .get_many::("command") - .and_then(|mut command| command.next()) - .is_some_and(|command| command.is_empty()) - { - return Err(failure( - exit_codes::GENERAL, - "invalid_command", - "The remote executable must not be empty", - )); - } - let endpoint = args - .get_one::("blox-base-url") - .cloned() - .map(Ok) - .unwrap_or_else(|| default_endpoint(&config.kgoose_base_url))?; - validate_endpoint(&endpoint)?; - runner::ensure_org_configured(config)?; - let storage = default_session_storage(config)?; - let verified = verify_stored_session(config, storage.as_ref()) - .map_err(map_error)? - .ok_or_else(|| { - failure( - exit_codes::AUTH_REQUIRED, - "auth_required", - "BuilderLab CLI auth is required; run `bl auth login`", - ) - })?; - let secret = verified - .credential - .session_credential_header_value() - .ok_or_else(|| { - failure( - exit_codes::AUTH_REQUIRED, - "auth_required", - "BuilderLab CLI auth is required; run `bl auth login`", - ) - })?; - // Test-only remapping leaves production origin validation and auth intact. - #[cfg(test)] - let endpoint = std::env::var("BL_BLOX_E2E_ENDPOINT").unwrap_or(endpoint); - let client = blox::Client::new(&endpoint)?.with_session_credential(&secret)?; - config.style.verbose(&format!("Blox {operation}")); - match operation { - "exec" => run_execute(config, args, &client), - "suspend" | "resume" | "delete" => run_lifecycle(config, args, &client, operation), - "skus" => { - let response = client.list_compute_providers().map_err(map_error)?; - if config.json { - print_json(&response) - } else { - if response.compute_skus.is_empty() { - println!("No workstation SKUs are available for your account."); - } else { - println!("CODE\tNAME\tMAX MULTIPLE\tPROVIDER"); - for sku in &response.compute_skus { - println!( - "{}\t{}\t{}\t{}", - terminal_safe_text(&sku.code), - terminal_safe_text(&sku.display_name), - sku.max_multiple, - terminal_safe_text(&sku.compute_provider) - ); - } - } - Ok(()) - } - } - "create" => { - let request = StartWorkstationRequest { - name: args.get_one::("name").cloned(), - compute_sku: Some(ComputeSkuSelection { - code: args - .get_one::("sku") - .context("expected SKU code")? - .clone(), - multiple: *args - .get_one::("multiple") - .context("expected SKU multiple")?, - }), - creation_client: "builderlab_cli".to_string(), - ..Default::default() - }; - let response = client - .start_workstation(&request) - .map_err(map_create_error)?; - if response.workstation_id.is_empty() { - return Err(create_unknown_outcome()); - } - if config.json { - print_json(&response) - } else { - println!("ID: {}", terminal_safe_text(&response.workstation_id)); - println!("Status: {}", status_text(response.status)); - if !response.message.is_empty() { - println!("Message: {}", terminal_safe_text(&response.message)); - } - println!( - "Inspect with `bl blox get {}` (preserve any --base-url override).", - terminal_safe_text(&response.workstation_id) - ); - Ok(()) - } - } - "list" => { - let response = client - .list_workstations(&ListWorkstationsRequest { - include_deleted: Some(args.get_flag("include-deleted")), - page_limit: *args - .get_one::("limit") - .context("expected result limit")?, - page_offset: *args - .get_one::("offset") - .context("expected result offset")?, - skip_access_details: true, - }) - .map_err(map_error)?; - if config.json { - print_json(&response) - } else { - if response.workstations.is_empty() { - println!("No workstations found."); - } else { - println!("ID\tNAME\tSTATUS\tPROVIDER"); - for workstation in &response.workstations { - println!( - "{}\t{}\t{}\t{}", - terminal_safe_text(&workstation.workstation_id), - terminal_safe_text(&workstation.name), - status_text(workstation.status), - terminal_safe_text(&workstation.provider) - ); - } - println!( - "{} of {} workstations", - response.workstations.len(), - response.total_count - ); - } - Ok(()) - } - } - "get" => { - let identifier = args - .get_one::("workstation") - .context("expected workstation name or ID")?; - let response = client.get_workstation(identifier).map_err(map_error)?; - let workstation = response.workstation.as_ref().ok_or_else(|| { - failure( - exit_codes::GENERAL, - "invalid_response", - "Blox returned no workstation", - ) - })?; - if config.json { - print_json(&response) - } else { - print_workstation(workstation); - Ok(()) - } - } - _ => anyhow::bail!("expected a blox subcommand"), - } -} - -fn run_lifecycle( - config: &SkillsConfig, - args: &ArgMatches, - client: &blox::Client, - operation: &str, -) -> Result<()> { - let selector = args - .get_one::("workstation") - .context("expected workstation")?; - let workstation = client - .get_workstation(selector) - .map_err(map_error)? - .workstation - .filter(|workstation| !workstation.workstation_id.is_empty()) - .ok_or_else(|| { - failure( - exit_codes::GENERAL, - "invalid_response", - "Blox returned no workstation ID", - ) - })?; - let id = &workstation.workstation_id; - match operation { - "suspend" => { - let response = client - .suspend_workstation(&SuspendWorkstationRequest { - workstation_id: id.clone(), - }) - .map_err(|error| map_lifecycle_error(error, operation, id))?; - print_lifecycle_result( - config, - operation, - id, - LifecycleSummary { - workstation_id: &response.workstation_id, - status: Some(response.status), - success: true, - message: &response.message, - }, - &response, - ) - } - "resume" => { - let response = client - .resume_workstation(&ResumeWorkstationRequest { - workstation_id: id.clone(), - }) - .map_err(|error| map_lifecycle_error(error, operation, id))?; - print_lifecycle_result( - config, - operation, - id, - LifecycleSummary { - workstation_id: &response.workstation_id, - status: Some(response.status), - success: true, - message: &response.message, - }, - &response, - ) - } - "delete" => { - let response = client - .delete_workstation(&DeleteWorkstationRequest { - workstation_id: id.clone(), - }) - .map_err(|error| map_lifecycle_error(error, operation, id))?; - print_lifecycle_result( - config, - operation, - id, - LifecycleSummary { - workstation_id: &response.workstation_id, - status: None, - success: response.success, - message: &response.message, - }, - &response, - ) - } - _ => anyhow::bail!("expected a lifecycle operation"), - } -} - -struct LifecycleSummary<'a> { - workstation_id: &'a str, - status: Option, - success: bool, - message: &'a str, -} - -fn print_lifecycle_result( - config: &SkillsConfig, - operation: &str, - requested_id: &str, - summary: LifecycleSummary<'_>, - response: &T, -) -> Result<()> { - if summary.workstation_id != requested_id { - return Err(lifecycle_unknown( - operation, - requested_id, - Some(summary.message), - )); - } - if !summary.success - || matches!(summary.status, Some(status) if status == WorkstationStatus::Error as i32 || status == WorkstationStatus::Degraded as i32) - { - return Err(failure( - exit_codes::GENERAL, - "lifecycle_failed", - format!( - "Blox {operation} failed for workstation {}: {}", - terminal_safe_text(requested_id), - if summary.message.is_empty() { - "server reported an unsuccessful transition".to_string() - } else { - terminal_safe_text(summary.message) - } - ), - )); - } - if let Some(status) = summary.status { - let accepted = WorkstationStatus::try_from(status).is_ok_and(|status| match operation { - "suspend" => matches!( - status, - WorkstationStatus::Suspending - | WorkstationStatus::Suspended - | WorkstationStatus::Deleting - | WorkstationStatus::Deleted - ), - "resume" => matches!( - status, - WorkstationStatus::Queued - | WorkstationStatus::Creating - | WorkstationStatus::Starting - | WorkstationStatus::Running - ), - _ => false, - }); - if !accepted { - return Err(lifecycle_unknown( - operation, - requested_id, - Some(summary.message), - )); - } - } - if config.json { - return print_json(response); - } - println!("Request accepted: {operation}"); - println!("ID: {}", terminal_safe_text(summary.workstation_id)); - if let Some(status) = summary.status { - println!("Server status: {}", status_text(status)); - } - if !summary.message.is_empty() { - println!("Message: {}", terminal_safe_text(summary.message)); - } - println!( - "Inspect progress with `bl blox get {}` (preserve any --base-url override).", - terminal_safe_text(requested_id) - ); - Ok(()) -} - -fn lifecycle_unknown(operation: &str, id: &str, server_message: Option<&str>) -> anyhow::Error { - let mut message = format!("Blox {operation} result is unknown for workstation {}. Inspect `bl blox get {}` with the same Blox endpoint before trying again; the transition may already be underway. Preserve any --base-url override.", terminal_safe_text(id), terminal_safe_text(id)); - if let Some(server_message) = server_message.filter(|message| !message.is_empty()) { - message.push_str(&format!( - " Server message: {}", - terminal_safe_text(server_message) - )); - } - failure(exit_codes::NETWORK, "lifecycle_outcome_unknown", message) -} - -fn map_lifecycle_error(error: anyhow::Error, operation: &str, id: &str) -> anyhow::Error { - if let Some(rpc) = error.downcast_ref::() { - if !matches!( - rpc.code.as_str(), - "unavailable" | "deadline_exceeded" | "unknown" | "internal" | "data_loss" - ) { - return map_error(error); - } - } - lifecycle_unknown(operation, id, None) -} - -fn run_execute(config: &SkillsConfig, args: &ArgMatches, client: &blox::Client) -> Result<()> { - let selector = args - .get_one::("workstation") - .context("expected workstation")?; - let workstation = client - .get_workstation(selector) - .map_err(map_error)? - .workstation - .filter(|workstation| !workstation.workstation_id.is_empty()) - .ok_or_else(|| { - failure( - exit_codes::GENERAL, - "invalid_response", - "Blox returned no workstation ID", - ) - })?; - let mut command = args - .get_many::("command") - .context("expected command")?; - let executable = command.next().context("expected executable")?.clone(); - let response = client - .execute_process(&ExecuteProcessRequest { - workstation_id: workstation.workstation_id.clone(), - command: executable, - args: command.cloned().collect(), - working_dir: args - .get_one::("workdir") - .cloned() - .unwrap_or_default(), - ..Default::default() - }) - .map_err(|error| map_execute_start_error(error, &workstation.workstation_id))?; - if response.command_id.is_empty() { - return Err(execute_start_unknown(&workstation.workstation_id)); - } - let command_id = response.command_id; - config - .style - .verbose(&format!("Following Blox command {command_id}")); - let mut exit_code = None; - client - .stream_command_output( - &StreamCommandOutputRequest { - workstation_id: workstation.workstation_id.clone(), - command_id: command_id.clone(), - follow: true, - ..Default::default() - }, - |output| { - if config.json { - write_json_line(&serde_json::json!({ - "workstation_id":workstation.workstation_id, - "command_id":command_id, - "output":output, - }))?; - } else if !output.data.is_empty() { - match output.stream { - 1 => { - let mut stdout = std::io::stdout().lock(); - stdout.write_all(&output.data)?; - stdout.flush()?; - } - 2 => { - let mut stderr = std::io::stderr().lock(); - stderr.write_all(&output.data)?; - stderr.flush()?; - } - _ => anyhow::bail!("Blox returned output with an unsupported stream type"), - } - } - if let Some(code) = output.exit_code { - exit_code = Some(code); - } - Ok(()) - }, - ) - .map_err(|_| execute_follow_failed(&workstation.workstation_id, &command_id))?; - let exit_code = - exit_code.ok_or_else(|| execute_follow_failed(&workstation.workstation_id, &command_id))?; - if config.json { - write_json_line( - &serde_json::json!({"workstation_id":workstation.workstation_id, "command_id":command_id, "exit_code":exit_code}), - )?; - } - if exit_code != 0 { - return Err(anyhow::Error::new(SilentJsonExit(exit_code))); - } - Ok(()) -} - -fn write_json_line(value: &Value) -> Result<()> { - let mut stdout = std::io::stdout().lock(); - serde_json::to_writer(&mut stdout, value).context("write Blox JSONL output")?; - stdout - .write_all(b"\n") - .context("write Blox JSONL newline")?; - stdout.flush().context("flush Blox JSONL output") -} - -fn execute_start_unknown(workstation_id: &str) -> anyhow::Error { - failure(exit_codes::NETWORK, "execute_outcome_unknown", format!("Blox execution result is unknown on workstation {}. Inspect its commands in Blox before running it again; the command may already be running. Preserve any --base-url override.", terminal_safe_text(workstation_id))) -} - -fn map_execute_start_error(error: anyhow::Error, workstation_id: &str) -> anyhow::Error { - if let Some(rpc) = error.downcast_ref::() { - if !matches!( - rpc.code.as_str(), - "unavailable" | "deadline_exceeded" | "unknown" | "internal" | "data_loss" - ) { - return map_error(error); - } - } - execute_start_unknown(workstation_id) -} - -fn execute_follow_failed(workstation_id: &str, command_id: &str) -> anyhow::Error { - failure(exit_codes::NETWORK, "execute_output_incomplete", format!("Could not confirm the result of Blox command {} on workstation {}. The command may still be running; inspect it in Blox before running it again. Preserve any --base-url override.", terminal_safe_text(command_id), terminal_safe_text(workstation_id))) -} - -fn default_endpoint(auth_base_url: &str) -> Result { - let origin = url::Url::parse(auth_base_url) - .context("parse BuilderLab auth URL")? - .origin() - .ascii_serialization(); - let endpoint = match origin.as_str() { - "https://test.blockstaging.build" => "https://blox.test.blockstaging.build", - "https://app.builderlabstaging.xyz" => "https://blox.public.builderlabstaging.xyz", - "https://app.builderlab.xyz" => "https://blox.public.builderlab.xyz", - "https://block.builderlab.xyz" => return Err(failure(exit_codes::GENERAL, "blox_unavailable", "Blox is not deployed in this BuilderLab environment")), - _ => return Err(failure(exit_codes::GENERAL, "endpoint_required", "No Blox endpoint is configured for this BuilderLab environment; set an approved ingress with --base-url or BL_BLOX_CONTROL_PLANE_URL")), - }; - Ok(endpoint.to_string()) -} - -fn validate_endpoint(endpoint: &str) -> Result<()> { - let url = url::Url::parse(endpoint).context("parse Blox control-plane URL")?; - let approved = url.scheme() == "https" - && url.port_or_known_default() == Some(443) - && url.username().is_empty() - && url.password().is_none() - && url.path() == "/" - && url.query().is_none() - && url.fragment().is_none() - && url.host_str().is_some_and(|host| { - APPROVED_HOSTS - .iter() - .any(|approved| host.eq_ignore_ascii_case(approved)) - }); - if !approved { - return Err(failure( - exit_codes::GENERAL, - "invalid_endpoint", - "Blox control-plane URL must target an approved BuilderLab ingress over HTTPS", - )); - } - Ok(()) -} - -fn status_text(status: i32) -> String { - WorkstationStatus::try_from(status) - .map(|status| { - status - .as_str_name() - .trim_start_matches("WORKSTATION_STATUS_") - .to_ascii_lowercase() - }) - .unwrap_or_else(|_| format!("unknown({status})")) -} - -fn print_workstation(workstation: &WorkstationSummary) { - for (label, value) in [ - ("ID", workstation.workstation_id.as_str()), - ("Name", workstation.name.as_str()), - ("Provider", workstation.provider.as_str()), - ("Created", workstation.created_at.as_str()), - ("Last used", workstation.last_used_at.as_str()), - ("Message", workstation.message.as_str()), - ] { - println!("{label}: {}", terminal_safe_text(value)); - } - println!("Status: {}", status_text(workstation.status)); - if let Some(label) = &workstation.display_name { - println!("Display name: {}", terminal_safe_text(label)); - } -} - -fn create_unknown_outcome() -> anyhow::Error { - failure(exit_codes::NETWORK, "create_outcome_unknown", "Blox creation result is unknown. Inspect `bl blox list` or `bl blox get ` before trying again; the workstation may already exist. Preserve any --base-url override when checking.") -} - -fn map_create_error(error: anyhow::Error) -> anyhow::Error { - if let Some(rpc) = error.downcast_ref::() { - if !matches!( - rpc.code.as_str(), - "unavailable" | "deadline_exceeded" | "unknown" | "internal" | "data_loss" - ) { - return map_error(error); - } - } - create_unknown_outcome() -} - -fn map_error(error: anyhow::Error) -> anyhow::Error { - if let Some(rpc) = error.downcast_ref::() { - let exit_code = match rpc.code.as_str() { - "unauthenticated" => exit_codes::AUTH_REQUIRED, - "permission_denied" => exit_codes::FORBIDDEN, - "unavailable" | "deadline_exceeded" => exit_codes::NETWORK, - _ => exit_codes::GENERAL, - }; - let message = if rpc.code == "unauthenticated" { - "Blox authentication failed; run `bl auth login`".to_string() - } else { - format!("Blox {}: {}", rpc.code, terminal_safe_text(&rpc.message)) - }; - return failure(exit_code, &rpc.code, message); - } - if error.chain().any(|cause| cause.is::()) { - return failure(exit_codes::NETWORK, "network_error", format!("{error:#}")); - } - error -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeMap; - use std::process::{Command as ProcessCommand, Output}; - use std::thread; - use std::time::Duration; - - use serde_json::json; - use sha2::{Digest, Sha256}; - use tiny_http::{Header, Response, Server}; - - use super::*; - - #[test] - fn defaults_follow_exact_login_origins() { - for (auth, blox) in [ - ( - "https://test.blockstaging.build", - "https://blox.test.blockstaging.build", - ), - ( - "https://app.builderlabstaging.xyz", - "https://blox.public.builderlabstaging.xyz", - ), - ( - "https://app.builderlab.xyz", - "https://blox.public.builderlab.xyz", - ), - ] { - assert_eq!(default_endpoint(auth).unwrap(), blox); - assert!(validate_endpoint(blox).is_ok()); - } - assert!(default_endpoint("https://block.builderlab.xyz") - .unwrap_err() - .to_string() - .contains("not deployed")); - for origin in [ - "http://app.builderlab.xyz", - "https://app.builderlab.xyz:8443", - "https://app.builderlab.xyz.evil.test", - ] { - assert!(default_endpoint(origin).is_err()); - } - } - - #[test] - fn session_origin_validation_rejects_unapproved_destinations() { - for endpoint in [ - "http://blox.public.builderlab.xyz", - "https://blox.public.builderlab.xyz:8443", - "https://blox.public.builderlab.xyz.evil.test", - "https://user@blox.public.builderlab.xyz", - "https://blox.public.builderlab.xyz/path", - "https://blox.public.builderlab.xyz/?q=1", - "https://blox.public.builderlab.xyz/#fragment", - "http://127.0.0.1:8895", - ] { - assert!(validate_endpoint(endpoint).is_err(), "accepted {endpoint}"); - } - } - - #[test] - fn unknown_and_transient_statuses_are_displayable() { - assert_eq!(status_text(999), "unknown(999)"); - assert_eq!(status_text(9), "pending"); - assert_eq!(status_text(10), "queued"); - assert_eq!(status_text(11), "suspending"); - assert_eq!(status_text(12), "deleting"); - } - - // Runs the CLI in its own process so environment-based auth is isolated - // from other unit tests and uses the real stored-session verification. - #[test] - fn blox_process_helper() { - let Ok(argv) = std::env::var("BL_BLOX_E2E_ARGS") else { - return; - }; - println!("BLOX_E2E_OUTPUT_BEGIN"); - let result = crate::run_bl_with_argv(serde_json::from_str(&argv).unwrap()); - if let Err(error) = result { - if let Some(silent) = error.downcast_ref::() { - std::process::exit(silent.0); - } - eprintln!("error: {error:#}"); - std::process::exit(super::super::skills_api::failure_info(&error).0); - } - println!("BLOX_E2E_OUTPUT_END"); - } - - type RecordedRequest = (String, BTreeMap, Value); - - enum MockReply { - Json(u16, Value), - Stream(Vec, Value), - } - - struct MockService { - url: String, - request: thread::JoinHandle>, - } - - impl MockService { - fn start(status: u16, value: Value) -> Self { - Self::steps(vec![MockReply::Json(status, value)]) - } - - fn steps(replies: Vec) -> Self { - let server = Server::http("127.0.0.1:0").unwrap(); - let url = format!("http://{}", server.server_addr()); - let request = thread::spawn(move || { - let mut requests = Vec::new(); - for reply in replies { - let mut request = server - .recv_timeout(Duration::from_secs(15)) - .unwrap() - .expect("mock request"); - let path = request.url().to_string(); - let headers = request - .headers() - .iter() - .map(|header| { - ( - header.field.to_string().to_ascii_lowercase(), - header.value.to_string(), - ) - }) - .collect(); - let mut body = Vec::new(); - request.as_reader().read_to_end(&mut body).unwrap(); - let body = if body.is_empty() { - Value::Null - } else if body[0] == 0 { - // Server-streaming Connect requests carry one framed JSON message. - serde_json::from_slice(&body[5..]).unwrap() - } else { - serde_json::from_slice(&body).unwrap() - }; - let (status, bytes, content_type) = match reply { - MockReply::Json(status, value) => ( - status, - serde_json::to_vec(&value).unwrap(), - "application/json", - ), - MockReply::Stream(outputs, terminal) => { - let mut bytes = Vec::new(); - for (flags, value) in outputs - .into_iter() - .map(|output| (0, output)) - .chain(std::iter::once((2, terminal))) - { - let body = serde_json::to_vec(&value).unwrap(); - bytes.push(flags); - bytes.extend_from_slice(&(body.len() as u32).to_be_bytes()); - bytes.extend_from_slice(&body); - } - (200, bytes, "application/connect+json") - } - }; - request - .respond( - Response::from_data(bytes) - .with_status_code(status) - .with_header( - Header::from_bytes("Content-Type", content_type).unwrap(), - ), - ) - .unwrap(); - requests.push((path, headers, body)); - } - requests - }); - Self { url, request } - } - - fn finish(self) -> RecordedRequest { - let mut requests = self.finish_all(); - assert_eq!(requests.len(), 1); - requests.pop().unwrap() - } - - fn finish_all(self) -> Vec { - self.request.join().unwrap() - } - } - - fn run_process(auth: &MockService, service: &MockService, args: &[&str]) -> Output { - let home = tempfile::tempdir().unwrap(); - std::fs::write(home.path().join("config.yaml"), "org: test\n").unwrap(); - let session_path = home.path().join("sessions.json"); - let service_url = format!("{}/api/goose", auth.url); - let mut hash = Sha256::new(); - hash.update(b"default"); - hash.update([0]); - hash.update(service_url.as_bytes()); - let key = hash - .finalize() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::(); - std::fs::write(&session_path, json!({ key: {"sessionCredential":"blox-test-only.session", "expiresAt":"2099-01-01T00:00:00Z"}}).to_string()).unwrap(); - let argv = std::iter::once("bl") - .chain(args.iter().copied()) - .map(str::to_string) - .collect::>(); - ProcessCommand::new(std::env::current_exe().unwrap()) - .args([ - "--exact", - "bl::blox::tests::blox_process_helper", - "--nocapture", - ]) - .env("BL_BLOX_E2E_ARGS", serde_json::to_string(&argv).unwrap()) - .env("BL_BLOX_E2E_ENDPOINT", &service.url) - .env("BL_HOME", home.path()) - .env("BL_AUTH_STORAGE", "file") - .env("BL_AUTH_STORAGE_FILE", session_path) - .env("KGOOSE_BASE_URL", &auth.url) - .env("KGOOSE_SERVICE_PATH", "/api/goose") - .env_remove("BL_SKILLS_CONFIG") - .env_remove("BL_SKILLS_PROFILE") - .env_remove("BL_KGOOSE_PLAYPEN") - .env_remove("KGOOSE_PLAYPEN") - .env_remove(ENDPOINT_ENV) - .output() - .unwrap() - } - - fn auth_service() -> MockService { - MockService::start( - 200, - json!({"subject":"test", "email":"blox-test@example.test", "name":"Test", "expires_at":"2099-01-01T00:00:00Z", "workspaces":{"active":[]}}), - ) - } - - fn assert_shared_auth(auth: MockService, service: MockService) -> (String, Value) { - let (auth_path, auth_headers, _) = auth.finish(); - assert_eq!(auth_path, "/api/goose/v1/auth/me"); - assert_eq!( - auth_headers["x-bb-session-credential"], - "blox-test-only.session" - ); - let (path, headers, body) = service.finish(); - assert_eq!( - headers["authorization"], - "BBIdentity blox-test-only.session" - ); - for header in [ - "cookie", - "x-bb-session-credential", - "x-forwarded-identity-token", - "x-forwarded-user", - "x-blox-workstation-key", - ] { - assert!(!headers.contains_key(header), "unexpected {header}"); - } - (path, body) - } - - #[test] - fn list_uses_shared_session_and_preserves_protobuf_json() { - let auth = auth_service(); - let service = MockService::start( - 200, - json!({"workstations":[{"workstationId":"9223372036854775806", "name":"project", "status":999}], "totalCount":"9007199254740993", "recentlyDeletedCount":"7"}), - ); - let output = run_process( - &auth, - &service, - &[ - "blox", - "--base-url", - "https://blox.public.builderlab.xyz", - "list", - "--include-deleted", - "--limit", - "2", - "--offset", - "3", - "--json", - ], - ); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("9007199254740993")); - assert!(stdout.contains("9223372036854775806")); - assert!(!stdout.contains("999")); // Forward enum variants become unspecified. - - let (path, body) = assert_shared_auth(auth, service); - assert!( - path.ends_with("/OrchestratorService/ListWorkstations") - || path.ends_with(".OrchestratorService/ListWorkstations") - ); - assert_eq!( - body, - json!({"includeDeleted":true, "pageLimit":2, "pageOffset":3, "skipAccessDetails":true}) - ); - } - - #[test] - fn get_forwards_the_selector_unchanged() { - let auth = auth_service(); - let service = MockService::start( - 200, - json!({"workstation":{"workstationId":"123", "name":"123", "status":"WORKSTATION_STATUS_SUSPENDING"}}), - ); - let output = run_process( - &auth, - &service, - &[ - "blox", - "get", - "123", - "--base-url", - "https://blox.test.blockstaging.build", - ], - ); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert!(String::from_utf8_lossy(&output.stdout).contains("Status: suspending")); - let (_, body) = assert_shared_auth(auth, service); - assert_eq!(body, json!({"workstation":"123"})); - } - - #[test] - fn errors_use_shared_json_format_and_redact_the_session() { - let auth = auth_service(); - let service = MockService::start( - 403, - json!({"code":"permission_denied", "message":"denied blox-test-only.session"}), - ); - let output = run_process( - &auth, - &service, - &[ - "blox", - "get", - "project", - "--base-url", - "https://blox.test.blockstaging.build", - "--json", - ], - ); - assert_eq!(output.status.code(), Some(4)); - let stderr = String::from_utf8_lossy(&output.stderr); - let payload: Value = serde_json::from_str(stderr.trim()).unwrap(); - assert_eq!(payload["error"]["code"], "permission_denied"); - assert_eq!(payload["error"]["exit_code"], 4); - assert!(!stderr.contains("blox-test-only.session")); - assert_shared_auth(auth, service); - } - #[test] - fn rejected_shared_session_stops_before_blox_request() { - let auth = MockService::start(401, json!({"error":"expired"})); - let output = run_process( - &auth, - &auth, - &[ - "blox", - "list", - "--base-url", - "https://blox.test.blockstaging.build", - "--json", - ], - ); - assert_eq!(output.status.code(), Some(3)); - let payload: Value = serde_json::from_slice(&output.stderr).unwrap(); - assert_eq!(payload["error"]["code"], "auth_required"); - assert!(payload["error"]["message"] - .as_str() - .unwrap() - .contains("bl auth login")); - let (path, _, _) = auth.finish(); - assert_eq!(path, "/api/goose/v1/auth/me"); - } - - #[test] - fn missing_workstation_is_an_error() { - let auth = auth_service(); - let service = MockService::start(200, json!({})); - let output = run_process( - &auth, - &service, - &[ - "blox", - "get", - "project", - "--base-url", - "https://blox.test.blockstaging.build", - "--json", - ], - ); - assert_eq!(output.status.code(), Some(1)); - let payload: Value = serde_json::from_slice(&output.stderr).unwrap(); - assert_eq!(payload["error"]["code"], "invalid_response"); - assert_shared_auth(auth, service); - } - fn output_json(output: &Output) -> Value { - let stdout = String::from_utf8_lossy(&output.stdout); - let body = stdout - .split("BLOX_E2E_OUTPUT_BEGIN") - .nth(1) - .unwrap() - .split("BLOX_E2E_OUTPUT_END") - .next() - .unwrap(); - serde_json::from_str(body.trim()).unwrap() - } - - #[test] - fn create_sends_only_the_selected_sku_multiple_and_optional_name() { - let auth = auth_service(); - let service = MockService::start( - 200, - json!({"workstationId":"1234567890123456789", "status":"WORKSTATION_STATUS_PENDING", "message":"awaiting approval"}), - ); - let output = run_process( - &auth, - &service, - &[ - "blox", - "create", - "test-name", - "--sku", - "CUSTOM_SKU", - "--multiple", - "3", - "--base-url", - "https://blox.test.blockstaging.build", - "--json", - ], - ); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!( - output_json(&output), - json!({"workstationId":"1234567890123456789", "status":"WORKSTATION_STATUS_PENDING", "message":"awaiting approval"}) - ); - let (path, request) = assert_shared_auth(auth, service); - assert!(path.ends_with(".OrchestratorService/StartWorkstation")); - assert_eq!( - request, - json!({"name":"test-name", "computeSku":{"code":"CUSTOM_SKU", "multiple":3}, "creationClient":"builderlab_cli"}) - ); - } - - #[test] - fn create_defaults_the_sku_and_multiple_and_leaves_name_to_the_server() { - let auth = auth_service(); - let service = MockService::start( - 200, - json!({"workstationId":"123", "status":"WORKSTATION_STATUS_QUEUED"}), - ); - let output = run_process( - &auth, - &service, - &[ - "blox", - "create", - "--base-url", - "https://blox.test.blockstaging.build", - ], - ); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("ID: 123")); - assert!(stdout.contains("Status: queued")); - assert!(!stdout.contains("ready")); - let (_, request) = assert_shared_auth(auth, service); - assert_eq!( - request, - json!({"computeSku":{"code":"DEFAULT_AMD64", "multiple":1}, "creationClient":"builderlab_cli"}) - ); - } - - #[test] - fn create_rejected_session_stops_before_the_mutation() { - let auth = MockService::start(401, json!({"error":"expired"})); - let output = run_process( - &auth, - &auth, - &[ - "blox", - "create", - "--base-url", - "https://blox.test.blockstaging.build", - "--json", - ], - ); - assert_eq!(output.status.code(), Some(3)); - let payload: Value = serde_json::from_slice(&output.stderr).unwrap(); - assert_eq!(payload["error"]["code"], "auth_required"); - let (path, _, _) = auth.finish(); - assert_eq!(path, "/api/goose/v1/auth/me"); - } - - #[test] - fn create_preserves_definite_server_rejections() { - let auth = auth_service(); - let service = MockService::start( - 400, - json!({"code":"invalid_argument", "message":"SKU multiple exceeds your maximum"}), - ); - let output = run_process( - &auth, - &service, - &[ - "blox", - "create", - "--multiple", - "9", - "--base-url", - "https://blox.test.blockstaging.build", - "--json", - ], - ); - assert_eq!(output.status.code(), Some(1)); - let payload: Value = serde_json::from_slice(&output.stderr).unwrap(); - assert_eq!(payload["error"]["code"], "invalid_argument"); - assert!(payload["error"]["message"] - .as_str() - .unwrap() - .contains("maximum")); - assert_shared_auth(auth, service); - } - - #[test] - fn create_ambiguous_results_require_inspection_before_retrying() { - for (status, response) in [ - ( - 503, - json!({"code":"unavailable", "message":"lost response"}), - ), - (200, json!("unexpected response")), - (200, json!({"status":"WORKSTATION_STATUS_CREATING"})), - ] { - let auth = auth_service(); - let service = MockService::start(status, response); - let output = run_process( - &auth, - &service, - &[ - "blox", - "create", - "--base-url", - "https://blox.test.blockstaging.build", - "--json", - ], - ); - assert_eq!(output.status.code(), Some(5)); - let payload: Value = serde_json::from_slice(&output.stderr).unwrap(); - assert_eq!(payload["error"]["code"], "create_outcome_unknown"); - let message = payload["error"]["message"].as_str().unwrap(); - assert!(message.contains("before trying again")); - assert!(message.contains("--base-url")); - assert_shared_auth(auth, service); - } - } - - #[test] - fn skus_discovery_preserves_catalog_json_and_prints_codes_with_maximums() { - for json_output in [false, true] { - let auth = auth_service(); - let catalog = json!({"computeSkuRequired":true, "computeSkus":[{"code":"DEFAULT_AMD64", "displayName":"Default", "cpu":1, "memoryGb":8, "maxMultiple":8, "computeProvider":"worker-cells"}]}); - let service = MockService::start(200, catalog.clone()); - let mut args = vec![ - "blox", - "skus", - "--base-url", - "https://blox.test.blockstaging.build", - ]; - if json_output { - args.push("--json"); - } - let output = run_process(&auth, &service, &args); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - if json_output { - assert_eq!(output_json(&output), catalog); - } else { - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("MAX MULTIPLE")); - assert!(stdout.contains("DEFAULT_AMD64\tDefault\t8\tworker-cells")); - } - let (path, request) = assert_shared_auth(auth, service); - assert!(path.ends_with(".OrchestratorService/ListComputeProviders")); - assert_eq!(request, json!({})); - } - } - fn process_stdout(output: &Output) -> String { - let stdout = String::from_utf8_lossy(&output.stdout); - let body = stdout.split("BLOX_E2E_OUTPUT_BEGIN\n").nth(1).unwrap(); - body.split("BLOX_E2E_OUTPUT_END") - .next() - .unwrap() - .to_string() - } - - fn execute_service(outputs: Vec, terminal: Value) -> MockService { - MockService::steps(vec![ - MockReply::Json( - 200, - json!({"workstation":{"workstationId":"resolved-id", "name":"my-workstation"}}), - ), - MockReply::Json(200, json!({"commandId":"command-id"})), - MockReply::Stream(outputs, terminal), - ]) - } - - fn assert_execute_requests(auth: MockService, service: MockService) -> Vec { - let (path, _, _) = auth.finish(); - assert_eq!(path, "/api/goose/v1/auth/me"); - let requests = service.finish_all(); - for (_, headers, _) in &requests { - assert_eq!( - headers["authorization"], - "BBIdentity blox-test-only.session" - ); - assert!(!headers.contains_key("x-forwarded-identity-token")); - } - assert_eq!(requests.len(), 3); - assert!(requests[0] - .0 - .ends_with(".OrchestratorService/GetWorkstation")); - assert!(requests[1] - .0 - .ends_with(".OrchestratorService/ExecuteProcess")); - assert!(requests[2] - .0 - .ends_with(".OrchestratorService/StreamCommandOutput")); - requests - } - - #[test] - fn execute_preserves_literal_arguments_and_raw_output_and_remote_exit_status() { - let auth = auth_service(); - let service = execute_service( - vec![ - json!({"stream":"STREAM_TYPE_STDOUT", "data":"SGVsbG8="}), - json!({"stream":"STREAM_TYPE_STDERR", "data":"c3RkZXJy", "exitCode":7, "eof":true}), - ], - json!({}), - ); - let output = run_process( - &auth, - &service, - &[ - "blox", - "exec", - "my-workstation", - "--workdir", - "/work/my directory", - "--base-url", - "https://blox.test.blockstaging.build", - "--", - "printf", - "a b", - "$HOME; exit 1", - "", - "--help", - "--json", - ], - ); - assert_eq!(output.status.code(), Some(7)); - assert_eq!(process_stdout(&output), "Hello"); - assert_eq!(String::from_utf8_lossy(&output.stderr), "stderr"); - let requests = assert_execute_requests(auth, service); - assert_eq!(requests[0].2, json!({"workstation":"my-workstation"})); - assert_eq!( - requests[1].2, - json!({"workstationId":"resolved-id", "command":"printf", "args":["a b","$HOME; exit 1","","--help","--json"], "workingDir":"/work/my directory"}) - ); - assert_eq!( - requests[2].2, - json!({"workstationId":"resolved-id", "commandId":"command-id", "follow":true}) - ); - } - - #[test] - fn execute_jsonl_events_end_with_remote_exit_without_an_extra_error() { - let auth = auth_service(); - let service = execute_service( - vec![ - json!({"stream":"STREAM_TYPE_STDOUT", "data":"SGVsbG8=", "resumeId":"1"}), - json!({"exitCode":7, "eof":true}), - ], - json!({}), - ); - let output = run_process( - &auth, - &service, - &[ - "--json", - "blox", - "exec", - "my-workstation", - "--base-url", - "https://blox.test.blockstaging.build", - "--", - "sh", - "-c", - "printf Hello; exit 7", - ], - ); - assert_eq!(output.status.code(), Some(7)); - assert!(output.stderr.is_empty()); - let events: Vec = process_stdout(&output) - .lines() - .map(|line| serde_json::from_str(line).unwrap()) - .collect(); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["output"]["data"], "SGVsbG8="); - assert_eq!(events[0]["command_id"], "command-id"); - assert_eq!( - events[2], - json!({"command_id":"command-id", "workstation_id":"resolved-id", "exit_code":7}) - ); - assert_execute_requests(auth, service); - } - - #[test] - fn execute_silent_completion_requires_an_explicit_exit_code() { - for remote_code in [0, 1] { - let auth = auth_service(); - let service = - execute_service(vec![json!({"exitCode":remote_code, "eof":true})], json!({})); - let output = run_process( - &auth, - &service, - &[ - "blox", - "exec", - "my-workstation", - "--base-url", - "https://blox.test.blockstaging.build", - "--", - "true", - ], - ); - assert_eq!(output.status.code(), Some(remote_code)); - assert!(process_stdout(&output).is_empty()); - assert!(output.stderr.is_empty()); - assert_execute_requests(auth, service); - } - } - - #[test] - fn execute_incomplete_or_failed_stream_never_claims_success() { - for (outputs, terminal) in [ - (vec![json!({"eof":true})], json!({})), - ( - vec![json!({"exitCode":0, "eof":true})], - json!({"error":{"code":"unavailable", "message":"lost stream"}}), - ), - ] { - let auth = auth_service(); - let service = execute_service(outputs, terminal); - let output = run_process( - &auth, - &service, - &[ - "--json", - "blox", - "exec", - "my-workstation", - "--base-url", - "https://blox.test.blockstaging.build", - "--", - "true", - ], - ); - assert_eq!(output.status.code(), Some(5)); - let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); - assert_eq!(failure["error"]["code"], "execute_output_incomplete"); - assert!(failure["error"]["message"] - .as_str() - .unwrap() - .contains("command-id")); - assert!(!process_stdout(&output).contains("exit_code")); - assert_execute_requests(auth, service); - } - } - - #[test] - fn execute_rejected_authentication_stops_before_any_workstation_request() { - let auth = MockService::start(401, json!({"error":"expired"})); - let output = run_process( - &auth, - &auth, - &[ - "--json", - "blox", - "exec", - "my-workstation", - "--base-url", - "https://blox.test.blockstaging.build", - "--", - "true", - ], - ); - assert_eq!(output.status.code(), Some(3)); - let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); - assert_eq!(failure["error"]["code"], "auth_required"); - assert_eq!(auth.finish().0, "/api/goose/v1/auth/me"); - } - - #[test] - fn ordinary_marketplace_json_errors_still_have_one_error_envelope() { - let service = MockService::start( - 403, - json!({"error":{"code":"forbidden", "message":"not permitted"}}), - ); - let output = run_process(&service, &service, &["--json", "skills", "list"]); - assert_eq!(output.status.code(), Some(4)); - let payload: Value = serde_json::from_slice(&output.stderr).unwrap(); - assert_eq!(payload["error"]["code"], "forbidden"); - assert_eq!(payload["error"]["exit_code"], 4); - assert!(service.finish().0.contains("/v1/marketplace/skills")); - } - - #[test] - fn execute_start_rejection_or_unknown_result_does_not_retry() { - for (status, reply, expected_code) in [ - ( - 400, - json!({"code":"failed_precondition", "message":"workstation is suspended"}), - "failed_precondition", - ), - ( - 503, - json!({"code":"unavailable", "message":"lost acknowledgement"}), - "execute_outcome_unknown", - ), - (200, json!({}), "execute_outcome_unknown"), - ] { - let auth = auth_service(); - let service = MockService::steps(vec![ - MockReply::Json(200, json!({"workstation":{"workstationId":"resolved-id"}})), - MockReply::Json(status, reply), - ]); - let output = run_process( - &auth, - &service, - &[ - "--json", - "blox", - "exec", - "my-workstation", - "--base-url", - "https://blox.test.blockstaging.build", - "--", - "true", - ], - ); - assert!(!output.status.success()); - let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); - assert_eq!(failure["error"]["code"], expected_code); - assert_eq!(auth.finish().0, "/api/goose/v1/auth/me"); - let requests = service.finish_all(); - assert_eq!(requests.len(), 2); - assert!(requests[1] - .0 - .ends_with(".OrchestratorService/ExecuteProcess")); - } - } - fn lifecycle_service(target: &str, status: u16, response: Value) -> MockService { - MockService::steps(vec![ - MockReply::Json( - 200, - json!({"workstation":{"workstationId":target, "name":"123", "status":"WORKSTATION_STATUS_DELETED"}}), - ), - MockReply::Json(status, response), - ]) - } - - fn assert_lifecycle_requests( - auth: MockService, - service: MockService, - operation: &str, - target: &str, - ) { - assert_eq!(auth.finish().0, "/api/goose/v1/auth/me"); - let requests = service.finish_all(); - assert_eq!(requests.len(), 2); - assert!(requests[0] - .0 - .ends_with(".OrchestratorService/GetWorkstation")); - let rpc = match operation { - "suspend" => "SuspendWorkstation", - "resume" => "ResumeWorkstation", - "delete" => "DeleteWorkstation", - _ => panic!("unknown test operation"), - }; - assert!(requests[1] - .0 - .ends_with(&format!(".OrchestratorService/{rpc}"))); - assert_eq!(requests[1].2, json!({"workstationId":target})); - for (_, headers, _) in requests { - assert_eq!( - headers["authorization"], - "BBIdentity blox-test-only.session" - ); - assert!(!headers.contains_key("x-forwarded-identity-token")); - assert!(!headers.contains_key("cookie")); - } - } - - #[test] - fn lifecycle_resolves_names_once_and_preserves_each_typed_response() { - let target = "9223372036854775806"; - for (operation, reply) in [ - ( - "suspend", - json!({"workstationId":target, "status":"WORKSTATION_STATUS_SUSPENDED", "message":"suspension initiated"}), - ), - ( - "resume", - json!({"workstationId":target, "status":"WORKSTATION_STATUS_QUEUED", "message":"resume queued"}), - ), - ( - "delete", - json!({"workstationId":target, "success":true, "message":"deletion initiated"}), - ), - ] { - let auth = auth_service(); - let service = lifecycle_service(target, 200, reply.clone()); - let output = run_process( - &auth, - &service, - &[ - "--json", - "blox", - operation, - "123", - "--base-url", - "https://blox.test.blockstaging.build", - ], - ); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(output_json(&output), reply); - assert_lifecycle_requests(auth, service, operation, target); - } - } - - #[test] - fn resume_accepts_initial_states_and_full_archived_ids_without_claiming_readiness() { - for status in [ - "WORKSTATION_STATUS_QUEUED", - "WORKSTATION_STATUS_CREATING", - "WORKSTATION_STATUS_STARTING", - "WORKSTATION_STATUS_RUNNING", - ] { - let auth = auth_service(); - let service = lifecycle_service( - "archived-id", - 200, - json!({"workstationId":"archived-id", "status":status, "message":"resume requested"}), - ); - let output = run_process( - &auth, - &service, - &[ - "blox", - "resume", - "archived-id", - "--base-url", - "https://blox.test.blockstaging.build", - ], - ); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = process_stdout(&output); - assert!(stdout.contains("Request accepted: resume")); - assert!(stdout.contains("Server status:")); - assert!(stdout.contains("bl blox get archived-id")); - assert!(!stdout.contains("ready")); - assert_lifecycle_requests(auth, service, "resume", "archived-id"); - } - } - - #[test] - fn lifecycle_false_error_and_degraded_results_fail_with_the_server_message() { - for (operation, response) in [ - ( - "delete", - json!({"workstationId":"target-id", "success":false, "message":"deletion rejected"}), - ), - ( - "suspend", - json!({"workstationId":"target-id", "status":"WORKSTATION_STATUS_ERROR", "message":"snapshot failed"}), - ), - ( - "resume", - json!({"workstationId":"target-id", "status":"WORKSTATION_STATUS_DEGRADED", "message":"bootstrap failed"}), - ), - ] { - let auth = auth_service(); - let service = lifecycle_service("target-id", 200, response.clone()); - let output = run_process( - &auth, - &service, - &[ - "--json", - "blox", - operation, - "123", - "--base-url", - "https://blox.test.blockstaging.build", - ], - ); - assert_eq!(output.status.code(), Some(1)); - let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); - assert_eq!(failure["error"]["code"], "lifecycle_failed"); - assert!(failure["error"]["message"] - .as_str() - .unwrap() - .contains(response["message"].as_str().unwrap())); - assert_lifecycle_requests(auth, service, operation, "target-id"); - } - } - - #[test] - fn lifecycle_unknown_acknowledgements_keep_the_target_and_never_retry() { - for operation in ["suspend", "resume", "delete"] { - for (status, response) in [ - ( - 503, - json!({"code":"unavailable", "message":"lost acknowledgement"}), - ), - (200, json!("unexpected response")), - ( - 200, - json!({"workstationId":"wrong-id", "message":"transition initiated"}), - ), - (200, json!({"message":"transition initiated"})), - ] { - let auth = auth_service(); - let service = lifecycle_service("known-id", status, response); - let output = run_process( - &auth, - &service, - &[ - "--json", - "blox", - operation, - "123", - "--base-url", - "https://blox.test.blockstaging.build", - ], - ); - assert_eq!(output.status.code(), Some(5)); - let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); - assert_eq!(failure["error"]["code"], "lifecycle_outcome_unknown"); - let message = failure["error"]["message"].as_str().unwrap(); - assert!(message.contains(operation)); - assert!(message.contains("bl blox get known-id")); - assert!(message.contains("--base-url")); - assert!(message.contains("before trying again")); - assert_lifecycle_requests(auth, service, operation, "known-id"); - } - } - } - - #[test] - fn lifecycle_unexpected_status_does_not_claim_acceptance() { - for (operation, status) in [ - ("suspend", 0), - ("suspend", 10), - ("resume", 999), - ("resume", 7), - ] { - let auth = auth_service(); - let service = lifecycle_service( - "known-id", - 200, - json!({"workstationId":"known-id", "status":status, "message":"inspect transition"}), - ); - let output = run_process( - &auth, - &service, - &[ - "--json", - "blox", - operation, - "123", - "--base-url", - "https://blox.test.blockstaging.build", - ], - ); - assert_eq!(output.status.code(), Some(5)); - let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); - assert_eq!(failure["error"]["code"], "lifecycle_outcome_unknown"); - assert!(failure["error"]["message"] - .as_str() - .unwrap() - .contains("inspect transition")); - assert_lifecycle_requests(auth, service, operation, "known-id"); - } - } - - #[test] - fn lifecycle_rejected_authentication_stops_before_workstation_resolution() { - for operation in ["suspend", "resume", "delete"] { - let auth = MockService::start(401, json!({"error":"expired"})); - let output = run_process( - &auth, - &auth, - &[ - "--json", - "blox", - operation, - "123", - "--base-url", - "https://blox.test.blockstaging.build", - ], - ); - assert_eq!(output.status.code(), Some(3)); - let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); - assert_eq!(failure["error"]["code"], "auth_required"); - assert_eq!(auth.finish().0, "/api/goose/v1/auth/me"); - } - } - - #[test] - fn lifecycle_definite_server_rejections_preserve_the_error_code() { - for operation in ["suspend", "resume", "delete"] { - let auth = auth_service(); - let service = lifecycle_service( - "known-id", - 400, - json!({"code":"failed_precondition", "message":"transition is not allowed"}), - ); - let output = run_process( - &auth, - &service, - &[ - "--json", - "blox", - operation, - "123", - "--base-url", - "https://blox.test.blockstaging.build", - ], - ); - assert_eq!(output.status.code(), Some(1)); - let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); - assert_eq!(failure["error"]["code"], "failed_precondition"); - assert_lifecycle_requests(auth, service, operation, "known-id"); - } - } -} diff --git a/src/bl/blox/context.rs b/src/bl/blox/context.rs new file mode 100644 index 0000000..201b4c0 --- /dev/null +++ b/src/bl/blox/context.rs @@ -0,0 +1,200 @@ +use crate::bl::auth_login::verify_stored_session; +use crate::bl::auth_storage::default_session_storage; +use crate::bl::display::terminal_safe_text; +use crate::bl::runner; +use crate::bl::skills_api::{exit_codes, failure}; +use crate::bl::skills_config::SkillsConfig; +use anyhow::{Context, Result}; +use blox::proto::WorkstationSummary; +use clap::{Arg, ArgMatches, Command}; +const ENDPOINT_ENV: &str = "BL_BLOX_CONTROL_PLANE_URL"; +const APPROVED_HOSTS: &[&str] = &[ + "blox.test.blockstaging.build", + "blox.public.builderlabstaging.xyz", + "blox.public.builderlab.xyz", +]; + +pub(super) fn args(command: Command) -> Command { + command.arg( + Arg::new("blox-base-url") + .long("base-url") + .visible_alias("control-plane-url") + .env(ENDPOINT_ENV) + .global(true) + .value_name("URL") + .help( + "Approved BuilderLab Blox control-plane URL (defaults to your login environment)", + ), + ) +} + +pub(super) fn client( + config: &SkillsConfig, + args: &ArgMatches, + operation: &str, +) -> Result { + let endpoint = args + .get_one::("blox-base-url") + .cloned() + .map(Ok) + .unwrap_or_else(|| default_endpoint(&config.kgoose_base_url))?; + validate_endpoint(&endpoint)?; + runner::ensure_org_configured(config)?; + let storage = default_session_storage(config)?; + let verified = verify_stored_session(config, storage.as_ref()) + .map_err(map_error)? + .ok_or_else(|| { + failure( + exit_codes::AUTH_REQUIRED, + "auth_required", + "BuilderLab CLI auth is required; run `bl auth login`", + ) + })?; + let secret = verified + .credential + .session_credential_header_value() + .ok_or_else(|| { + failure( + exit_codes::AUTH_REQUIRED, + "auth_required", + "BuilderLab CLI auth is required; run `bl auth login`", + ) + })?; + // Test-only remapping leaves production origin validation and auth intact. + #[cfg(test)] + let endpoint = std::env::var("BL_BLOX_E2E_ENDPOINT").unwrap_or(endpoint); + let client = blox::Client::new(&endpoint)?.with_session_credential(&secret)?; + config.style.verbose(&format!("Blox {operation}")); + Ok(client) +} + +pub(super) fn workstation(client: &blox::Client, args: &ArgMatches) -> Result { + let selector = args + .get_one::("workstation") + .context("expected workstation")?; + let workstation = client + .get_workstation(selector) + .map_err(map_error)? + .workstation + .filter(|workstation| !workstation.workstation_id.is_empty()) + .ok_or_else(|| { + failure( + exit_codes::GENERAL, + "invalid_response", + "Blox returned no workstation ID", + ) + })?; + + Ok(workstation) +} + +fn default_endpoint(auth_base_url: &str) -> Result { + let origin = url::Url::parse(auth_base_url) + .context("parse BuilderLab auth URL")? + .origin() + .ascii_serialization(); + let endpoint = match origin.as_str() { + "https://test.blockstaging.build" => "https://blox.test.blockstaging.build", + "https://app.builderlabstaging.xyz" => "https://blox.public.builderlabstaging.xyz", + "https://app.builderlab.xyz" => "https://blox.public.builderlab.xyz", + "https://block.builderlab.xyz" => return Err(failure(exit_codes::GENERAL, "blox_unavailable", "Blox is not deployed in this BuilderLab environment")), + _ => return Err(failure(exit_codes::GENERAL, "endpoint_required", "No Blox endpoint is configured for this BuilderLab environment; set an approved ingress with --base-url or BL_BLOX_CONTROL_PLANE_URL")), + }; + Ok(endpoint.to_string()) +} + +fn validate_endpoint(endpoint: &str) -> Result<()> { + let url = url::Url::parse(endpoint).context("parse Blox control-plane URL")?; + let approved = url.scheme() == "https" + && url.port_or_known_default() == Some(443) + && url.username().is_empty() + && url.password().is_none() + && url.path() == "/" + && url.query().is_none() + && url.fragment().is_none() + && url.host_str().is_some_and(|host| { + APPROVED_HOSTS + .iter() + .any(|approved| host.eq_ignore_ascii_case(approved)) + }); + if !approved { + return Err(failure( + exit_codes::GENERAL, + "invalid_endpoint", + "Blox control-plane URL must target an approved BuilderLab ingress over HTTPS", + )); + } + Ok(()) +} + +pub(super) fn map_error(error: anyhow::Error) -> anyhow::Error { + if let Some(rpc) = error.downcast_ref::() { + let exit_code = match rpc.code.as_str() { + "unauthenticated" => exit_codes::AUTH_REQUIRED, + "permission_denied" => exit_codes::FORBIDDEN, + "unavailable" | "deadline_exceeded" => exit_codes::NETWORK, + _ => exit_codes::GENERAL, + }; + let message = if rpc.code == "unauthenticated" { + "Blox authentication failed; run `bl auth login`".to_string() + } else { + format!("Blox {}: {}", rpc.code, terminal_safe_text(&rpc.message)) + }; + return failure(exit_code, &rpc.code, message); + } + if error.chain().any(|cause| cause.is::()) { + return failure(exit_codes::NETWORK, "network_error", format!("{error:#}")); + } + error +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn defaults_follow_exact_login_origins() { + for (auth, blox) in [ + ( + "https://test.blockstaging.build", + "https://blox.test.blockstaging.build", + ), + ( + "https://app.builderlabstaging.xyz", + "https://blox.public.builderlabstaging.xyz", + ), + ( + "https://app.builderlab.xyz", + "https://blox.public.builderlab.xyz", + ), + ] { + assert_eq!(default_endpoint(auth).unwrap(), blox); + assert!(validate_endpoint(blox).is_ok()); + } + assert!(default_endpoint("https://block.builderlab.xyz") + .unwrap_err() + .to_string() + .contains("not deployed")); + for origin in [ + "http://app.builderlab.xyz", + "https://app.builderlab.xyz:8443", + "https://app.builderlab.xyz.evil.test", + ] { + assert!(default_endpoint(origin).is_err()); + } + } + + #[test] + fn session_origin_validation_rejects_unapproved_destinations() { + for endpoint in [ + "http://blox.public.builderlab.xyz", + "https://blox.public.builderlab.xyz:8443", + "https://blox.public.builderlab.xyz.evil.test", + "https://user@blox.public.builderlab.xyz", + "https://blox.public.builderlab.xyz/path", + "https://blox.public.builderlab.xyz/?q=1", + "https://blox.public.builderlab.xyz/#fragment", + "http://127.0.0.1:8895", + ] { + assert!(validate_endpoint(endpoint).is_err(), "accepted {endpoint}"); + } + } +} diff --git a/src/bl/blox/create.rs b/src/bl/blox/create.rs new file mode 100644 index 0000000..06a1b04 --- /dev/null +++ b/src/bl/blox/create.rs @@ -0,0 +1,234 @@ +use super::context; +use super::context::map_error; +use super::output::status_text; +use crate::bl::display::{print_json, terminal_safe_text}; +use crate::bl::skills_api::{exit_codes, failure}; +use crate::bl::skills_config::SkillsConfig; +use anyhow::{Context, Result}; +use blox::proto::{ComputeSkuSelection, StartWorkstationRequest}; +use clap::{Arg, ArgMatches, Command}; +pub(super) fn command() -> Command { + Command::new("create") + .about("Create a new workstation with a SKU and multiple") + .long_about("Create a new workstation without requesting project repositories. The deployment may still bootstrap its configured home repository. Returns the initial ID and status; creation continues on the server.") + .arg(Arg::new("name").value_name("NAME").value_parser(clap::builder::NonEmptyStringValueParser::new()).help("Optional workstation name (otherwise generated by the server)")) + .arg(Arg::new("sku").long("sku").value_name("CODE").default_value("DEFAULT_AMD64").value_parser(clap::builder::NonEmptyStringValueParser::new()).help("SKU code from `bl blox skus`")) + .arg(Arg::new("multiple").long("multiple").value_name("COUNT").default_value("1").value_parser(clap::value_parser!(i32).range(1..)).help("SKU multiple")) +} + +pub(super) fn run(config: &SkillsConfig, args: &ArgMatches) -> Result<()> { + let client = context::client(config, args, "create")?; + let request = StartWorkstationRequest { + name: args.get_one::("name").cloned(), + compute_sku: Some(ComputeSkuSelection { + code: args + .get_one::("sku") + .context("expected SKU code")? + .clone(), + multiple: *args + .get_one::("multiple") + .context("expected SKU multiple")?, + }), + creation_client: "builderlab_cli".to_string(), + ..Default::default() + }; + let response = client + .start_workstation(&request) + .map_err(map_create_error)?; + if response.workstation_id.is_empty() { + return Err(create_unknown_outcome()); + } + if config.json { + print_json(&response) + } else { + println!("ID: {}", terminal_safe_text(&response.workstation_id)); + println!("Status: {}", status_text(response.status)); + if !response.message.is_empty() { + println!("Message: {}", terminal_safe_text(&response.message)); + } + println!( + "Inspect with `bl blox get {}` (preserve any --base-url override).", + terminal_safe_text(&response.workstation_id) + ); + Ok(()) + } +} + +fn create_unknown_outcome() -> anyhow::Error { + failure(exit_codes::NETWORK, "create_outcome_unknown", "Blox creation result is unknown. Inspect `bl blox list` or `bl blox get ` before trying again; the workstation may already exist. Preserve any --base-url override when checking.") +} + +fn map_create_error(error: anyhow::Error) -> anyhow::Error { + if let Some(rpc) = error.downcast_ref::() { + if !matches!( + rpc.code.as_str(), + "unavailable" | "deadline_exceeded" | "unknown" | "internal" | "data_loss" + ) { + return map_error(error); + } + } + create_unknown_outcome() +} + +#[cfg(test)] +mod tests { + use super::super::test_support::*; + use serde_json::{json, Value}; + + #[test] + fn create_sends_only_the_selected_sku_multiple_and_optional_name() { + let auth = auth_service(); + let service = MockService::start( + 200, + json!({"workstationId":"1234567890123456789", "status":"WORKSTATION_STATUS_PENDING", "message":"awaiting approval"}), + ); + let output = run_process( + &auth, + &service, + &[ + "blox", + "create", + "test-name", + "--sku", + "CUSTOM_SKU", + "--multiple", + "3", + "--base-url", + "https://blox.test.blockstaging.build", + "--json", + ], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + output_json(&output), + json!({"workstationId":"1234567890123456789", "status":"WORKSTATION_STATUS_PENDING", "message":"awaiting approval"}) + ); + let (path, request) = assert_shared_auth(auth, service); + assert!(path.ends_with(".OrchestratorService/StartWorkstation")); + assert_eq!( + request, + json!({"name":"test-name", "computeSku":{"code":"CUSTOM_SKU", "multiple":3}, "creationClient":"builderlab_cli"}) + ); + } + + #[test] + fn create_defaults_the_sku_and_multiple_and_leaves_name_to_the_server() { + let auth = auth_service(); + let service = MockService::start( + 200, + json!({"workstationId":"123", "status":"WORKSTATION_STATUS_QUEUED"}), + ); + let output = run_process( + &auth, + &service, + &[ + "blox", + "create", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("ID: 123")); + assert!(stdout.contains("Status: queued")); + assert!(!stdout.contains("ready")); + let (_, request) = assert_shared_auth(auth, service); + assert_eq!( + request, + json!({"computeSku":{"code":"DEFAULT_AMD64", "multiple":1}, "creationClient":"builderlab_cli"}) + ); + } + + #[test] + fn create_rejected_session_stops_before_the_mutation() { + let auth = MockService::start(401, json!({"error":"expired"})); + let output = run_process( + &auth, + &auth, + &[ + "blox", + "create", + "--base-url", + "https://blox.test.blockstaging.build", + "--json", + ], + ); + assert_eq!(output.status.code(), Some(3)); + let payload: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(payload["error"]["code"], "auth_required"); + let (path, _, _) = auth.finish(); + assert_eq!(path, "/api/goose/v1/auth/me"); + } + + #[test] + fn create_preserves_definite_server_rejections() { + let auth = auth_service(); + let service = MockService::start( + 400, + json!({"code":"invalid_argument", "message":"SKU multiple exceeds your maximum"}), + ); + let output = run_process( + &auth, + &service, + &[ + "blox", + "create", + "--multiple", + "9", + "--base-url", + "https://blox.test.blockstaging.build", + "--json", + ], + ); + assert_eq!(output.status.code(), Some(1)); + let payload: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(payload["error"]["code"], "invalid_argument"); + assert!(payload["error"]["message"] + .as_str() + .unwrap() + .contains("maximum")); + assert_shared_auth(auth, service); + } + + #[test] + fn create_ambiguous_results_require_inspection_before_retrying() { + for (status, response) in [ + ( + 503, + json!({"code":"unavailable", "message":"lost response"}), + ), + (200, json!("unexpected response")), + (200, json!({"status":"WORKSTATION_STATUS_CREATING"})), + ] { + let auth = auth_service(); + let service = MockService::start(status, response); + let output = run_process( + &auth, + &service, + &[ + "blox", + "create", + "--base-url", + "https://blox.test.blockstaging.build", + "--json", + ], + ); + assert_eq!(output.status.code(), Some(5)); + let payload: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(payload["error"]["code"], "create_outcome_unknown"); + let message = payload["error"]["message"].as_str().unwrap(); + assert!(message.contains("before trying again")); + assert!(message.contains("--base-url")); + assert_shared_auth(auth, service); + } + } +} diff --git a/src/bl/blox/delete.rs b/src/bl/blox/delete.rs new file mode 100644 index 0000000..42f0e16 --- /dev/null +++ b/src/bl/blox/delete.rs @@ -0,0 +1,187 @@ +use super::context; +use super::lifecycle::{self, LifecycleSummary}; +use crate::bl::skills_config::SkillsConfig; +use anyhow::Result; +use blox::proto::DeleteWorkstationRequest; +use clap::{Arg, ArgMatches, Command}; + +pub(super) fn command() -> Command { + Command::new("delete").about("Request workstation deletion") + .long_about("Request this transition for a workstation identified by canonical name or full ID. Reports the server response without waiting for the final state; inspect progress with `bl blox get `. Use the full ID for an archived workstation.") + .arg(Arg::new("workstation").value_name("WORKSTATION").required(true).value_parser(clap::builder::NonEmptyStringValueParser::new())) +} + +pub(super) fn run(config: &SkillsConfig, args: &ArgMatches) -> Result<()> { + let client = context::client(config, args, "delete")?; + let workstation = context::workstation(&client, args)?; + let id = &workstation.workstation_id; + let response = client + .delete_workstation(&DeleteWorkstationRequest { + workstation_id: id.clone(), + }) + .map_err(|error| lifecycle::map_error(error, "delete", id))?; + lifecycle::print_result( + config, + "delete", + id, + LifecycleSummary { + workstation_id: &response.workstation_id, + status: None, + success: response.success, + message: &response.message, + }, + &response, + ) +} + +#[cfg(test)] +mod tests { + use super::super::test_support::*; + use serde_json::{json, Value}; + #[test] + fn delete_resolves_name_once_and_preserves_response() { + let target = "9223372036854775806"; + let operation = "delete"; + let reply = json!({"workstationId":target, "success":true, "message":"deletion initiated"}); + let auth = auth_service(); + let service = lifecycle_service(target, 200, reply.clone()); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + operation, + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(output_json(&output), reply); + assert_lifecycle_requests(auth, service, operation, target); + } + + #[test] + fn delete_false_result_preserves_the_server_message() { + let operation = "delete"; + let response = + json!({"workstationId":"target-id", "success":false, "message":"deletion rejected"}); + let auth = auth_service(); + let service = lifecycle_service("target-id", 200, response.clone()); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + operation, + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert_eq!(output.status.code(), Some(1)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], "lifecycle_failed"); + assert!(failure["error"]["message"] + .as_str() + .unwrap() + .contains(response["message"].as_str().unwrap())); + assert_lifecycle_requests(auth, service, operation, "target-id"); + } + + #[test] + fn delete_unknown_acknowledgements_keep_the_target_and_never_retry() { + let operation = "delete"; + for (status, response) in [ + ( + 503, + json!({"code":"unavailable", "message":"lost acknowledgement"}), + ), + (200, json!("unexpected response")), + ( + 200, + json!({"workstationId":"wrong-id", "message":"transition initiated"}), + ), + (200, json!({"message":"transition initiated"})), + ] { + let auth = auth_service(); + let service = lifecycle_service("known-id", status, response); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + operation, + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert_eq!(output.status.code(), Some(5)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], "lifecycle_outcome_unknown"); + let message = failure["error"]["message"].as_str().unwrap(); + assert!(message.contains(operation)); + assert!(message.contains("bl blox get known-id")); + assert!(message.contains("--base-url")); + assert!(message.contains("before trying again")); + assert_lifecycle_requests(auth, service, operation, "known-id"); + } + } + + #[test] + fn delete_rejected_authentication_stops_before_workstation_resolution() { + let operation = "delete"; + let auth = MockService::start(401, json!({"error":"expired"})); + let output = run_process( + &auth, + &auth, + &[ + "--json", + "blox", + operation, + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert_eq!(output.status.code(), Some(3)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], "auth_required"); + assert_eq!(auth.finish().0, "/api/goose/v1/auth/me"); + } + + #[test] + fn delete_definite_server_rejections_preserve_the_error_code() { + let operation = "delete"; + let auth = auth_service(); + let service = lifecycle_service( + "known-id", + 400, + json!({"code":"failed_precondition", "message":"transition is not allowed"}), + ); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + operation, + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert_eq!(output.status.code(), Some(1)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], "failed_precondition"); + assert_lifecycle_requests(auth, service, operation, "known-id"); + } +} diff --git a/src/bl/blox/exec.rs b/src/bl/blox/exec.rs new file mode 100644 index 0000000..a77510b --- /dev/null +++ b/src/bl/blox/exec.rs @@ -0,0 +1,409 @@ +use super::context; +use super::context::map_error; +use crate::bl::display::terminal_safe_text; +use crate::bl::skills_api::SilentJsonExit; +use crate::bl::skills_api::{exit_codes, failure}; +use crate::bl::skills_config::SkillsConfig; +use anyhow::{Context, Result}; +use blox::proto::{ExecuteProcessRequest, StreamCommandOutputRequest}; +use clap::{Arg, ArgMatches, Command}; +use serde_json::Value; +use std::io::Write; + +pub(super) fn command() -> Command { + Command::new("exec") + .about("Run a command on a workstation and follow its output") + .long_about("Run a literal executable and arguments after --, stream stdout/stderr, and exit with the remote process status. Use explicit sh -c for shell expansion or pipelines. This is noninteractive: stdin and PTY are not supported. Interrupting or disconnecting may leave the command running. --json prints JSONL output events (protobuf bytes use base64), followed by an exit_code event.") + .arg(Arg::new("workstation").value_name("WORKSTATION").required(true).value_parser(clap::builder::NonEmptyStringValueParser::new())) + .arg(Arg::new("workdir").long("workdir").value_name("PATH").value_parser(clap::builder::NonEmptyStringValueParser::new()).help("Remote working directory (defaults to the workstation's configured directory)")) + .arg(Arg::new("command").value_name("COMMAND").num_args(1..).last(true).required(true).help("Executable and arguments passed literally to the remote process")) +} + +pub(super) fn run(config: &SkillsConfig, args: &ArgMatches) -> Result<()> { + if args + .get_many::("command") + .and_then(|mut command| command.next()) + .is_some_and(|command| command.is_empty()) + { + return Err(failure( + exit_codes::GENERAL, + "invalid_command", + "The remote executable must not be empty", + )); + } + let client = context::client(config, args, "exec")?; + let workstation = context::workstation(&client, args)?; + let mut command = args + .get_many::("command") + .context("expected command")?; + let executable = command.next().context("expected executable")?.clone(); + let response = client + .execute_process(&ExecuteProcessRequest { + workstation_id: workstation.workstation_id.clone(), + command: executable, + args: command.cloned().collect(), + working_dir: args + .get_one::("workdir") + .cloned() + .unwrap_or_default(), + ..Default::default() + }) + .map_err(|error| map_execute_start_error(error, &workstation.workstation_id))?; + if response.command_id.is_empty() { + return Err(execute_start_unknown(&workstation.workstation_id)); + } + let command_id = response.command_id; + config + .style + .verbose(&format!("Following Blox command {command_id}")); + let mut exit_code = None; + client + .stream_command_output( + &StreamCommandOutputRequest { + workstation_id: workstation.workstation_id.clone(), + command_id: command_id.clone(), + follow: true, + ..Default::default() + }, + |output| { + if config.json { + write_json_line(&serde_json::json!({ + "workstation_id":workstation.workstation_id, + "command_id":command_id, + "output":output, + }))?; + } else if !output.data.is_empty() { + match output.stream { + 1 => { + let mut stdout = std::io::stdout().lock(); + stdout.write_all(&output.data)?; + stdout.flush()?; + } + 2 => { + let mut stderr = std::io::stderr().lock(); + stderr.write_all(&output.data)?; + stderr.flush()?; + } + _ => anyhow::bail!("Blox returned output with an unsupported stream type"), + } + } + if let Some(code) = output.exit_code { + exit_code = Some(code); + } + Ok(()) + }, + ) + .map_err(|_| execute_follow_failed(&workstation.workstation_id, &command_id))?; + let exit_code = + exit_code.ok_or_else(|| execute_follow_failed(&workstation.workstation_id, &command_id))?; + if config.json { + write_json_line( + &serde_json::json!({"workstation_id":workstation.workstation_id, "command_id":command_id, "exit_code":exit_code}), + )?; + } + if exit_code != 0 { + return Err(anyhow::Error::new(SilentJsonExit(exit_code))); + } + Ok(()) +} + +fn write_json_line(value: &Value) -> Result<()> { + let mut stdout = std::io::stdout().lock(); + serde_json::to_writer(&mut stdout, value).context("write Blox JSONL output")?; + stdout + .write_all(b"\n") + .context("write Blox JSONL newline")?; + stdout.flush().context("flush Blox JSONL output") +} + +fn execute_start_unknown(workstation_id: &str) -> anyhow::Error { + failure(exit_codes::NETWORK, "execute_outcome_unknown", format!("Blox execution result is unknown on workstation {}. Inspect its commands in Blox before running it again; the command may already be running. Preserve any --base-url override.", terminal_safe_text(workstation_id))) +} + +fn map_execute_start_error(error: anyhow::Error, workstation_id: &str) -> anyhow::Error { + if let Some(rpc) = error.downcast_ref::() { + if !matches!( + rpc.code.as_str(), + "unavailable" | "deadline_exceeded" | "unknown" | "internal" | "data_loss" + ) { + return map_error(error); + } + } + execute_start_unknown(workstation_id) +} + +fn execute_follow_failed(workstation_id: &str, command_id: &str) -> anyhow::Error { + failure(exit_codes::NETWORK, "execute_output_incomplete", format!("Could not confirm the result of Blox command {} on workstation {}. The command may still be running; inspect it in Blox before running it again. Preserve any --base-url override.", terminal_safe_text(command_id), terminal_safe_text(workstation_id))) +} + +#[cfg(test)] +mod tests { + use super::super::test_support::*; + use serde_json::{json, Value}; + fn execute_service(outputs: Vec, terminal: Value) -> MockService { + MockService::steps(vec![ + MockReply::Json( + 200, + json!({"workstation":{"workstationId":"resolved-id", "name":"my-workstation"}}), + ), + MockReply::Json(200, json!({"commandId":"command-id"})), + MockReply::Stream(outputs, terminal), + ]) + } + + fn assert_execute_requests(auth: MockService, service: MockService) -> Vec { + let (path, _, _) = auth.finish(); + assert_eq!(path, "/api/goose/v1/auth/me"); + let requests = service.finish_all(); + for (_, headers, _) in &requests { + assert_eq!( + headers["authorization"], + "BBIdentity blox-test-only.session" + ); + assert!(!headers.contains_key("x-forwarded-identity-token")); + } + assert_eq!(requests.len(), 3); + assert!(requests[0] + .0 + .ends_with(".OrchestratorService/GetWorkstation")); + assert!(requests[1] + .0 + .ends_with(".OrchestratorService/ExecuteProcess")); + assert!(requests[2] + .0 + .ends_with(".OrchestratorService/StreamCommandOutput")); + requests + } + #[test] + fn execute_preserves_literal_arguments_and_raw_output_and_remote_exit_status() { + let auth = auth_service(); + let service = execute_service( + vec![ + json!({"stream":"STREAM_TYPE_STDOUT", "data":"SGVsbG8="}), + json!({"stream":"STREAM_TYPE_STDERR", "data":"c3RkZXJy", "exitCode":7, "eof":true}), + ], + json!({}), + ); + let output = run_process( + &auth, + &service, + &[ + "blox", + "exec", + "my-workstation", + "--workdir", + "/work/my directory", + "--base-url", + "https://blox.test.blockstaging.build", + "--", + "printf", + "a b", + "$HOME; exit 1", + "", + "--help", + "--json", + ], + ); + assert_eq!(output.status.code(), Some(7)); + assert_eq!(process_stdout(&output), "Hello"); + assert_eq!(String::from_utf8_lossy(&output.stderr), "stderr"); + let requests = assert_execute_requests(auth, service); + assert_eq!(requests[0].2, json!({"workstation":"my-workstation"})); + assert_eq!( + requests[1].2, + json!({"workstationId":"resolved-id", "command":"printf", "args":["a b","$HOME; exit 1","","--help","--json"], "workingDir":"/work/my directory"}) + ); + assert_eq!( + requests[2].2, + json!({"workstationId":"resolved-id", "commandId":"command-id", "follow":true}) + ); + } + + #[test] + fn execute_jsonl_events_end_with_remote_exit_without_an_extra_error() { + let auth = auth_service(); + let service = execute_service( + vec![ + json!({"stream":"STREAM_TYPE_STDOUT", "data":"SGVsbG8=", "resumeId":"1"}), + json!({"exitCode":7, "eof":true}), + ], + json!({}), + ); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + "exec", + "my-workstation", + "--base-url", + "https://blox.test.blockstaging.build", + "--", + "sh", + "-c", + "printf Hello; exit 7", + ], + ); + assert_eq!(output.status.code(), Some(7)); + assert!(output.stderr.is_empty()); + let events: Vec = process_stdout(&output) + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(events.len(), 3); + assert_eq!(events[0]["output"]["data"], "SGVsbG8="); + assert_eq!(events[0]["command_id"], "command-id"); + assert_eq!( + events[2], + json!({"command_id":"command-id", "workstation_id":"resolved-id", "exit_code":7}) + ); + assert_execute_requests(auth, service); + } + + #[test] + fn execute_silent_completion_requires_an_explicit_exit_code() { + for remote_code in [0, 1] { + let auth = auth_service(); + let service = + execute_service(vec![json!({"exitCode":remote_code, "eof":true})], json!({})); + let output = run_process( + &auth, + &service, + &[ + "blox", + "exec", + "my-workstation", + "--base-url", + "https://blox.test.blockstaging.build", + "--", + "true", + ], + ); + assert_eq!(output.status.code(), Some(remote_code)); + assert!(process_stdout(&output).is_empty()); + assert!(output.stderr.is_empty()); + assert_execute_requests(auth, service); + } + } + + #[test] + fn execute_incomplete_or_failed_stream_never_claims_success() { + for (outputs, terminal) in [ + (vec![json!({"eof":true})], json!({})), + ( + vec![json!({"exitCode":0, "eof":true})], + json!({"error":{"code":"unavailable", "message":"lost stream"}}), + ), + ] { + let auth = auth_service(); + let service = execute_service(outputs, terminal); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + "exec", + "my-workstation", + "--base-url", + "https://blox.test.blockstaging.build", + "--", + "true", + ], + ); + assert_eq!(output.status.code(), Some(5)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], "execute_output_incomplete"); + assert!(failure["error"]["message"] + .as_str() + .unwrap() + .contains("command-id")); + assert!(!process_stdout(&output).contains("exit_code")); + assert_execute_requests(auth, service); + } + } + + #[test] + fn execute_rejected_authentication_stops_before_any_workstation_request() { + let auth = MockService::start(401, json!({"error":"expired"})); + let output = run_process( + &auth, + &auth, + &[ + "--json", + "blox", + "exec", + "my-workstation", + "--base-url", + "https://blox.test.blockstaging.build", + "--", + "true", + ], + ); + assert_eq!(output.status.code(), Some(3)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], "auth_required"); + assert_eq!(auth.finish().0, "/api/goose/v1/auth/me"); + } + + #[test] + fn ordinary_marketplace_json_errors_still_have_one_error_envelope() { + let service = MockService::start( + 403, + json!({"error":{"code":"forbidden", "message":"not permitted"}}), + ); + let output = run_process(&service, &service, &["--json", "skills", "list"]); + assert_eq!(output.status.code(), Some(4)); + let payload: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(payload["error"]["code"], "forbidden"); + assert_eq!(payload["error"]["exit_code"], 4); + assert!(service.finish().0.contains("/v1/marketplace/skills")); + } + + #[test] + fn execute_start_rejection_or_unknown_result_does_not_retry() { + for (status, reply, expected_code) in [ + ( + 400, + json!({"code":"failed_precondition", "message":"workstation is suspended"}), + "failed_precondition", + ), + ( + 503, + json!({"code":"unavailable", "message":"lost acknowledgement"}), + "execute_outcome_unknown", + ), + (200, json!({}), "execute_outcome_unknown"), + ] { + let auth = auth_service(); + let service = MockService::steps(vec![ + MockReply::Json(200, json!({"workstation":{"workstationId":"resolved-id"}})), + MockReply::Json(status, reply), + ]); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + "exec", + "my-workstation", + "--base-url", + "https://blox.test.blockstaging.build", + "--", + "true", + ], + ); + assert!(!output.status.success()); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], expected_code); + assert_eq!(auth.finish().0, "/api/goose/v1/auth/me"); + let requests = service.finish_all(); + assert_eq!(requests.len(), 2); + assert!(requests[1] + .0 + .ends_with(".OrchestratorService/ExecuteProcess")); + } + } +} diff --git a/src/bl/blox/get.rs b/src/bl/blox/get.rs new file mode 100644 index 0000000..4b14d5e --- /dev/null +++ b/src/bl/blox/get.rs @@ -0,0 +1,141 @@ +use super::context; +use super::context::map_error; +use super::output::status_text; +use crate::bl::display::{print_json, terminal_safe_text}; +use crate::bl::skills_api::{exit_codes, failure}; +use crate::bl::skills_config::SkillsConfig; +use anyhow::{Context, Result}; +use blox::proto::WorkstationSummary; +use clap::{Arg, ArgMatches, Command}; +pub(super) fn command() -> Command { + Command::new("get") + .about("Get a workstation by canonical name or full ID") + .arg( + Arg::new("workstation") + .value_name("WORKSTATION") + .required(true) + .value_parser(clap::builder::NonEmptyStringValueParser::new()), + ) +} + +pub(super) fn run(config: &SkillsConfig, args: &ArgMatches) -> Result<()> { + let client = context::client(config, args, "get")?; + let identifier = args + .get_one::("workstation") + .context("expected workstation name or ID")?; + let response = client.get_workstation(identifier).map_err(map_error)?; + let workstation = response.workstation.as_ref().ok_or_else(|| { + failure( + exit_codes::GENERAL, + "invalid_response", + "Blox returned no workstation", + ) + })?; + if config.json { + print_json(&response) + } else { + print_workstation(workstation); + Ok(()) + } +} + +fn print_workstation(workstation: &WorkstationSummary) { + for (label, value) in [ + ("ID", workstation.workstation_id.as_str()), + ("Name", workstation.name.as_str()), + ("Provider", workstation.provider.as_str()), + ("Created", workstation.created_at.as_str()), + ("Last used", workstation.last_used_at.as_str()), + ("Message", workstation.message.as_str()), + ] { + println!("{label}: {}", terminal_safe_text(value)); + } + println!("Status: {}", status_text(workstation.status)); + if let Some(label) = &workstation.display_name { + println!("Display name: {}", terminal_safe_text(label)); + } +} + +#[cfg(test)] +mod tests { + use super::super::test_support::*; + use serde_json::{json, Value}; + + #[test] + fn get_forwards_the_selector_unchanged() { + let auth = auth_service(); + let service = MockService::start( + 200, + json!({"workstation":{"workstationId":"123", "name":"123", "status":"WORKSTATION_STATUS_SUSPENDING"}}), + ); + let output = run_process( + &auth, + &service, + &[ + "blox", + "get", + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("Status: suspending")); + let (_, body) = assert_shared_auth(auth, service); + assert_eq!(body, json!({"workstation":"123"})); + } + + #[test] + fn errors_use_shared_json_format_and_redact_the_session() { + let auth = auth_service(); + let service = MockService::start( + 403, + json!({"code":"permission_denied", "message":"denied blox-test-only.session"}), + ); + let output = run_process( + &auth, + &service, + &[ + "blox", + "get", + "project", + "--base-url", + "https://blox.test.blockstaging.build", + "--json", + ], + ); + assert_eq!(output.status.code(), Some(4)); + let stderr = String::from_utf8_lossy(&output.stderr); + let payload: Value = serde_json::from_str(stderr.trim()).unwrap(); + assert_eq!(payload["error"]["code"], "permission_denied"); + assert_eq!(payload["error"]["exit_code"], 4); + assert!(!stderr.contains("blox-test-only.session")); + assert_shared_auth(auth, service); + } + + #[test] + fn missing_workstation_is_an_error() { + let auth = auth_service(); + let service = MockService::start(200, json!({})); + let output = run_process( + &auth, + &service, + &[ + "blox", + "get", + "project", + "--base-url", + "https://blox.test.blockstaging.build", + "--json", + ], + ); + assert_eq!(output.status.code(), Some(1)); + let payload: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(payload["error"]["code"], "invalid_response"); + assert_shared_auth(auth, service); + } +} diff --git a/src/bl/blox/lifecycle.rs b/src/bl/blox/lifecycle.rs new file mode 100644 index 0000000..872b43e --- /dev/null +++ b/src/bl/blox/lifecycle.rs @@ -0,0 +1,112 @@ +use super::context::map_error as map_rpc_error; +use super::output::status_text; +use crate::bl::display::{print_json, terminal_safe_text}; +use crate::bl::skills_api::{exit_codes, failure}; +use crate::bl::skills_config::SkillsConfig; +use anyhow::Result; +use blox::proto::WorkstationStatus; +use serde::Serialize; +pub(super) struct LifecycleSummary<'a> { + pub(super) workstation_id: &'a str, + pub(super) status: Option, + pub(super) success: bool, + pub(super) message: &'a str, +} + +pub(super) fn print_result( + config: &SkillsConfig, + operation: &str, + requested_id: &str, + summary: LifecycleSummary<'_>, + response: &T, +) -> Result<()> { + if summary.workstation_id != requested_id { + return Err(lifecycle_unknown( + operation, + requested_id, + Some(summary.message), + )); + } + if !summary.success + || matches!(summary.status, Some(status) if status == WorkstationStatus::Error as i32 || status == WorkstationStatus::Degraded as i32) + { + return Err(failure( + exit_codes::GENERAL, + "lifecycle_failed", + format!( + "Blox {operation} failed for workstation {}: {}", + terminal_safe_text(requested_id), + if summary.message.is_empty() { + "server reported an unsuccessful transition".to_string() + } else { + terminal_safe_text(summary.message) + } + ), + )); + } + if let Some(status) = summary.status { + let accepted = WorkstationStatus::try_from(status).is_ok_and(|status| match operation { + "suspend" => matches!( + status, + WorkstationStatus::Suspending + | WorkstationStatus::Suspended + | WorkstationStatus::Deleting + | WorkstationStatus::Deleted + ), + "resume" => matches!( + status, + WorkstationStatus::Queued + | WorkstationStatus::Creating + | WorkstationStatus::Starting + | WorkstationStatus::Running + ), + _ => false, + }); + if !accepted { + return Err(lifecycle_unknown( + operation, + requested_id, + Some(summary.message), + )); + } + } + if config.json { + return print_json(response); + } + println!("Request accepted: {operation}"); + println!("ID: {}", terminal_safe_text(summary.workstation_id)); + if let Some(status) = summary.status { + println!("Server status: {}", status_text(status)); + } + if !summary.message.is_empty() { + println!("Message: {}", terminal_safe_text(summary.message)); + } + println!( + "Inspect progress with `bl blox get {}` (preserve any --base-url override).", + terminal_safe_text(requested_id) + ); + Ok(()) +} + +fn lifecycle_unknown(operation: &str, id: &str, server_message: Option<&str>) -> anyhow::Error { + let mut message = format!("Blox {operation} result is unknown for workstation {}. Inspect `bl blox get {}` with the same Blox endpoint before trying again; the transition may already be underway. Preserve any --base-url override.", terminal_safe_text(id), terminal_safe_text(id)); + if let Some(server_message) = server_message.filter(|message| !message.is_empty()) { + message.push_str(&format!( + " Server message: {}", + terminal_safe_text(server_message) + )); + } + failure(exit_codes::NETWORK, "lifecycle_outcome_unknown", message) +} + +pub(super) fn map_error(error: anyhow::Error, operation: &str, id: &str) -> anyhow::Error { + if let Some(rpc) = error.downcast_ref::() { + if !matches!( + rpc.code.as_str(), + "unavailable" | "deadline_exceeded" | "unknown" | "internal" | "data_loss" + ) { + return map_rpc_error(error); + } + } + lifecycle_unknown(operation, id, None) +} diff --git a/src/bl/blox/list.rs b/src/bl/blox/list.rs new file mode 100644 index 0000000..9265258 --- /dev/null +++ b/src/bl/blox/list.rs @@ -0,0 +1,162 @@ +use super::context; +use super::context::map_error; +use super::output::status_text; +use crate::bl::display::{print_json, terminal_safe_text}; +use crate::bl::skills_api::{exit_codes, failure}; +use crate::bl::skills_config::SkillsConfig; +use anyhow::{Context, Result}; +use blox::proto::ListWorkstationsRequest; +use clap::ArgAction; +use clap::{Arg, ArgMatches, Command}; +pub(super) fn command() -> Command { + Command::new("list") + .about("List your workstations") + .arg( + Arg::new("include-deleted") + .long("include-deleted") + .action(ArgAction::SetTrue) + .help("Include archived workstations"), + ) + .arg( + Arg::new("limit") + .long("limit") + .value_name("COUNT") + .value_parser(clap::value_parser!(i32).range(0..)) + .default_value("100") + .help("Maximum results (0 means all)"), + ) + .arg( + Arg::new("offset") + .long("offset") + .value_name("COUNT") + .value_parser(clap::value_parser!(i32).range(0..)) + .default_value("0") + .help("Number of results to skip"), + ) +} + +pub(super) fn run(config: &SkillsConfig, args: &ArgMatches) -> Result<()> { + if args.get_one::("limit") == Some(&0) + && args + .get_one::("offset") + .is_some_and(|offset| *offset > 0) + { + return Err(failure( + exit_codes::GENERAL, + "invalid_pagination", + "--offset requires a nonzero --limit", + )); + } + let client = context::client(config, args, "list")?; + let response = client + .list_workstations(&ListWorkstationsRequest { + include_deleted: Some(args.get_flag("include-deleted")), + page_limit: *args + .get_one::("limit") + .context("expected result limit")?, + page_offset: *args + .get_one::("offset") + .context("expected result offset")?, + skip_access_details: true, + }) + .map_err(map_error)?; + if config.json { + print_json(&response) + } else { + if response.workstations.is_empty() { + println!("No workstations found."); + } else { + println!("ID\tNAME\tSTATUS\tPROVIDER"); + for workstation in &response.workstations { + println!( + "{}\t{}\t{}\t{}", + terminal_safe_text(&workstation.workstation_id), + terminal_safe_text(&workstation.name), + status_text(workstation.status), + terminal_safe_text(&workstation.provider) + ); + } + println!( + "{} of {} workstations", + response.workstations.len(), + response.total_count + ); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::super::test_support::*; + use serde_json::{json, Value}; + + #[test] + fn list_uses_shared_session_and_preserves_protobuf_json() { + let auth = auth_service(); + let service = MockService::start( + 200, + json!({"workstations":[{"workstationId":"9223372036854775806", "name":"project", "status":999}], "totalCount":"9007199254740993", "recentlyDeletedCount":"7"}), + ); + let output = run_process( + &auth, + &service, + &[ + "blox", + "--base-url", + "https://blox.public.builderlab.xyz", + "list", + "--include-deleted", + "--limit", + "2", + "--offset", + "3", + "--json", + ], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("9007199254740993")); + assert!(stdout.contains("9223372036854775806")); + assert!(!stdout.contains("999")); // Forward enum variants become unspecified. + + let (path, body) = assert_shared_auth(auth, service); + assert!( + path.ends_with("/OrchestratorService/ListWorkstations") + || path.ends_with(".OrchestratorService/ListWorkstations") + ); + assert_eq!( + body, + json!({"includeDeleted":true, "pageLimit":2, "pageOffset":3, "skipAccessDetails":true}) + ); + } + + #[test] + fn rejected_shared_session_stops_before_blox_request() { + let auth = MockService::start(401, json!({"error":"expired"})); + let output = run_process( + &auth, + &auth, + &[ + "blox", + "list", + "--base-url", + "https://blox.test.blockstaging.build", + "--json", + ], + ); + assert_eq!(output.status.code(), Some(3)); + let payload: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(payload["error"]["code"], "auth_required"); + assert!(payload["error"]["message"] + .as_str() + .unwrap() + .contains("bl auth login")); + let (path, _, _) = auth.finish(); + assert_eq!(path, "/api/goose/v1/auth/me"); + } +} diff --git a/src/bl/blox/mod.rs b/src/bl/blox/mod.rs new file mode 100644 index 0000000..3b415b3 --- /dev/null +++ b/src/bl/blox/mod.rs @@ -0,0 +1,60 @@ +//! Blox workstation commands using the shared BuilderLab session. +mod context; +mod create; +mod delete; +mod exec; +mod get; +mod lifecycle; +mod list; +mod output; +mod resume; +mod skus; +mod suspend; +#[cfg(test)] +mod test_support; + +use crate::bl::runner; +use crate::bl::skills_config::SkillsConfig; +use anyhow::{Context, Result}; +use clap::{ArgMatches, Command}; +use serde_json::Value; + +pub fn command() -> Command { + context::args(Command::new("blox") + .about("Manage Blox workstations") + .long_about("Manage your Blox workstations using your BuilderLab login. Names are resolved by the orchestrator; use the full ID to identify an archived workstation.") + .subcommand_required(true) + .arg_required_else_help(true) + .disable_help_subcommand(true) + .subcommand(list::command()) + .subcommand(skus::command()) + .subcommand(create::command()) + .subcommand(exec::command()) + .subcommand(suspend::command()) + .subcommand(resume::command()) + .subcommand(delete::command()) + .subcommand(get::command())) +} + +pub fn describe_commands() -> Value { + crate::bl::description::describe_command_tree(&command()) +} + +pub fn run(matches: &ArgMatches) -> Result<()> { + runner::run(matches, dispatch) +} + +fn dispatch(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let (operation, args) = matches.subcommand().context("expected a blox subcommand")?; + match operation { + "list" => list::run(config, args), + "get" => get::run(config, args), + "skus" => skus::run(config, args), + "create" => create::run(config, args), + "exec" => exec::run(config, args), + "suspend" => suspend::run(config, args), + "resume" => resume::run(config, args), + "delete" => delete::run(config, args), + _ => anyhow::bail!("expected a blox subcommand"), + } +} diff --git a/src/bl/blox/output.rs b/src/bl/blox/output.rs new file mode 100644 index 0000000..77b843a --- /dev/null +++ b/src/bl/blox/output.rs @@ -0,0 +1,25 @@ +use blox::proto::WorkstationStatus; + +pub(super) fn status_text(status: i32) -> String { + WorkstationStatus::try_from(status) + .map(|status| { + status + .as_str_name() + .trim_start_matches("WORKSTATION_STATUS_") + .to_ascii_lowercase() + }) + .unwrap_or_else(|_| format!("unknown({status})")) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn unknown_and_transient_statuses_are_displayable() { + assert_eq!(status_text(999), "unknown(999)"); + assert_eq!(status_text(9), "pending"); + assert_eq!(status_text(10), "queued"); + assert_eq!(status_text(11), "suspending"); + assert_eq!(status_text(12), "deleting"); + } +} diff --git a/src/bl/blox/resume.rs b/src/bl/blox/resume.rs new file mode 100644 index 0000000..ef8947e --- /dev/null +++ b/src/bl/blox/resume.rs @@ -0,0 +1,258 @@ +use super::context; +use super::lifecycle::{self, LifecycleSummary}; +use crate::bl::skills_config::SkillsConfig; +use anyhow::Result; +use blox::proto::ResumeWorkstationRequest; +use clap::{Arg, ArgMatches, Command}; + +pub(super) fn command() -> Command { + Command::new("resume").about("Request workstation resumption") + .long_about("Request this transition for a workstation identified by canonical name or full ID. Reports the server response without waiting for the final state; inspect progress with `bl blox get `. Use the full ID for an archived workstation.") + .arg(Arg::new("workstation").value_name("WORKSTATION").required(true).value_parser(clap::builder::NonEmptyStringValueParser::new())) +} + +pub(super) fn run(config: &SkillsConfig, args: &ArgMatches) -> Result<()> { + let client = context::client(config, args, "resume")?; + let workstation = context::workstation(&client, args)?; + let id = &workstation.workstation_id; + let response = client + .resume_workstation(&ResumeWorkstationRequest { + workstation_id: id.clone(), + }) + .map_err(|error| lifecycle::map_error(error, "resume", id))?; + lifecycle::print_result( + config, + "resume", + id, + LifecycleSummary { + workstation_id: &response.workstation_id, + status: Some(response.status), + success: true, + message: &response.message, + }, + &response, + ) +} + +#[cfg(test)] +mod tests { + use super::super::test_support::*; + use serde_json::{json, Value}; + #[test] + fn resume_resolves_name_once_and_preserves_response() { + let target = "9223372036854775806"; + let operation = "resume"; + let reply = json!({"workstationId":target, "status":"WORKSTATION_STATUS_QUEUED", "message":"resume queued"}); + let auth = auth_service(); + let service = lifecycle_service(target, 200, reply.clone()); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + operation, + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(output_json(&output), reply); + assert_lifecycle_requests(auth, service, operation, target); + } + + #[test] + fn resume_degraded_result_preserves_the_server_message() { + let operation = "resume"; + let response = json!({"workstationId":"target-id", "status":"WORKSTATION_STATUS_DEGRADED", "message":"bootstrap failed"}); + let auth = auth_service(); + let service = lifecycle_service("target-id", 200, response.clone()); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + operation, + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert_eq!(output.status.code(), Some(1)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], "lifecycle_failed"); + assert!(failure["error"]["message"] + .as_str() + .unwrap() + .contains(response["message"].as_str().unwrap())); + assert_lifecycle_requests(auth, service, operation, "target-id"); + } + + #[test] + fn resume_unknown_acknowledgements_keep_the_target_and_never_retry() { + let operation = "resume"; + for (status, response) in [ + ( + 503, + json!({"code":"unavailable", "message":"lost acknowledgement"}), + ), + (200, json!("unexpected response")), + ( + 200, + json!({"workstationId":"wrong-id", "message":"transition initiated"}), + ), + (200, json!({"message":"transition initiated"})), + ] { + let auth = auth_service(); + let service = lifecycle_service("known-id", status, response); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + operation, + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert_eq!(output.status.code(), Some(5)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], "lifecycle_outcome_unknown"); + let message = failure["error"]["message"].as_str().unwrap(); + assert!(message.contains(operation)); + assert!(message.contains("bl blox get known-id")); + assert!(message.contains("--base-url")); + assert!(message.contains("before trying again")); + assert_lifecycle_requests(auth, service, operation, "known-id"); + } + } + + #[test] + fn resume_rejected_authentication_stops_before_workstation_resolution() { + let operation = "resume"; + let auth = MockService::start(401, json!({"error":"expired"})); + let output = run_process( + &auth, + &auth, + &[ + "--json", + "blox", + operation, + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert_eq!(output.status.code(), Some(3)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], "auth_required"); + assert_eq!(auth.finish().0, "/api/goose/v1/auth/me"); + } + + #[test] + fn resume_definite_server_rejections_preserve_the_error_code() { + let operation = "resume"; + let auth = auth_service(); + let service = lifecycle_service( + "known-id", + 400, + json!({"code":"failed_precondition", "message":"transition is not allowed"}), + ); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + operation, + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert_eq!(output.status.code(), Some(1)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], "failed_precondition"); + assert_lifecycle_requests(auth, service, operation, "known-id"); + } + + #[test] + fn resume_unexpected_status_does_not_claim_acceptance() { + let operation = "resume"; + for status in [999, 7] { + let auth = auth_service(); + let service = lifecycle_service( + "known-id", + 200, + json!({"workstationId":"known-id", "status":status, "message":"inspect transition"}), + ); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + operation, + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert_eq!(output.status.code(), Some(5)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], "lifecycle_outcome_unknown"); + assert!(failure["error"]["message"] + .as_str() + .unwrap() + .contains("inspect transition")); + assert_lifecycle_requests(auth, service, operation, "known-id"); + } + } + + #[test] + fn resume_accepts_initial_states_and_full_archived_ids_without_claiming_readiness() { + for status in [ + "WORKSTATION_STATUS_QUEUED", + "WORKSTATION_STATUS_CREATING", + "WORKSTATION_STATUS_STARTING", + "WORKSTATION_STATUS_RUNNING", + ] { + let auth = auth_service(); + let service = lifecycle_service( + "archived-id", + 200, + json!({"workstationId":"archived-id", "status":status, "message":"resume requested"}), + ); + let output = run_process( + &auth, + &service, + &[ + "blox", + "resume", + "archived-id", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = process_stdout(&output); + assert!(stdout.contains("Request accepted: resume")); + assert!(stdout.contains("Server status:")); + assert!(stdout.contains("bl blox get archived-id")); + assert!(!stdout.contains("ready")); + assert_lifecycle_requests(auth, service, "resume", "archived-id"); + } + } +} diff --git a/src/bl/blox/skus.rs b/src/bl/blox/skus.rs new file mode 100644 index 0000000..560e509 --- /dev/null +++ b/src/bl/blox/skus.rs @@ -0,0 +1,74 @@ +use super::context; +use super::context::map_error; +use crate::bl::display::{print_json, terminal_safe_text}; +use crate::bl::skills_config::SkillsConfig; +use anyhow::Result; +use clap::{ArgMatches, Command}; + +pub(super) fn command() -> Command { + Command::new("skus").about("List available workstation SKUs and maximum multiples") +} + +pub(super) fn run(config: &SkillsConfig, args: &ArgMatches) -> Result<()> { + let client = context::client(config, args, "skus")?; + let response = client.list_compute_providers().map_err(map_error)?; + if config.json { + print_json(&response) + } else { + if response.compute_skus.is_empty() { + println!("No workstation SKUs are available for your account."); + } else { + println!("CODE\tNAME\tMAX MULTIPLE\tPROVIDER"); + for sku in &response.compute_skus { + println!( + "{}\t{}\t{}\t{}", + terminal_safe_text(&sku.code), + terminal_safe_text(&sku.display_name), + sku.max_multiple, + terminal_safe_text(&sku.compute_provider) + ); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::super::test_support::*; + use serde_json::json; + + #[test] + fn skus_discovery_preserves_catalog_json_and_prints_codes_with_maximums() { + for json_output in [false, true] { + let auth = auth_service(); + let catalog = json!({"computeSkuRequired":true, "computeSkus":[{"code":"DEFAULT_AMD64", "displayName":"Default", "cpu":1, "memoryGb":8, "maxMultiple":8, "computeProvider":"worker-cells"}]}); + let service = MockService::start(200, catalog.clone()); + let mut args = vec![ + "blox", + "skus", + "--base-url", + "https://blox.test.blockstaging.build", + ]; + if json_output { + args.push("--json"); + } + let output = run_process(&auth, &service, &args); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + if json_output { + assert_eq!(output_json(&output), catalog); + } else { + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("MAX MULTIPLE")); + assert!(stdout.contains("DEFAULT_AMD64\tDefault\t8\tworker-cells")); + } + let (path, request) = assert_shared_auth(auth, service); + assert!(path.ends_with(".OrchestratorService/ListComputeProviders")); + assert_eq!(request, json!({})); + } + } +} diff --git a/src/bl/blox/suspend.rs b/src/bl/blox/suspend.rs new file mode 100644 index 0000000..251e0fe --- /dev/null +++ b/src/bl/blox/suspend.rs @@ -0,0 +1,219 @@ +use super::context; +use super::lifecycle::{self, LifecycleSummary}; +use crate::bl::skills_config::SkillsConfig; +use anyhow::Result; +use blox::proto::SuspendWorkstationRequest; +use clap::{Arg, ArgMatches, Command}; + +pub(super) fn command() -> Command { + Command::new("suspend").about("Request workstation suspension") + .long_about("Request this transition for a workstation identified by canonical name or full ID. Reports the server response without waiting for the final state; inspect progress with `bl blox get `. Use the full ID for an archived workstation.") + .arg(Arg::new("workstation").value_name("WORKSTATION").required(true).value_parser(clap::builder::NonEmptyStringValueParser::new())) +} + +pub(super) fn run(config: &SkillsConfig, args: &ArgMatches) -> Result<()> { + let client = context::client(config, args, "suspend")?; + let workstation = context::workstation(&client, args)?; + let id = &workstation.workstation_id; + let response = client + .suspend_workstation(&SuspendWorkstationRequest { + workstation_id: id.clone(), + }) + .map_err(|error| lifecycle::map_error(error, "suspend", id))?; + lifecycle::print_result( + config, + "suspend", + id, + LifecycleSummary { + workstation_id: &response.workstation_id, + status: Some(response.status), + success: true, + message: &response.message, + }, + &response, + ) +} + +#[cfg(test)] +mod tests { + use super::super::test_support::*; + use serde_json::{json, Value}; + #[test] + fn suspend_resolves_name_once_and_preserves_response() { + let target = "9223372036854775806"; + let operation = "suspend"; + let reply = json!({"workstationId":target, "status":"WORKSTATION_STATUS_SUSPENDED", "message":"suspension initiated"}); + let auth = auth_service(); + let service = lifecycle_service(target, 200, reply.clone()); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + operation, + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(output_json(&output), reply); + assert_lifecycle_requests(auth, service, operation, target); + } + + #[test] + fn suspend_error_result_preserves_the_server_message() { + let operation = "suspend"; + let response = json!({"workstationId":"target-id", "status":"WORKSTATION_STATUS_ERROR", "message":"snapshot failed"}); + let auth = auth_service(); + let service = lifecycle_service("target-id", 200, response.clone()); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + operation, + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert_eq!(output.status.code(), Some(1)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], "lifecycle_failed"); + assert!(failure["error"]["message"] + .as_str() + .unwrap() + .contains(response["message"].as_str().unwrap())); + assert_lifecycle_requests(auth, service, operation, "target-id"); + } + + #[test] + fn suspend_unknown_acknowledgements_keep_the_target_and_never_retry() { + let operation = "suspend"; + for (status, response) in [ + ( + 503, + json!({"code":"unavailable", "message":"lost acknowledgement"}), + ), + (200, json!("unexpected response")), + ( + 200, + json!({"workstationId":"wrong-id", "message":"transition initiated"}), + ), + (200, json!({"message":"transition initiated"})), + ] { + let auth = auth_service(); + let service = lifecycle_service("known-id", status, response); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + operation, + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert_eq!(output.status.code(), Some(5)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], "lifecycle_outcome_unknown"); + let message = failure["error"]["message"].as_str().unwrap(); + assert!(message.contains(operation)); + assert!(message.contains("bl blox get known-id")); + assert!(message.contains("--base-url")); + assert!(message.contains("before trying again")); + assert_lifecycle_requests(auth, service, operation, "known-id"); + } + } + + #[test] + fn suspend_rejected_authentication_stops_before_workstation_resolution() { + let operation = "suspend"; + let auth = MockService::start(401, json!({"error":"expired"})); + let output = run_process( + &auth, + &auth, + &[ + "--json", + "blox", + operation, + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert_eq!(output.status.code(), Some(3)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], "auth_required"); + assert_eq!(auth.finish().0, "/api/goose/v1/auth/me"); + } + + #[test] + fn suspend_definite_server_rejections_preserve_the_error_code() { + let operation = "suspend"; + let auth = auth_service(); + let service = lifecycle_service( + "known-id", + 400, + json!({"code":"failed_precondition", "message":"transition is not allowed"}), + ); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + operation, + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert_eq!(output.status.code(), Some(1)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], "failed_precondition"); + assert_lifecycle_requests(auth, service, operation, "known-id"); + } + + #[test] + fn suspend_unexpected_status_does_not_claim_acceptance() { + let operation = "suspend"; + for status in [0, 10] { + let auth = auth_service(); + let service = lifecycle_service( + "known-id", + 200, + json!({"workstationId":"known-id", "status":status, "message":"inspect transition"}), + ); + let output = run_process( + &auth, + &service, + &[ + "--json", + "blox", + operation, + "123", + "--base-url", + "https://blox.test.blockstaging.build", + ], + ); + assert_eq!(output.status.code(), Some(5)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["error"]["code"], "lifecycle_outcome_unknown"); + assert!(failure["error"]["message"] + .as_str() + .unwrap() + .contains("inspect transition")); + assert_lifecycle_requests(auth, service, operation, "known-id"); + } + } +} diff --git a/src/bl/blox/test_support.rs b/src/bl/blox/test_support.rs new file mode 100644 index 0000000..3f5f6e9 --- /dev/null +++ b/src/bl/blox/test_support.rs @@ -0,0 +1,258 @@ +//! Isolated subprocess and mock-service support for Blox command tests. + +use std::collections::BTreeMap; +use std::process::{Command as ProcessCommand, Output}; +use std::thread; +use std::time::Duration; + +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use tiny_http::{Header, Response, Server}; + +// Runs the CLI in its own process so environment-based auth is isolated +// from other unit tests and uses the real stored-session verification. +#[test] +fn blox_process_helper() { + let Ok(argv) = std::env::var("BL_BLOX_E2E_ARGS") else { + return; + }; + println!("BLOX_E2E_OUTPUT_BEGIN"); + let result = crate::run_bl_with_argv(serde_json::from_str(&argv).unwrap()); + if let Err(error) = result { + if let Some(silent) = error.downcast_ref::() { + std::process::exit(silent.0); + } + eprintln!("error: {error:#}"); + std::process::exit(crate::bl::skills_api::failure_info(&error).0); + } + println!("BLOX_E2E_OUTPUT_END"); +} + +pub(super) type RecordedRequest = (String, BTreeMap, Value); + +pub(super) enum MockReply { + Json(u16, Value), + Stream(Vec, Value), +} + +pub(super) struct MockService { + pub(super) url: String, + request: thread::JoinHandle>, +} + +impl MockService { + pub(super) fn start(status: u16, value: Value) -> Self { + Self::steps(vec![MockReply::Json(status, value)]) + } + + pub(super) fn steps(replies: Vec) -> Self { + let server = Server::http("127.0.0.1:0").unwrap(); + let url = format!("http://{}", server.server_addr()); + let request = thread::spawn(move || { + let mut requests = Vec::new(); + for reply in replies { + let mut request = server + .recv_timeout(Duration::from_secs(15)) + .unwrap() + .expect("mock request"); + let path = request.url().to_string(); + let headers = request + .headers() + .iter() + .map(|header| { + ( + header.field.to_string().to_ascii_lowercase(), + header.value.to_string(), + ) + }) + .collect(); + let mut body = Vec::new(); + request.as_reader().read_to_end(&mut body).unwrap(); + let body = if body.is_empty() { + Value::Null + } else if body[0] == 0 { + // Server-streaming Connect requests carry one framed JSON message. + serde_json::from_slice(&body[5..]).unwrap() + } else { + serde_json::from_slice(&body).unwrap() + }; + let (status, bytes, content_type) = match reply { + MockReply::Json(status, value) => ( + status, + serde_json::to_vec(&value).unwrap(), + "application/json", + ), + MockReply::Stream(outputs, terminal) => { + let mut bytes = Vec::new(); + for (flags, value) in outputs + .into_iter() + .map(|output| (0, output)) + .chain(std::iter::once((2, terminal))) + { + let body = serde_json::to_vec(&value).unwrap(); + bytes.push(flags); + bytes.extend_from_slice(&(body.len() as u32).to_be_bytes()); + bytes.extend_from_slice(&body); + } + (200, bytes, "application/connect+json") + } + }; + request + .respond( + Response::from_data(bytes) + .with_status_code(status) + .with_header(Header::from_bytes("Content-Type", content_type).unwrap()), + ) + .unwrap(); + requests.push((path, headers, body)); + } + requests + }); + Self { url, request } + } + + pub(super) fn finish(self) -> RecordedRequest { + let mut requests = self.finish_all(); + assert_eq!(requests.len(), 1); + requests.pop().unwrap() + } + + pub(super) fn finish_all(self) -> Vec { + self.request.join().unwrap() + } +} + +pub(super) fn run_process(auth: &MockService, service: &MockService, args: &[&str]) -> Output { + let home = tempfile::tempdir().unwrap(); + std::fs::write(home.path().join("config.yaml"), "org: test\n").unwrap(); + let session_path = home.path().join("sessions.json"); + let service_url = format!("{}/api/goose", auth.url); + let mut hash = Sha256::new(); + hash.update(b"default"); + hash.update([0]); + hash.update(service_url.as_bytes()); + let key = hash + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + std::fs::write(&session_path, json!({ key: {"sessionCredential":"blox-test-only.session", "expiresAt":"2099-01-01T00:00:00Z"}}).to_string()).unwrap(); + let argv = std::iter::once("bl") + .chain(args.iter().copied()) + .map(str::to_string) + .collect::>(); + ProcessCommand::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "bl::blox::test_support::blox_process_helper", + "--nocapture", + ]) + .env("BL_BLOX_E2E_ARGS", serde_json::to_string(&argv).unwrap()) + .env("BL_BLOX_E2E_ENDPOINT", &service.url) + .env("BL_HOME", home.path()) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", session_path) + .env("KGOOSE_BASE_URL", &auth.url) + .env("KGOOSE_SERVICE_PATH", "/api/goose") + .env_remove("BL_SKILLS_CONFIG") + .env_remove("BL_SKILLS_PROFILE") + .env_remove("BL_KGOOSE_PLAYPEN") + .env_remove("KGOOSE_PLAYPEN") + .env_remove("BL_BLOX_CONTROL_PLANE_URL") + .output() + .unwrap() +} + +pub(super) fn auth_service() -> MockService { + MockService::start( + 200, + json!({"subject":"test", "email":"blox-test@example.test", "name":"Test", "expires_at":"2099-01-01T00:00:00Z", "workspaces":{"active":[]}}), + ) +} + +pub(super) fn assert_shared_auth(auth: MockService, service: MockService) -> (String, Value) { + let (auth_path, auth_headers, _) = auth.finish(); + assert_eq!(auth_path, "/api/goose/v1/auth/me"); + assert_eq!( + auth_headers["x-bb-session-credential"], + "blox-test-only.session" + ); + let (path, headers, body) = service.finish(); + assert_eq!( + headers["authorization"], + "BBIdentity blox-test-only.session" + ); + for header in [ + "cookie", + "x-bb-session-credential", + "x-forwarded-identity-token", + "x-forwarded-user", + "x-blox-workstation-key", + ] { + assert!(!headers.contains_key(header), "unexpected {header}"); + } + (path, body) +} + +pub(super) fn output_json(output: &Output) -> Value { + let stdout = String::from_utf8_lossy(&output.stdout); + let body = stdout + .split("BLOX_E2E_OUTPUT_BEGIN") + .nth(1) + .unwrap() + .split("BLOX_E2E_OUTPUT_END") + .next() + .unwrap(); + serde_json::from_str(body.trim()).unwrap() +} + +pub(super) fn process_stdout(output: &Output) -> String { + let stdout = String::from_utf8_lossy(&output.stdout); + let body = stdout.split("BLOX_E2E_OUTPUT_BEGIN\n").nth(1).unwrap(); + body.split("BLOX_E2E_OUTPUT_END") + .next() + .unwrap() + .to_string() +} + +pub(super) fn lifecycle_service(target: &str, status: u16, response: Value) -> MockService { + MockService::steps(vec![ + MockReply::Json( + 200, + json!({"workstation":{"workstationId":target, "name":"123", "status":"WORKSTATION_STATUS_DELETED"}}), + ), + MockReply::Json(status, response), + ]) +} + +pub(super) fn assert_lifecycle_requests( + auth: MockService, + service: MockService, + operation: &str, + target: &str, +) { + assert_eq!(auth.finish().0, "/api/goose/v1/auth/me"); + let requests = service.finish_all(); + assert_eq!(requests.len(), 2); + assert!(requests[0] + .0 + .ends_with(".OrchestratorService/GetWorkstation")); + let rpc = match operation { + "suspend" => "SuspendWorkstation", + "resume" => "ResumeWorkstation", + "delete" => "DeleteWorkstation", + _ => panic!("unknown test operation"), + }; + assert!(requests[1] + .0 + .ends_with(&format!(".OrchestratorService/{rpc}"))); + assert_eq!(requests[1].2, json!({"workstationId":target})); + for (_, headers, _) in requests { + assert_eq!( + headers["authorization"], + "BBIdentity blox-test-only.session" + ); + assert!(!headers.contains_key("x-forwarded-identity-token")); + assert!(!headers.contains_key("cookie")); + } +} From a2061079c090923501a08386c2db1180b1d638c0 Mon Sep 17 00:00:00 2001 From: Stuart Douglas Date: Thu, 15 Oct 2026 12:11:44 +1100 Subject: [PATCH 3/4] Rename bl blox to compute with a hidden compatibility alias --- README.md | 34 ++++++----- src/bl/blox/create.rs | 16 ++--- src/bl/blox/delete.rs | 14 ++--- src/bl/blox/exec.rs | 12 ++-- src/bl/blox/get.rs | 6 +- src/bl/blox/lifecycle.rs | 4 +- src/bl/blox/list.rs | 82 +++++++++++++------------ src/bl/blox/mod.rs | 9 ++- src/bl/blox/resume.rs | 20 +++--- src/bl/blox/skus.rs | 2 +- src/bl/blox/suspend.rs | 16 ++--- src/lib.rs | 2 +- tests/blox_e2e.rs | 129 +++++++++++++++++++++++---------------- 13 files changed, 190 insertions(+), 156 deletions(-) diff --git a/README.md b/README.md index 09b9f97..e2d903f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ to see the current command surface: auth Manage BuilderLab marketplace authentication workspace Manage BuilderLab workspaces apps Manage apps through Apps Platform -blox Manage Blox workstations +compute Manage Blox workstations config Get and set bl preferences skills Manage BuilderLab skills agents Manage BuilderLab marketplace agents @@ -88,21 +88,23 @@ approved HTTPS ingress and requires `bl auth login`. Use your existing `bl auth login` session to create and inspect Blox workstations: ```bash -bl blox skus -bl blox create my-workstation --multiple 2 -bl blox list -bl blox get -bl blox list --include-deleted --limit 20 --offset 20 --json -bl blox exec my-workstation -- uname -a -bl blox suspend my-workstation -bl blox resume my-workstation -bl blox delete my-workstation +bl compute skus +bl compute create my-workstation --multiple 2 +bl compute list +bl compute get +bl compute list --include-deleted --limit 20 --offset 20 --json +bl compute exec my-workstation -- uname -a +bl compute suspend my-workstation +bl compute resume my-workstation +bl compute delete my-workstation ``` +`bl blox` remains available as a hidden compatibility alias for `bl compute`. + The Blox endpoint defaults to the deployment associated with your BuilderLab login: Block staging, public staging, or public production. Block production does not currently deploy Blox. Override the endpoint with -`bl blox --base-url list` or `BL_BLOX_CONTROL_PLANE_URL`. The CLI accepts +`bl compute --base-url list` or `BL_BLOX_CONTROL_PLANE_URL`. The CLI accepts only the approved HTTPS ingress origins `blox.test.blockstaging.build`, `blox.public.builderlabstaging.xyz`, and `blox.public.builderlab.xyz`. @@ -120,21 +122,21 @@ completions work offline. `create [NAME]` always requests a new workstation. Its default SKU is `DEFAULT_AMD64`, the SKU available in public BuilderLab staging; `--multiple` -defaults to 1 and must be positive. Use `bl blox skus` to see the codes and +defaults to 1 and must be positive. Use `bl compute skus` to see the codes and maximum multiples available to your account, and override with `--sku CODE` when needed. The deployment validates SKU availability and multiples. The CLI accepts no CPU, memory, repository, or prompt options. It requests no project repositories; the deployment may still bootstrap your configured home repository. Creation returns the initial workstation ID and status without waiting for -readiness. Inspect it with `bl blox get `. If the response is lost or cannot +readiness. Inspect it with `bl compute get `. If the response is lost or cannot be read, the CLI reports an unknown result and does not retry automatically. -Inspect `bl blox list` or `bl blox get ` before trying again to avoid +Inspect `bl compute list` or `bl compute get ` before trying again to avoid creating a second workstation. Preserve any `--base-url` override for these checks. `suspend`, `resume`, and `delete` accept a canonical workstation name or full ID. The CLI resolves the target once and sends one transition request. It reports -the server's response without waiting for the final state; use `bl blox get ` +the server's response without waiting for the final state; use `bl compute get ` to inspect progress. Suspension can take several minutes while capturing a snapshot, then resource teardown may continue after the response. A resume response does not establish readiness, and deletion may continue asynchronously. @@ -152,7 +154,7 @@ otherwise Blox selects the workstation's configured directory. Shell expansion and pipelines require an explicit shell, for example: ```bash -bl blox exec my-workstation -- sh -c 'printf hello; uname -a' +bl compute exec my-workstation -- sh -c 'printf hello; uname -a' ``` Execution is noninteractive: stdin and PTY allocation are unsupported. Commands diff --git a/src/bl/blox/create.rs b/src/bl/blox/create.rs index 06a1b04..361cc13 100644 --- a/src/bl/blox/create.rs +++ b/src/bl/blox/create.rs @@ -12,7 +12,7 @@ pub(super) fn command() -> Command { .about("Create a new workstation with a SKU and multiple") .long_about("Create a new workstation without requesting project repositories. The deployment may still bootstrap its configured home repository. Returns the initial ID and status; creation continues on the server.") .arg(Arg::new("name").value_name("NAME").value_parser(clap::builder::NonEmptyStringValueParser::new()).help("Optional workstation name (otherwise generated by the server)")) - .arg(Arg::new("sku").long("sku").value_name("CODE").default_value("DEFAULT_AMD64").value_parser(clap::builder::NonEmptyStringValueParser::new()).help("SKU code from `bl blox skus`")) + .arg(Arg::new("sku").long("sku").value_name("CODE").default_value("DEFAULT_AMD64").value_parser(clap::builder::NonEmptyStringValueParser::new()).help("SKU code from `bl compute skus`")) .arg(Arg::new("multiple").long("multiple").value_name("COUNT").default_value("1").value_parser(clap::value_parser!(i32).range(1..)).help("SKU multiple")) } @@ -47,7 +47,7 @@ pub(super) fn run(config: &SkillsConfig, args: &ArgMatches) -> Result<()> { println!("Message: {}", terminal_safe_text(&response.message)); } println!( - "Inspect with `bl blox get {}` (preserve any --base-url override).", + "Inspect with `bl compute get {}` (preserve any --base-url override).", terminal_safe_text(&response.workstation_id) ); Ok(()) @@ -55,7 +55,7 @@ pub(super) fn run(config: &SkillsConfig, args: &ArgMatches) -> Result<()> { } fn create_unknown_outcome() -> anyhow::Error { - failure(exit_codes::NETWORK, "create_outcome_unknown", "Blox creation result is unknown. Inspect `bl blox list` or `bl blox get ` before trying again; the workstation may already exist. Preserve any --base-url override when checking.") + failure(exit_codes::NETWORK, "create_outcome_unknown", "Blox creation result is unknown. Inspect `bl compute list` or `bl compute get ` before trying again; the workstation may already exist. Preserve any --base-url override when checking.") } fn map_create_error(error: anyhow::Error) -> anyhow::Error { @@ -86,7 +86,7 @@ mod tests { &auth, &service, &[ - "blox", + "compute", "create", "test-name", "--sku", @@ -126,7 +126,7 @@ mod tests { &auth, &service, &[ - "blox", + "compute", "create", "--base-url", "https://blox.test.blockstaging.build", @@ -155,7 +155,7 @@ mod tests { &auth, &auth, &[ - "blox", + "compute", "create", "--base-url", "https://blox.test.blockstaging.build", @@ -180,7 +180,7 @@ mod tests { &auth, &service, &[ - "blox", + "compute", "create", "--multiple", "9", @@ -215,7 +215,7 @@ mod tests { &auth, &service, &[ - "blox", + "compute", "create", "--base-url", "https://blox.test.blockstaging.build", diff --git a/src/bl/blox/delete.rs b/src/bl/blox/delete.rs index 42f0e16..9d05f35 100644 --- a/src/bl/blox/delete.rs +++ b/src/bl/blox/delete.rs @@ -7,7 +7,7 @@ use clap::{Arg, ArgMatches, Command}; pub(super) fn command() -> Command { Command::new("delete").about("Request workstation deletion") - .long_about("Request this transition for a workstation identified by canonical name or full ID. Reports the server response without waiting for the final state; inspect progress with `bl blox get `. Use the full ID for an archived workstation.") + .long_about("Request this transition for a workstation identified by canonical name or full ID. Reports the server response without waiting for the final state; inspect progress with `bl compute get `. Use the full ID for an archived workstation.") .arg(Arg::new("workstation").value_name("WORKSTATION").required(true).value_parser(clap::builder::NonEmptyStringValueParser::new())) } @@ -50,7 +50,7 @@ mod tests { &service, &[ "--json", - "blox", + "compute", operation, "123", "--base-url", @@ -78,7 +78,7 @@ mod tests { &service, &[ "--json", - "blox", + "compute", operation, "123", "--base-url", @@ -117,7 +117,7 @@ mod tests { &service, &[ "--json", - "blox", + "compute", operation, "123", "--base-url", @@ -129,7 +129,7 @@ mod tests { assert_eq!(failure["error"]["code"], "lifecycle_outcome_unknown"); let message = failure["error"]["message"].as_str().unwrap(); assert!(message.contains(operation)); - assert!(message.contains("bl blox get known-id")); + assert!(message.contains("bl compute get known-id")); assert!(message.contains("--base-url")); assert!(message.contains("before trying again")); assert_lifecycle_requests(auth, service, operation, "known-id"); @@ -145,7 +145,7 @@ mod tests { &auth, &[ "--json", - "blox", + "compute", operation, "123", "--base-url", @@ -172,7 +172,7 @@ mod tests { &service, &[ "--json", - "blox", + "compute", operation, "123", "--base-url", diff --git a/src/bl/blox/exec.rs b/src/bl/blox/exec.rs index a77510b..090acc6 100644 --- a/src/bl/blox/exec.rs +++ b/src/bl/blox/exec.rs @@ -188,7 +188,7 @@ mod tests { &auth, &service, &[ - "blox", + "compute", "exec", "my-workstation", "--workdir", @@ -234,7 +234,7 @@ mod tests { &service, &[ "--json", - "blox", + "compute", "exec", "my-workstation", "--base-url", @@ -271,7 +271,7 @@ mod tests { &auth, &service, &[ - "blox", + "compute", "exec", "my-workstation", "--base-url", @@ -303,7 +303,7 @@ mod tests { &service, &[ "--json", - "blox", + "compute", "exec", "my-workstation", "--base-url", @@ -332,7 +332,7 @@ mod tests { &auth, &[ "--json", - "blox", + "compute", "exec", "my-workstation", "--base-url", @@ -386,7 +386,7 @@ mod tests { &service, &[ "--json", - "blox", + "compute", "exec", "my-workstation", "--base-url", diff --git a/src/bl/blox/get.rs b/src/bl/blox/get.rs index 4b14d5e..e418d3b 100644 --- a/src/bl/blox/get.rs +++ b/src/bl/blox/get.rs @@ -72,7 +72,7 @@ mod tests { &auth, &service, &[ - "blox", + "compute", "get", "123", "--base-url", @@ -100,7 +100,7 @@ mod tests { &auth, &service, &[ - "blox", + "compute", "get", "project", "--base-url", @@ -125,7 +125,7 @@ mod tests { &auth, &service, &[ - "blox", + "compute", "get", "project", "--base-url", diff --git a/src/bl/blox/lifecycle.rs b/src/bl/blox/lifecycle.rs index 872b43e..834fc36 100644 --- a/src/bl/blox/lifecycle.rs +++ b/src/bl/blox/lifecycle.rs @@ -82,14 +82,14 @@ pub(super) fn print_result( println!("Message: {}", terminal_safe_text(summary.message)); } println!( - "Inspect progress with `bl blox get {}` (preserve any --base-url override).", + "Inspect progress with `bl compute get {}` (preserve any --base-url override).", terminal_safe_text(requested_id) ); Ok(()) } fn lifecycle_unknown(operation: &str, id: &str, server_message: Option<&str>) -> anyhow::Error { - let mut message = format!("Blox {operation} result is unknown for workstation {}. Inspect `bl blox get {}` with the same Blox endpoint before trying again; the transition may already be underway. Preserve any --base-url override.", terminal_safe_text(id), terminal_safe_text(id)); + let mut message = format!("Blox {operation} result is unknown for workstation {}. Inspect `bl compute get {}` with the same Blox endpoint before trying again; the transition may already be underway. Preserve any --base-url override.", terminal_safe_text(id), terminal_safe_text(id)); if let Some(server_message) = server_message.filter(|message| !message.is_empty()) { message.push_str(&format!( " Server message: {}", diff --git a/src/bl/blox/list.rs b/src/bl/blox/list.rs index 9265258..8c945fc 100644 --- a/src/bl/blox/list.rs +++ b/src/bl/blox/list.rs @@ -93,46 +93,48 @@ mod tests { #[test] fn list_uses_shared_session_and_preserves_protobuf_json() { - let auth = auth_service(); - let service = MockService::start( - 200, - json!({"workstations":[{"workstationId":"9223372036854775806", "name":"project", "status":999}], "totalCount":"9007199254740993", "recentlyDeletedCount":"7"}), - ); - let output = run_process( - &auth, - &service, - &[ - "blox", - "--base-url", - "https://blox.public.builderlab.xyz", - "list", - "--include-deleted", - "--limit", - "2", - "--offset", - "3", - "--json", - ], - ); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("9007199254740993")); - assert!(stdout.contains("9223372036854775806")); - assert!(!stdout.contains("999")); // Forward enum variants become unspecified. + for namespace in ["compute", "blox"] { + let auth = auth_service(); + let service = MockService::start( + 200, + json!({"workstations":[{"workstationId":"9223372036854775806", "name":"project", "status":999}], "totalCount":"9007199254740993", "recentlyDeletedCount":"7"}), + ); + let output = run_process( + &auth, + &service, + &[ + namespace, + "--base-url", + "https://blox.public.builderlab.xyz", + "list", + "--include-deleted", + "--limit", + "2", + "--offset", + "3", + "--json", + ], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("9007199254740993")); + assert!(stdout.contains("9223372036854775806")); + assert!(!stdout.contains("999")); // Forward enum variants become unspecified. - let (path, body) = assert_shared_auth(auth, service); - assert!( - path.ends_with("/OrchestratorService/ListWorkstations") - || path.ends_with(".OrchestratorService/ListWorkstations") - ); - assert_eq!( - body, - json!({"includeDeleted":true, "pageLimit":2, "pageOffset":3, "skipAccessDetails":true}) - ); + let (path, body) = assert_shared_auth(auth, service); + assert!( + path.ends_with("/OrchestratorService/ListWorkstations") + || path.ends_with(".OrchestratorService/ListWorkstations") + ); + assert_eq!( + body, + json!({"includeDeleted":true, "pageLimit":2, "pageOffset":3, "skipAccessDetails":true}) + ); + } } #[test] @@ -142,7 +144,7 @@ mod tests { &auth, &auth, &[ - "blox", + "compute", "list", "--base-url", "https://blox.test.blockstaging.build", diff --git a/src/bl/blox/mod.rs b/src/bl/blox/mod.rs index 3b415b3..6c06d3f 100644 --- a/src/bl/blox/mod.rs +++ b/src/bl/blox/mod.rs @@ -20,7 +20,8 @@ use clap::{ArgMatches, Command}; use serde_json::Value; pub fn command() -> Command { - context::args(Command::new("blox") + context::args(Command::new("compute") + .alias("blox") .about("Manage Blox workstations") .long_about("Manage your Blox workstations using your BuilderLab login. Names are resolved by the orchestrator; use the full ID to identify an archived workstation.") .subcommand_required(true) @@ -45,7 +46,9 @@ pub fn run(matches: &ArgMatches) -> Result<()> { } fn dispatch(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { - let (operation, args) = matches.subcommand().context("expected a blox subcommand")?; + let (operation, args) = matches + .subcommand() + .context("expected a compute subcommand")?; match operation { "list" => list::run(config, args), "get" => get::run(config, args), @@ -55,6 +58,6 @@ fn dispatch(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { "suspend" => suspend::run(config, args), "resume" => resume::run(config, args), "delete" => delete::run(config, args), - _ => anyhow::bail!("expected a blox subcommand"), + _ => anyhow::bail!("expected a compute subcommand"), } } diff --git a/src/bl/blox/resume.rs b/src/bl/blox/resume.rs index ef8947e..2780447 100644 --- a/src/bl/blox/resume.rs +++ b/src/bl/blox/resume.rs @@ -7,7 +7,7 @@ use clap::{Arg, ArgMatches, Command}; pub(super) fn command() -> Command { Command::new("resume").about("Request workstation resumption") - .long_about("Request this transition for a workstation identified by canonical name or full ID. Reports the server response without waiting for the final state; inspect progress with `bl blox get `. Use the full ID for an archived workstation.") + .long_about("Request this transition for a workstation identified by canonical name or full ID. Reports the server response without waiting for the final state; inspect progress with `bl compute get `. Use the full ID for an archived workstation.") .arg(Arg::new("workstation").value_name("WORKSTATION").required(true).value_parser(clap::builder::NonEmptyStringValueParser::new())) } @@ -50,7 +50,7 @@ mod tests { &service, &[ "--json", - "blox", + "compute", operation, "123", "--base-url", @@ -77,7 +77,7 @@ mod tests { &service, &[ "--json", - "blox", + "compute", operation, "123", "--base-url", @@ -116,7 +116,7 @@ mod tests { &service, &[ "--json", - "blox", + "compute", operation, "123", "--base-url", @@ -128,7 +128,7 @@ mod tests { assert_eq!(failure["error"]["code"], "lifecycle_outcome_unknown"); let message = failure["error"]["message"].as_str().unwrap(); assert!(message.contains(operation)); - assert!(message.contains("bl blox get known-id")); + assert!(message.contains("bl compute get known-id")); assert!(message.contains("--base-url")); assert!(message.contains("before trying again")); assert_lifecycle_requests(auth, service, operation, "known-id"); @@ -144,7 +144,7 @@ mod tests { &auth, &[ "--json", - "blox", + "compute", operation, "123", "--base-url", @@ -171,7 +171,7 @@ mod tests { &service, &[ "--json", - "blox", + "compute", operation, "123", "--base-url", @@ -199,7 +199,7 @@ mod tests { &service, &[ "--json", - "blox", + "compute", operation, "123", "--base-url", @@ -235,7 +235,7 @@ mod tests { &auth, &service, &[ - "blox", + "compute", "resume", "archived-id", "--base-url", @@ -250,7 +250,7 @@ mod tests { let stdout = process_stdout(&output); assert!(stdout.contains("Request accepted: resume")); assert!(stdout.contains("Server status:")); - assert!(stdout.contains("bl blox get archived-id")); + assert!(stdout.contains("bl compute get archived-id")); assert!(!stdout.contains("ready")); assert_lifecycle_requests(auth, service, "resume", "archived-id"); } diff --git a/src/bl/blox/skus.rs b/src/bl/blox/skus.rs index 560e509..79cd080 100644 --- a/src/bl/blox/skus.rs +++ b/src/bl/blox/skus.rs @@ -45,7 +45,7 @@ mod tests { let catalog = json!({"computeSkuRequired":true, "computeSkus":[{"code":"DEFAULT_AMD64", "displayName":"Default", "cpu":1, "memoryGb":8, "maxMultiple":8, "computeProvider":"worker-cells"}]}); let service = MockService::start(200, catalog.clone()); let mut args = vec![ - "blox", + "compute", "skus", "--base-url", "https://blox.test.blockstaging.build", diff --git a/src/bl/blox/suspend.rs b/src/bl/blox/suspend.rs index 251e0fe..ea91ecb 100644 --- a/src/bl/blox/suspend.rs +++ b/src/bl/blox/suspend.rs @@ -7,7 +7,7 @@ use clap::{Arg, ArgMatches, Command}; pub(super) fn command() -> Command { Command::new("suspend").about("Request workstation suspension") - .long_about("Request this transition for a workstation identified by canonical name or full ID. Reports the server response without waiting for the final state; inspect progress with `bl blox get `. Use the full ID for an archived workstation.") + .long_about("Request this transition for a workstation identified by canonical name or full ID. Reports the server response without waiting for the final state; inspect progress with `bl compute get `. Use the full ID for an archived workstation.") .arg(Arg::new("workstation").value_name("WORKSTATION").required(true).value_parser(clap::builder::NonEmptyStringValueParser::new())) } @@ -50,7 +50,7 @@ mod tests { &service, &[ "--json", - "blox", + "compute", operation, "123", "--base-url", @@ -77,7 +77,7 @@ mod tests { &service, &[ "--json", - "blox", + "compute", operation, "123", "--base-url", @@ -116,7 +116,7 @@ mod tests { &service, &[ "--json", - "blox", + "compute", operation, "123", "--base-url", @@ -128,7 +128,7 @@ mod tests { assert_eq!(failure["error"]["code"], "lifecycle_outcome_unknown"); let message = failure["error"]["message"].as_str().unwrap(); assert!(message.contains(operation)); - assert!(message.contains("bl blox get known-id")); + assert!(message.contains("bl compute get known-id")); assert!(message.contains("--base-url")); assert!(message.contains("before trying again")); assert_lifecycle_requests(auth, service, operation, "known-id"); @@ -144,7 +144,7 @@ mod tests { &auth, &[ "--json", - "blox", + "compute", operation, "123", "--base-url", @@ -171,7 +171,7 @@ mod tests { &service, &[ "--json", - "blox", + "compute", operation, "123", "--base-url", @@ -199,7 +199,7 @@ mod tests { &service, &[ "--json", - "blox", + "compute", operation, "123", "--base-url", diff --git a/src/lib.rs b/src/lib.rs index de60a67..d2472ca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -155,7 +155,7 @@ fn run_bl_with_argv(argv: Vec) -> Result<()> { Some(("auth", auth_matches)) => bl::skills::run_auth(auth_matches), Some(("workspace", workspace_matches)) => bl::workspace::run(workspace_matches), Some(("apps", apps_matches)) => bl::apps::run(apps_matches), - Some(("blox", blox_matches)) => bl::blox::run(blox_matches), + Some(("compute", compute_matches)) => bl::blox::run(compute_matches), Some(("config", config_matches)) => bl::skills::run_config(config_matches), Some(("completions", completions_matches)) => { let shell = completions_matches diff --git a/tests/blox_e2e.rs b/tests/blox_e2e.rs index a67cdc4..ef1337b 100644 --- a/tests/blox_e2e.rs +++ b/tests/blox_e2e.rs @@ -25,30 +25,48 @@ fn text(output: &Output) -> (String, String) { } #[test] -fn blox_help_is_available_without_authentication_or_network() { - for args in [ - vec!["blox", "--help"], - vec!["blox", "list", "--help"], - vec!["blox", "get", "--help"], - vec!["blox", "create", "--help"], - vec!["blox", "skus", "--help"], - vec!["blox", "exec", "--help"], - vec!["blox", "suspend", "--help"], - vec!["blox", "resume", "--help"], - vec!["blox", "delete", "--help"], +fn compute_help_is_available_without_authentication_or_network() { + for operation in [ + None, + Some("list"), + Some("get"), + Some("create"), + Some("skus"), + Some("exec"), + Some("suspend"), + Some("resume"), + Some("delete"), ] { - let (mut command, _home) = offline_command(); - let output = command.args(&args).output().expect("bl help"); - let (stdout, stderr) = text(&output); - assert!(output.status.success(), "{stderr}"); - assert!(stdout.contains("Usage: bl blox")); - assert!(!stdout.contains("--token")); - assert!(!stdout.contains("StartWorkstation")); + let mut canonical_help = None; + for namespace in ["compute", "blox"] { + let mut args = vec![namespace]; + args.extend(operation); + args.push("--help"); + let (mut command, _home) = offline_command(); + let output = command.args(&args).output().expect("bl help"); + let (stdout, stderr) = text(&output); + assert!(output.status.success(), "{stderr}"); + assert!(stdout.contains("Usage: bl compute")); + assert!(!stdout.contains("--token")); + assert!(!stdout.contains("StartWorkstation")); + if let Some(expected) = &canonical_help { + assert_eq!(&stdout, expected); + } else { + canonical_help = Some(stdout); + } + } } } #[test] -fn blox_is_registered_in_descriptions_and_shell_completions() { +fn compute_is_registered_in_descriptions_and_shell_completions() { + let (mut command, _home) = offline_command(); + let output = command.arg("--help").output().expect("bl help"); + assert!(output.status.success()); + let (stdout, _) = text(&output); + assert!(stdout.contains("compute")); + assert!(!stdout.contains("blox")); + let (mut command, _home) = offline_command(); let output = command .arg("--describe-commands") @@ -56,13 +74,18 @@ fn blox_is_registered_in_descriptions_and_shell_completions() { .expect("describe bl"); assert!(output.status.success()); let description: Value = serde_json::from_slice(&output.stdout).expect("command description"); - let blox = description["commands"] + assert!(!description["commands"] + .as_array() + .unwrap() + .iter() + .any(|command| command["name"] == "blox")); + let compute = description["commands"] .as_array() .unwrap() .iter() - .find(|command| command["name"] == "blox") - .expect("blox namespace"); - let mut names: Vec<_> = blox["commands"] + .find(|command| command["name"] == "compute") + .expect("compute namespace"); + let mut names: Vec<_> = compute["commands"] .as_array() .unwrap() .iter() @@ -74,36 +97,40 @@ fn blox_is_registered_in_descriptions_and_shell_completions() { ["create", "delete", "exec", "get", "list", "resume", "skus", "suspend"] ); - let (mut command, _home) = offline_command(); - let output = command - .args(["completions", "bash"]) - .output() - .expect("bl completions"); - assert!(output.status.success()); - assert!(text(&output).0.contains("blox")); + for shell in ["bash", "zsh", "fish", "powershell", "elvish"] { + let (mut command, _home) = offline_command(); + let output = command + .args(["completions", shell]) + .output() + .expect("bl completions"); + assert!(output.status.success()); + let (stdout, _) = text(&output); + assert!(stdout.contains("compute"), "{shell}"); + assert!(!stdout.contains("blox"), "{shell}"); + } } #[test] -fn invalid_blox_inputs_fail_before_session_loading() { +fn invalid_compute_inputs_fail_before_session_loading() { for args in [ - vec!["blox", "list", "--limit=-1"], - vec!["blox", "list", "--offset=-1"], - vec!["blox", "get"], - vec!["blox", "execute"], - vec!["blox", "exec"], - vec!["blox", "suspend"], - vec!["blox", "resume"], - vec!["blox", "delete"], - vec!["blox", "exec", "my-workstation"], - vec!["blox", "create", "--multiple", "0"], - vec!["blox", "create", "--multiple=-1"], - vec!["blox", "create", "--repo", "https://example.com/repo"], - vec!["blox", "create", "--cpu", "2"], - vec!["blox", "create", "--memory", "4"], - vec!["blox", "create", "--provider", "kube"], - vec!["blox", "create", "--architecture", "amd64"], + vec!["compute", "list", "--limit=-1"], + vec!["compute", "list", "--offset=-1"], + vec!["compute", "get"], + vec!["compute", "execute"], + vec!["compute", "exec"], + vec!["compute", "suspend"], + vec!["compute", "resume"], + vec!["compute", "delete"], + vec!["compute", "exec", "my-workstation"], + vec!["compute", "create", "--multiple", "0"], + vec!["compute", "create", "--multiple=-1"], + vec!["compute", "create", "--repo", "https://example.com/repo"], + vec!["compute", "create", "--cpu", "2"], + vec!["compute", "create", "--memory", "4"], + vec!["compute", "create", "--provider", "kube"], + vec!["compute", "create", "--architecture", "amd64"], vec![ - "blox", + "compute", "create", "my-workstation", "https://example.com/repo", @@ -118,17 +145,17 @@ fn invalid_blox_inputs_fail_before_session_loading() { for (args, expected_code) in [ ( - vec!["--json", "blox", "exec", "ws", "--", ""], + vec!["--json", "compute", "exec", "ws", "--", ""], "invalid_command", ), ( - vec!["--json", "blox", "list", "--limit", "0", "--offset", "1"], + vec!["--json", "compute", "list", "--limit", "0", "--offset", "1"], "invalid_pagination", ), ( vec![ "--json", - "blox", + "compute", "list", "--base-url", "https://unapproved.example", From c73e8db7f9066b6dad0c5ef8ce66f08a9a0ef9d1 Mon Sep 17 00:00:00 2001 From: Stuart Douglas Date: Fri, 16 Oct 2026 14:11:30 +1100 Subject: [PATCH 4/4] fix(blox): diagnose unsuccessful HTTP responses (#23) --- crates/blox/src/client.rs | 379 +++++++++++++++++++++++++++++++++++++- 1 file changed, 374 insertions(+), 5 deletions(-) diff --git a/crates/blox/src/client.rs b/crates/blox/src/client.rs index bc91365..ef7b70f 100644 --- a/crates/blox/src/client.rs +++ b/crates/blox/src/client.rs @@ -193,6 +193,7 @@ impl Client { .send() .context("call Blox StreamCommandOutput")?; let status = response.status(); + let response_headers = response.headers().clone(); if !status.is_success() { let mut body = Vec::new(); response @@ -203,7 +204,12 @@ impl Client { body.len() as u64 <= MAX_RESPONSE_BYTES, "Blox StreamCommandOutput error exceeds 2 MiB" ); - return Err(self.response_error(status, &body, "StreamCommandOutput")); + return Err(self.response_error( + status, + &body, + "StreamCommandOutput", + &response_headers, + )); } let content_type = response .headers() @@ -290,6 +296,7 @@ impl Client { .send() .with_context(|| format!("call Blox {method}"))?; let status = response.status(); + let response_headers = response.headers().clone(); let mut body = Vec::new(); response .take(MAX_RESPONSE_BYTES + 1) @@ -300,7 +307,7 @@ impl Client { "Blox {method} response exceeds 2 MiB" ); if !status.is_success() { - return Err(self.response_error(status, &body, method)); + return Err(self.response_error(status, &body, method, &response_headers)); } self.decode_response(&body, method) } @@ -310,10 +317,19 @@ impl Client { status: reqwest::StatusCode, body: &[u8], method: &str, + headers: &HeaderMap, ) -> anyhow::Error { + let context = self.response_error_context(headers); if let Ok(mut error) = serde_json::from_slice::(body) { - error.code = self.redact(&error.code); - error.message = self.redact(&error.message); + error.code = strip_bidi_controls(&strip_ansi(&self.redact(&error.code))) + .chars() + .filter(|character| !character.is_control()) + .collect(); + error.message = format!( + "Blox {method} failed with HTTP {status}: {}{}", + self.sanitize_detail(&error.message), + context + ); return error.into(); } let code = match status.as_u16() { @@ -327,11 +343,117 @@ impl Client { }; RpcError { code: code.to_owned(), - message: format!("Blox {method} failed with HTTP {status}"), + message: format!( + "Blox {method} failed with HTTP {status}{}{}", + context, + self.response_body_detail(body, headers) + .map(|detail| format!(" (detail={detail})")) + .unwrap_or_default() + ), } .into() } + fn response_error_context(&self, headers: &HeaderMap) -> String { + let mut details = Vec::new(); + for name in ["x-request-id", "x-correlation-id"] { + if let Some(value) = headers + .get(name) + .and_then(|value| value.to_str().ok()) + .filter(|value| { + !value.is_empty() + && value.len() <= 96 + && self.redact(value) == *value + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._:-".contains(&byte)) + }) + { + details.push(format!("{name}={value}")); + break; + } + } + if let Some(value) = headers + .get("content-type") + .and_then(|value| value.to_str().ok()) + .filter(|value| { + value.len() <= 128 + && self.redact(value) == *value + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"/;=+.- _".contains(&byte)) + }) + { + details.push(format!("content-type={value}")); + } + if details.is_empty() { + String::new() + } else { + format!(" ({})", details.join(", ")) + } + } + + fn response_body_detail(&self, body: &[u8], headers: &HeaderMap) -> Option { + if body.is_empty() { + return None; + } + let content_type = headers + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .split(';') + .next()? + .trim() + .to_ascii_lowercase(); + let text = if content_type == "application/json" + || content_type.ends_with("+json") + || content_type == "application/connect+json" + { + let value: serde_json::Value = serde_json::from_slice(body).ok()?; + value + .get("message") + .or_else(|| value.get("detail")) + .or_else(|| value.get("error"))? + .as_str()? + .to_owned() + } else { + return None; + }; + let detail = self.sanitize_detail(&text); + (!detail.is_empty()).then_some(detail) + } + + fn sanitize_detail(&self, value: &str) -> String { + let value = strip_bidi_controls(&strip_ansi(value)); + // Drop controls before classification so they cannot split a sensitive marker. + let value: String = value + .chars() + .filter(|character| !character.is_control()) + .collect(); + let value = self.redact(&value); + if contains_sensitive_marker(&value) || contains_url(&value) { + return "[response detail omitted: may contain sensitive data]".to_owned(); + } + let mut safe = String::with_capacity(value.len().min(256)); + let mut pending_space = false; + for character in value.chars() { + if character.is_control() || character.is_whitespace() { + pending_space = !safe.is_empty(); + continue; + } + if pending_space { + safe.push(' '); + pending_space = false; + } + if safe.len() + character.len_utf8() > 240 { + safe.push('…'); + break; + } + safe.push(character); + } + safe + } + fn decode_response(&self, body: &[u8], method: &str) -> Result { let value: serde_json::Value = serde_json::from_slice(body).map_err(|error| { anyhow::anyhow!( @@ -381,6 +503,86 @@ impl Client { } } +fn contains_sensitive_marker(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + [ + "authorization", + "cookie", + "token", + "password", + "secret", + "api_key", + "api-key", + "apikey", + "accesstoken", + "refreshtoken", + "client_secret", + "clientsecret", + "credential", + "session", + "passwd", + "bearer ", + "set-cookie", + "bbidentity ", + ] + .iter() + .any(|marker| lower.contains(marker)) +} + +fn contains_url(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + lower.contains("://") || lower.contains('?') +} + +fn strip_bidi_controls(value: &str) -> String { + value + .chars() + .filter(|character| { + !matches!( + *character as u32, + 0x00ad + | 0x061c + | 0x200b..=0x200f + | 0x202a..=0x202e + | 0x2060..=0x206f + | 0xfeff + ) + }) + .collect() +} + +fn strip_ansi(value: &str) -> String { + let mut safe = String::with_capacity(value.len()); + let mut characters = value.chars().peekable(); + while let Some(character) = characters.next() { + if character != '\u{1b}' { + safe.push(character); + continue; + } + match characters.next() { + Some('[') => { + for character in characters.by_ref() { + if ('@'..='~').contains(&character) { + break; + } + } + } + Some(']') => { + let mut previous_escape = false; + for character in characters.by_ref() { + if character == '\u{7}' || (previous_escape && character == '\\') { + break; + } + previous_escape = character == '\u{1b}'; + } + } + Some(_) => {} + None => {} + } + } + safe +} + #[cfg(test)] mod tests { use super::*; @@ -724,6 +926,10 @@ mod tests { let mut response = Response::from_string(body) .with_status_code(status) .with_header(Header::from_bytes("Content-Type", content_type).unwrap()); + if status == 401 { + response = response + .with_header(Header::from_bytes("X-Request-Id", "stream-req-401").unwrap()); + } if let Some(compression) = compression { response = response.with_header( Header::from_bytes("Connect-Content-Encoding", compression).unwrap(), @@ -741,6 +947,8 @@ mod tests { error.downcast_ref::().unwrap().code, "unauthenticated" ); + assert!(error.to_string().contains("x-request-id=stream-req-401")); + assert!(error.to_string().contains("Blox StreamCommandOutput")); } assert!(!format!("{error:?}").contains("secret-session")); worker.join().unwrap(); @@ -888,6 +1096,167 @@ mod tests { assert!(!format!("{error:?}").contains("secret-session")); } + #[test] + fn non_connect_503_includes_operation_status_request_id_and_safe_json_detail() { + let client = Client::new("https://blox.example").unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("x-request-id", HeaderValue::from_static("req-123.ab")); + headers.insert( + "content-type", + HeaderValue::from_static("application/json; charset=utf-8"), + ); + let error = client.response_error( + reqwest::StatusCode::SERVICE_UNAVAILABLE, + br#"{"message":"upstream unavailable"}"#, + "GetWorkstation", + &headers, + ); + let message = error.to_string(); + assert!(message.contains("Blox GetWorkstation failed with HTTP 503")); + assert!(message.contains("x-request-id=req-123.ab")); + assert!(message.contains("content-type=application/json; charset=utf-8")); + assert!(message.contains("detail=upstream unavailable")); + assert_eq!( + error.downcast_ref::().unwrap().code, + "unavailable" + ); + } + + #[test] + fn response_error_details_omit_sensitive_or_unsafe_values() { + let client = Client::new("https://blox.example") + .unwrap() + .with_session_credential("secret-session") + .unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("x-request-id", HeaderValue::from_static("secret-session")); + headers.insert( + "x-correlation-id", + HeaderValue::from_static("req/with?query"), + ); + headers.insert("content-type", HeaderValue::from_static("text/html")); + let error = client.response_error( + reqwest::StatusCode::SERVICE_UNAVAILABLE, + br#"{"message":"upstream token=do-not-print"}"#, + "GetWorkstation", + &headers, + ); + let message = error.to_string(); + assert!(message.contains("HTTP 503")); + assert!(!message.contains("secret-session")); + assert!(!message.contains("req/with?query")); + assert!(message.contains("content-type=text/html")); + assert!(!message.contains("do-not-print")); + assert!(!message.contains("detail=")); + + let body = serde_json::json!({ + "message": "reflected secret-\u{001b}[31msession" + }) + .to_string(); + let detail_headers = HeaderMap::from_iter([( + "content-type".parse().unwrap(), + HeaderValue::from_static("application/json"), + )]); + let error = client.response_error( + reqwest::StatusCode::SERVICE_UNAVAILABLE, + body.as_bytes(), + "GetWorkstation", + &detail_headers, + ); + assert!(error.to_string().contains("[REDACTED]")); + assert!(!error.to_string().contains("secret-session")); + } + + #[test] + fn unary_503_diagnostics_include_http_response_headers() { + let server = Server::http("127.0.0.1:0").unwrap(); + let client = Client::new(&format!("http://{}", server.server_addr())).unwrap(); + let worker = thread::spawn(move || { + server + .recv() + .unwrap() + .respond( + Response::from_string(r#"{"message":"upstream unavailable"}"#) + .with_status_code(503) + .with_header( + Header::from_bytes("Content-Type", "application/json").unwrap(), + ) + .with_header(Header::from_bytes("X-Request-Id", "unary-req-503").unwrap()), + ) + .unwrap(); + }); + let error = client.get_workstation("ws-1").unwrap_err(); + assert!(error + .to_string() + .contains("Blox GetWorkstation failed with HTTP 503")); + assert!(error.to_string().contains("x-request-id=unary-req-503")); + assert!(error.to_string().contains("detail=upstream unavailable")); + worker.join().unwrap(); + } + + #[test] + fn response_error_details_omit_urls_and_strip_ansi_and_controls() { + let client = Client::new("https://blox.example").unwrap(); + let headers = HeaderMap::from_iter([( + "content-type".parse().unwrap(), + HeaderValue::from_static("application/problem+json"), + )]); + let body = serde_json::json!({ + "detail": "reset\u{001b}[31m\nvia https://mesh.example/path?token=hidden" + }) + .to_string(); + let error = client.response_error( + reqwest::StatusCode::BAD_GATEWAY, + body.as_bytes(), + "GetWorkstation", + &headers, + ); + let message = error.to_string(); + assert!(message.contains("response detail omitted")); + assert!(!message.contains("\u{001b}")); + assert!(!message.contains("token=hidden")); + + let body = serde_json::json!({ + "detail": "reset\u{001b}[31m\nvia\u{202e} now" + }) + .to_string(); + let error = client.response_error( + reqwest::StatusCode::BAD_GATEWAY, + body.as_bytes(), + "GetWorkstation", + &headers, + ); + let message = error.to_string(); + assert!(message.contains("detail=resetvia now")); + assert!(!message.contains("\u{001b}")); + assert!(!message.contains("\u{202e}")); + + let long_detail = format!("{{\"message\":\"{}\"}}", "x".repeat(400)); + let error = client.response_error( + reqwest::StatusCode::SERVICE_UNAVAILABLE, + long_detail.as_bytes(), + "GetWorkstation", + &headers, + ); + assert!(error.to_string().len() < 400); + assert!(error.to_string().contains('…')); + } + + #[test] + fn response_error_without_safe_headers_or_body_still_names_operation_and_status() { + let client = Client::new("https://blox.example").unwrap(); + let error = client.response_error( + reqwest::StatusCode::SERVICE_UNAVAILABLE, + b"proxy error", + "GetWorkstation", + &HeaderMap::new(), + ); + assert_eq!( + error.to_string(), + "unavailable: Blox GetWorkstation failed with HTTP 503 Service Unavailable" + ); + } + #[test] fn non_json_auth_failures_keep_stable_codes() { for (status, code) in [(401, "unauthenticated"), (403, "permission_denied")] {