Skip to content

Latest commit

 

History

History
295 lines (266 loc) · 73 KB

File metadata and controls

295 lines (266 loc) · 73 KB

Render CLI compatibility checklist

The full command surface of the official Render CLI (render-oss/cli v2.27.0), enumerated from render --help and graded against a live bex by driving the unmodified CLI through its REST API. Every command, subcommand, and flag is a dedicated checklist item.

Legend: [x] verified working · [~] works with a documented limitation · [ ] not working / not verifiable here · [-] deliberate bex non-goal.

Native bex launcher (w9/m59)

lego/cli/ imports (rather than forks) Render CLI v2.27.0, pinned to a764810a768202704e7206eb7b87a47211fcd98e, and starts its exported command runner after mapping Bex defaults. With no RENDER_* input, it targets https://api.bex.co/v1/ and lets the upstream config library persist its normal YAML schema as ~/.bex/cli.yaml; BEX_HOST, BEX_CLI_CONFIG_{DIR,PATH}, BEX_WORKSPACE, BEX_OUTPUT, and a temporary BEX_ACCESS_TOKEN are supported Bex inputs. The launcher maps a Bex-owned telemetry opt-out (BEX_CLI_DISABLE_ANALYTICS) onto the upstream opt-out and otherwise lets the imported sender report to bex-api (POST /v1/cli-telemetry-events, w5/m92) — usage analytics stay opt-out upstream, but the endpoint is Bex's, never Render's. An explicit RENDER_CLI_DISABLE_ANALYTICS or DO_NOT_TRACK is left untouched. Hermetic launcher tests cover that mapping, the consent default, process-level authenticated REST behavior, and Render-config isolation — including RENDER_CLI_CONFIG_DIR alone never writing $HOME/.bex/cli.yaml (w8/017), and persistent state (installation_id, notice marker) living under $HOME/.bex/state / BEX_CLI_CONFIG_DIR/state rather than ~/.render/state (w8/018). scripts/bex-cli-auth-e2e.sh is the live device-login/refresh/ logout evidence runner; it must be run against a Bex auth environment before a release tag. The launcher overlays Bex help chrome (Use, Short/Long/examples, bex docs, version line) via lego/cli/internal/branding without forking; residual runtime strings (login TUI, ErrLogin, User-Agent prefix, skills state path) remain upstream.

This launcher record does not replace the unmodified server oracle below: that client is the broad command/API compatibility baseline. The command surface is graded at the launcher pin (v2.27.0 after the w6/m142 round-4 re-baseline; prior: v2.26.0 w8/m33, v2.24.0 w2/m79, v2.21.0 live-REST production runs / launcher v2.22.0). Known server-side non-goals are still recorded as [-] in this checklist, including workflows, ephemeral SSH, and ea objects; importing a newer client does not claim those operations as implemented.

How this pass was verified

  • Image input validation (w8/m49, 2026-10-02): the rebuilt dev-8 API passed 57 final checks across REST/current Bex/unmodified Render 2.27.0/GraphQL/MCP, service creation, Blueprints, and a configured-digest workload. Both CLIs' same-digest --image … --wait controls reached live, with literal workload refs and Service HTTP 200 verified. Refusals preserve deploy history and saved images. Eight cleanup checks passed. The shared older operator was unchanged: different-image override consumption and the new operator diagnosis are not claimed as live coverage; the latter passed current operator envtest. Two earlier unsuccessful harness/CLI attempts are retained. Evidence and limits.

  • Environment selector round-trip (w2/m165, 2026-10-02): a rebuilt dev-2 API passed 113 CLI commands across current Bex + unmodified Render v2.27.0 (a764810a7682) and the actually installed Bex vdev-140f1ef06 + its unmodified Render v2.26.0 (6c0f561f8af9). Discovery returns canonical evm-*; KV/PG create/get/list accept the discovered ID, name+project controls, and unscoped controls. kv/pg aliases and raw services --environment-ids filters passed. REST/GraphQL/MCP agree; legacy env-* API inputs, SQL membership, and CR labels retain identity. Ten expected refusals cover scope/name controls and the unchanged CLI's old-literal limitation. Twenty datastores, five environments, five projects, and both disposable accounts were removed and absence-verified. This is local selector/metadata acceptance, not a production or datastore-connectivity claim. All 14 datastore command paths share the audited pinned resolver; the remaining mutations and canonical-ID+wrong-project CLI path are source/regression-covered, not separately live-tested. Evidence, identity contract. Old env-* literals still look like names to the unchanged CLI: rediscover the evm-* ID.

  • Human device-flow op-class pass (2026-08-20): after fixing the login → "you are not allowed to take this action" regression (the cliauth device-grant requested no granular capability, so every authenticated call 403'd at the dispatch scope matrix — see the login row), the unmodified bex binary was driven against a locally-run real bex-api (control-plane store on a throwaway Postgres, real app.bex.co CRDs, a fake Hydra introspecting the platform-marked human device-flow token that now carries bex.read bex.write bex.sensitive). ~90 invocations were exercised with zero authorization refusals: 60 client-side (--version, help, completion ×4, docs, and the full --help tree for every command/subcommand) plus ~30 authenticated across all four op-classes — whoami/workspaces/workspace current; services/postgres/keyvalues/projects list + get; services create ×4 (web/cron/static/--env-var), postgres/keyvalues create; services/postgres update; restart, postgres suspend/resume; deploys list/create, jobs list, services instances; services/postgres delete; environments (correct 404); logs. Residual non-passes were all non-auth environment artifacts (the same namespaces "tea-…" not found control-plane provisioning gap of .pm/w2/026.md, KeyValue datastore-namespace resolution in the hand-seeded tenant, interactive confirm prompts). Note: cli-compat.sh verify authenticates with client_credentials tokens, which are capability-exempt, so it structurally cannot exercise this human-token op-class path — only the device-flow login (scripts/bex-cli-auth-e2e.sh, which runs workspaces + services) does. Guards: TestDeviceGrantScopeCoversEveryOpClass, TestRenderCLITokenClearsEveryOpClassEndToEnd, and the env-gated live TestRenderCLIBinaryAgainstLiveServer.

  • CLI: render-oss/cli v2.24.0 (fe8a6188119ee1a53dcf3e5c19f6a5302e840c3f), built unmodified from the Go-module pin, driven through scripts/cli-compat.sh against dev-2 on 2026-08-19 (w2/m79). Auth/list/env legs passed (login, whoami, workspace current, workspaces, projects, missing-resource not-found shapes, environments). Create legs (services/postgres/keyvalues) failed with harness namespaces "tea-…" not found — control-plane tenant-namespace provisioning on this CAPD stack, not a CLI wire regression (filed .pm/w2/026.md). Prior production/dev supplements below remain historical evidence under older pins unless restated.

  • Upstream surface delta v2.24.0 → v2.26.0 (w8/m33 round 3, 2026-09-07, help-graded + backend-wire-shape-verified): launcher pin moved v2.24.0 → v2.26.0 (6c0f561f8af9d4a6cfb88f4d1845ffd18cee181a, module v1.1.3-0.20260901190744-6c0f561f8af9); bash scripts/bex-cli-validate.sh + cd lego/cli && go build/vet/test ./... green. Graded the v2.24.0→v2.26.0 command/flag diff (upstream git log v2.24.0..v2.26.0 + regenerated REST client):

    • New postgres create|update --connection-pool=none|pgbouncer (v2.26.0; CLI validates the enum client-side, sends connectionPool in the create/PATCH body, and postgres get output now shows the mode). bex already accepts this on both create and update — internal/postgres folds Render's connectionPool enum onto its native pooler bool via resolvePooler and returns the enum on read (the w2/024 alignment, now exercised by the client). New rows added below, graded [x] on wire shape.
    • New spec-based compute plan names (v2.25.0: 0.1c-256mb … 128c-1024g for Postgres, 1g for Key Value; legacy names stay valid input). The CLI's --plan help/picker list is documentation-only, not validation, so it passes any value through. bex validates --plan against its own tier catalog (tiers.Postgres.IDs()), so a suggested spec-based name is a named 400 while bex's own plan names still work — partially closed by w8/011 input aliases for overlapping rungs; larger Render names remain an Intended divergence.
    • blueprints validate now exits status 1 on an invalid blueprint for every --output mode (v2.25.0) — a client-side exit-code change; bex's valid:false transport is unchanged (blueprints row updated).
    • Analytics is now opt-out (v2.26.0) — the launcher disables it by default (see the launcher record above); recorded under Intended divergences.
    • Go 1.27 (upstream 85c8c2c): the module and lego/go.work moved to go 1.27.0; CI/toolchain handled (see UPSTREAM_RENDER_CLI.md, lego/AGENTS.md). RENDER_CLI_CONFIG_DIR now recommended in help (launcher already maps BEX_CLI_CONFIG_DIR). No new top-level commands (only a hidden analytics notice subcommand) and no Bex-native command-name collisions (code/glm/muse/kimi/deepseek/upgrade); RootCmd still exported, CustomHelpTemplate intact (internal/branding tests green), and isRootVersionRequest body unchanged vs lego/cli/internal/update. No fresh live dev-N REST run this round (same environment limit as w2/m79's dev-2 create failure, .pm/w2/026.md); grades are help-graded plus backend wire-shape reads.
  • Upstream surface delta v2.26.0 → v2.27.0 (w6/m142 round 4, 2026-09-10, help-graded + client-diff): launcher pin moved v2.26.0 → v2.27.0 (a764810a768202704e7206eb7b87a47211fcd98e, module v1.1.3-0.20260909214233-a764810a7682); bash scripts/bex-cli-validate.sh + cd lego/cli && go build/vet/test ./... green. Graded the v2.26.0→v2.27.0 command/client diff:

    • New ea sandboxes snapshots {create,get,list} and ea sandboxes create --snapshot-id (v2.27.0) — regenerated REST client gains CreateSandboxSnapshot* / ListSandboxSnapshots / RetrieveSandboxSnapshot / DeleteSandboxSnapshot. Graded [-]: early-access sandbox surface outside the bex ea compat target (same class as round-3 sandbox-exec ops); importing the client does not claim these as implemented.
    • blueprints validate now reports workflows in a Blueprint (v2.27.0) — client display change; workflows remain a deliberate non-goal (DO_NOT_DO.md); bex's validate transport continues to refuse workflow resources.
    • golang.org/x/crypto security bump (upstream) — transitive via the pin; no launcher behavior change.
    • Seams re-verified: RootCmd exported, CustomHelpTemplate intact, isRootVersionRequest body unchanged, no new top-level commands, no Bex-native name collisions. Service-disk pin-conditional re-check: still no disks command / service disk flag. w8/016 (2026-09-15): also re-diff ea subcommand names, not only presence — the group is sandboxes (plural) with no sandbox alias at this pin; four older checklist rows still said ea sandbox <verb> until this date. No fresh live cli-compat.sh verify this round (environment carve-out as w8/m33 / w2/m79).
  • Upstream surface delta v2.22.0 → v2.24.0 (help-graded): new root completion (bash/zsh/fish/powershell) — [x] local help/smoke; RENDER_CLI_CONFIG_DIR preferred over RENDER_CLI_CONFIG_PATH — launcher already maps BEX_CLI_CONFIG_DIR; Windows archive ships render.exe (release note only); kv create|update --persistence-mode and existing pg disk flags unchanged in contract. No Bex-native command-name collisions (code/glm/muse/kimi/deepseek). isRootVersionRequest body unchanged vs lego/cli/internal/update.

  • CLI: (historical) render-oss/cli v2.21.0, built unmodified from a separate pinned upstream checkout (or a checksum-verified release binary), driven only through scripts/cli-compat.sh (Hydra client_credentials token exchange per call, RENDER_HOST → local bex-api). That dev-9 baseline never touched production; the named supplements below are separately scoped. The repository's lego/cli/ path is the distinct import-based bex launcher, not an upstream checkout.

  • Target: the isolated local dev-9 environment (.pm/w9/dev-9, bex-api at :54090), current lego/backend HEAD, on 2026-07-18.

  • Method: the maintained regression suite scripts/cli-compat.sh verify (whole-shape checkFields assertions over the core families), the cleanup-safe services-parity-verify baseline/configured legs, and a full sweep of the remaining command tree. Each item was graded on the unmodified CLI's exit status and the wire shape read back via -o json and raw REST GET. The exact service flag contract and redacted POST/PATCH captures are in cli-services-create-update.md.

  • Operator-correctness schema supplement (w2/m60): the installed, unmodified v2.21.0 CLI's services update --help and postgres update --help were re-read on 2026-07-19. Service update has no --type; Postgres update has both --plan and --disk-size-gb. Focused backend regression sends the CLI's exact PATCH {"diskSizeGB":…} wire shape: growth persists, a lower value returns the named 400 envelope, and the resource remains at its accepted size. Operator/envtest separately proves a plan downgrade preserves that size. This is a command-schema plus API regression supplement, not a new live dev-9/production CLI run; the previously graded create/update/delete baseline remains the live-client evidence.

  • Service-disk supplement (w1/m86, 2026-08-23): ADR082 shipped persistent service disks on REST/GraphQL/MCP/dashboard, and the unmodified v2.24.0 CLI's --help tree was re-read for a matching surface: there is none. Render's own CLI has no disks command and no service-level disk flag — --disk-size-gb exists only on postgres create|update (the datastore's own storage, already graded above) and services create|update has no disk flag at all. So this feature adds no row to this checklist: there is nothing in the client to grade, and the absence is Render's, not a bex gap. The parity boundary for disks is the REST shape the CLI would call if it ever grows one — /v1/disks in Render's documented request/response shapes, covered by the backend suite. Re-check this bullet when the CLI pin moves: a future render disks command would make this a real row. w8/m33 re-check (v2.26.0): still no disks command and no service-level disk flag — the v2.24.0→v2.26.0 command tree adds no disk surface, so this stays a non-row. w6/m142 re-check (v2.27.0): still none — v2.26.0→v2.27.0 adds only ea sandboxes snapshots*, not disks.

  • Durable-delete and Key Value credential supplement (w2/m61): the unmodified v2.21.0 CLI delete commands remain plain service/Postgres/Key Value id-or-name operations with no bex-only flag or cleanup handshake. Focused REST/GraphQL/MCP regressions preserve their existing acknowledgement shapes while subsequent reads show deleting until the finalizer has proved required cleanup absent, then NotFound. Key Value connection-info returns conflict rather than a split credential during rollout. This is an operator/backend regression supplement, not a new live production CLI run; the previously graded unmodified-CLI delete rows remain the live-client evidence.

  • Service deleting-read supplement (w3/m81): the services delete acknowledgement shape is unchanged, but a REST by-id GET /v1/services/{id} and the CLI's services -o json list observed during finalization now agrees on absence — a deleted service's by-id read returns not found (Render's GET 404) the instant deletion is accepted, rather than the w2/m61 interim deleting, so the CLI never sees a service it can no longer act on. This matches Render's own delete convergence (the w5/m49 production run reached GET 404 + list absence within five minutes) and the service-delete row below; it is a backend read-contract correction, not a CLI wire change. Postgres/Key Value keep the interim deleting read (no serving URL to go stale) pending the adjacent parity follow-up.

  • OpenAPI request-boundary supplement (w7/m49): the existing unmodified-v2.21.0 request fixtures across services, deploys, Postgres, Key Value, environment groups, projects, custom domains, jobs, registry credentials, and logs run through the authenticated 119-operation Render-contract intersection. Focused boundary tests also replay the accepted CLI-shaped bodies, compatibility defaults, and bex dryRun/confirm extensions while proving invalid input has no side effect. This is repository regression evidence, not a new live CLI run; the live grades below are unchanged. Bex deliberately rejects undeclared query/body input more strictly than Render's open schemas, as documented in the OpenAPI boundary contract.

  • Canonical-route retirement supplement (w1/m55): the official CLI's existing request fixtures already use Render's canonical /v1/services, /v1/postgres, /v1/key-value, and /v1/registrycredentials routes, so their focused backend regressions remain green after bex removed its non-Render public aliases. Dedicated negative tests now require those retired aliases to return 404; the internal control-plane listener's unrelated /v1/apps route remains available only on port 8091. This is repository regression evidence, not a new live CLI run; the live grades below are unchanged.

  • Nested-image/delete-convergence correction (w5/m49, production accepted 2026-07-21 UTC): production proved that v2.21.0 serializes image.ownerId:"" on image create and update. The strict REST adapter now accepts and authorizes that Render-declared field after OpenAPI validation. First-build deletion now interrupts synchronous build waits, inventories with the required build-namespace RBAC, repairs least-privilege registry authority, proves repository absence before revocation, and quiesces Ingress/cert-manager TLS producers before Secret deletion. The deployed unmodified CLI passed image create/update/delete and immediate repo-build delete to raw GET 404 plus official-CLI list absence inside the five-minute bound. The original stuck fixture and the final acceptance prefix both had zero Kubernetes/build/TLS/credential residue; Zot htpasswd/ACL/config and a builder-authenticated catalog also had zero matches. Sanitized evidence is in the m49 diagnosis.

  • Configured service pass: a disposable local OpenBao plus auth-enabled persistent Zot augmented dev-9 for the second service run. It proved CLI env vars, secret files, create/update registry-credential binding, a genuinely private kubelet pull, and native cron commands.

  • Postgres create supplement (w6/m38): scripts/postgres-create-cli-smoke.sh drove the same unmodified CLI against isolated dev-6 and a real local CNPG cluster on 2026-07-18. It proved both custom names, database-only and user-only independent defaults, the CR intent, generated Secret metadata, and current_database()/current_user inside PostgreSQL. It also proved that unsupported Datadog create/update flags fail with a named 400 and leave no resource behind.

  • Postgres psql supplement (w7/m46): scripts/psql-compat-verify.sh drove Render's checksum-verified, unmodified v2.21.0 release against isolated dev-7, real CNPG, a configured BEX_DB_DOMAIN, and pg-sni-proxy on 2026-07-18. Both opaque id and exact name executed SELECT 1 AS bex_psql_probe; through real psql 18.4 over the external TLS/SNI route; the caller's public /32 passed the CLI's own allow-list gate, the absent-IP negative case was refused before connect, and the disposable database and local credential state were removed. Redacted markers and release hashes are in the psql CLI artifact.

  • Production SSH supplement (w6/020): scripts/ssh-verify.sh drove the checksum-verified, unmodified v2.21.0 release through https://api.bex.co and the public ssh.bex.co:22 gateway in a real PTY on 2026-07-18. Service name, service id with the interactive Any instance selection, and complete instance id all reached the asserted running container and runtime environment; the verifier also re-proved the pinned host key, raw any/exact OpenSSH, PTY resize, exit status, restart/redeploy closure, suspended/free/stale/shell-less/unknown/deleted denial, and deleted its disposable key, service, and Scale workspace. The broader viewer/foreign/static/cron denial matrix remains captured in the prior production acceptance.

  • Production Key Value CLI supplement (w2/m57): scripts/cli-compat.sh kv-cli-verify drove the unmodified v2.21.0 release through https://api.bex.co and the public *.kv.bex.co:6379 TLS/SNI edge on 2026-07-18. Under an automated PTY, opaque-id PING/SET and display-name GET/DEL all passed through the CLI's own list/item/connection-info path with an exact source /32; the verifier removed its key and Key Value, then revoked its API key and deleted its workspace. Sanitized results are in the production acceptance evidence.

  • Production durable-logs supplement: scripts/cli-logs-verify.sh drove the unmodified v2.21.0 CLI through https://api.bex.co against the deployed Loki + log-shipper on 2026-07-18, closing the store-only logs filter rows the Loki-less dev-9 baseline could only grade 503. A disposable busybox web-service fixture planted one JSON error line, one JSON info line, and one plaintext line at boot, then served a 200 on / and a 404 on a nonce path through the public edge; render logs -o json then proved every filter with a positive match AND a negative exclusion — the app/request split clean both ways, --level error isolating exactly the planted line (critical an honest empty), --path/--host matching only the probe values, --method GET positive with POST empty, and --status-code exact 404/200 exclusion both ways plus the 4xx class shorthand. The fixture is deleted on exit; the raw REST/MCP halves of the same durable-store paths remain covered by scripts/logs-verify.sh.

  • Base dev-9 does not run (so paths needing them are marked accordingly, distinguishing a real bex gap from an environment limit): build infra (kpack/zot), OpenBao (env-vars/secret-file store), the registry-credential store, Loki (durable log store — but see the production durable-logs supplement above), Prometheus, cert-manager, OpenFGA (authz is allow-all here), BEX_DB_DOMAIN/BEX_KV_DOMAIN (external datastore connect), and the SSH gateway.

Real gaps found this pass (bex-side, worth filing)

  • (w2/m79) Create-path namespaces "tea-…" not found on CAPD dev-2 during cli-compat.sh verify with v2.24.0 — auth/list/env passed; service/Postgres/Key Value creates 500'd before any wire-shape assert. Filed .pm/w2/026.md (harness/control-plane, not a CLI pin bug).

  • keyvalues update --memory-policy / --ip-allow-list / --clear-ip-allow-list are silent no-ops — fixed (w7/m45): KeyValuePatch now carries MaxmemoryPolicy + IPAllowList and handleUpdateKeyValue decodes them, so the CLI's update flags mutate the store; GraphQL setKeyValueMaxmemoryPolicy + MCP update_key_value (w1/m71's fold of set_key_value_maxmemory_policy/set_key_value_ip_allow_list) bring the programmatic surfaces to parity. Dashboard memory-policy editing after create has also since shipped (cbf49951 / d2b1de3e: KeyValueMaxmemoryPolicySection edit-in-place on the Key Value detail page, w7/007).

  • services update --health-check-path (and every other serviceDetails field) is unusable on a Dockerfile-runtime web service — fixed (w4/052): bex left spec.runtime empty for a Dockerfile build expressed through the default/auto/dockerfile builder — the shape a dashboard, Blueprint, or hand-applied App produces — so GET /v1/services returned no serviceDetails.runtime/env. The upstream CLI reads that field to round-trip a partial services update, so it rejected the empty value client-side (unsupported runtime "") or treated an explicit --runtime docker as a forbidden switch (cannot switch runtimes via the CLI), stranding such a service on healthCheckPath: / with no CLI path to repoint it (found fixing a live production deploy whose homepage-SSR health check timed out as its corpus grew, while the app already shipped a bodyless /healthz). The read surfaces now derive the effective runtime (internal/apps.effectiveRuntime), consistently across REST/GraphQL/MCP and the runtime-keyed envSpecificDetails: a repo build under the default/auto/dockerfile builder reads back docker, and a prebuilt image (image set, no repo) reads back image (no envSpecificDetails — the container is supplied whole via imagePath), so both round-trip a partial services update. A buildpack build or static site has no bex runtime and stays empty as before. The upstream --runtime-guard divergence below is unchanged (the guard is correct once a runtime reads back). The w9/m93 milestone hardened this into a class guard so no future read omission can re-strand the CLI: TestServiceReadRoundTripCompleteness (create-matrix readback: runtime present, non-empty, and shape-consistent for every build strategy), TestEffectiveRuntimeIsTotal (the spec→runtime projection is total for runnable types), TestDockerfileServiceRuntimeIsConsistentAcrossSurfaces, plus build-strategy-diverse fixtures in scripts/cli-services-parity-verify.sh (a runtime-less image service and an out-of-band Dockerfile-via-builder service each exercise a services update --health-check-path leg — the monoculture --runtime go fixture that hid this can no longer hide its successor). Live on dev-9, 2026-09-08: unmodified render-oss/cli at pin fe8a6188119e (v2.24.0) against local bex-api HEAD 9024a9672 — image-healthcheck and web-builder-roundtrip both PASS (GET derives runtime/env image/docker; CLI partial update round-trips; fixtures deleted to GET 404). CLI pin left at v2.24.0 (w8/m33 owns the v2.26.0 refresh). Verifier hygiene fixed in the same pass: out-of-band create puts ownerId in the body (not a rejected query param), and allowlist asserts read serviceDetails.ipAllowList (w6/m106 nesting).

  • (upstream CLI, not bex) The v2.21.0 SSH instance picker discards an exact-instance selection: both callbacks pass the service id instead of their instanceID argument. Any instance therefore works through the picker, and the supported complete-instance-id command argument works for exact targeting; the verifier does not patch the CLI or falsely credit the broken menu path.

  • (upstream CLI, not bex) skills list / skills update panic (nil-pointer) in every non-TTY output mode.

  • (upstream CLI, not bex) logs panics (exit 2) with no usable credential in every non-interactive output mode (-o json|yaml|text): cmd/logs.go calls deps.LogLoader() inside RunE before auth is established, and (*Dependencies).APIConfig (pkg/dependencies/dependencies.go:281) does panic(err) on DefaultAPIConfig's ErrLogin — so an absent, expired-beyond-refresh, or corrupt cli.yaml turns bex logs into a raw Go stack trace (panic: run \render login` to authenticate) where every sibling command (services, deploys list, restart, whoami, workspaces, projects) returns a clean Error:and exits 1. No bex request is ever sent; the crash is entirely client-side. Re-check when the CLI pin moves: upstream may fix the panic. (Found live 2026-09-15,/qa-find-bugs-cliround,bexv0.2.1 / pin v2.27.0a764810a7682; filed .pm/w8/015.md`.)

  • (upstream CLI + Render's wire contract, not bex) --secret-file silently corrupts binary files (w8/029, sweep 41; pin v2.27.0): pkg/service/secretfiles.go sends Content: string(data), and the API's content is a JSON string on every surface, so invalid UTF-8 bytes become U+FFFD before the request leaves the machine (a 1,024-byte random file mounted with a different sha256; a 404 KB text file round-tripped exactly). bex stores what it receives. Documented in docs/bex-cli.md with a base64 recipe; the dashboard upload refuses non-UTF-8 files. Pin-conditional: re-check on every pin move.

Intended divergences (not bugs)

  • Log text search (w2/m167): the Render CLI accepts comma-separated --text values and the logs API accepts an array. The exact v2.27.0 pin sends repeated text keys; Bex preserves all of them and matches their OR, ANDed with the other supported filters. Bex treats each term as a case-insensitive literal substring, including wildcard/regex characters, while Render documents wildcard/regex text search. Exact empty terms are ignored, duplicates after lowercasing have no additional effect, and spaces remain literal; no effective terms means no text filter. GraphQL/dashboard and datastore convenience searches remain scalar. See the full contract and source limits.

  • Blueprint size (w8/m48): bex limits decoded YAML to 512 KiB before parsing. Oversized blueprints validate uploads receive a named 413 for that limit. Render documents a 10 MB total multipart request bound; bex keeps the smaller pre-decode amplification guard. Request-envelope allowance is separate from the YAML budget.

  • --region is accepted but platform-stamped from BEX_REGION (local-capd in dev-9, fsn1 in production); the submitted hint is not persisted. Both truthful installation values sit outside the CLI's closed Render-region enum, so a bare services create --from clone fails client-side and needs an explicit --region.

  • --previews is rejected platform-wide (400 "not supported by this platform").

  • Postgres Datadog forwarding is not implemented. Supplying --datadog-api-key or --datadog-site on create or update returns a named 400; bex never accepts or persists the credential.

  • services update --runtime is rejected inside the unmodified CLI by design (cannot switch runtimes via the CLI) before any bex request.

  • Usage analytics go to bex-api, never Render (w5/m92). Upstream analytics stay opt-out-shaped, but the launcher no longer force-disables them: events (command path, duration, exit code, install id, workspace — no args or secrets) POST to bex-api's POST /v1/cli-telemetry-events, attributed server-side and retained under the audit-retention window. BEX_CLI_DISABLE_ANALYTICS=1 (or DO_NOT_TRACK) opts out; an explicit RENDER_CLI_DISABLE_ANALYTICS is left untouched. The upstream one-time notice block still prints once per machine with Render copy — documented in the Bex CLI guide, not reworded. Release attribution (w5/m94): the launcher stamps X-Bex-CLI-Version on requests to the configured bex host only, and bex-api stores it beside the upstream pin. The request body stays byte-identical to upstream's CliTelemetryEventPOSTInput — verified field-for-field against the pinned client (18 fields, no additions, no omissions), so an unmodified render binary pointed at bex still ingests and simply records the release as unknown. The header is a bex extension: Render has no equivalent, and User-Agent keeps naming the upstream release this checklist tracks. Two launcher-owned paths now report themselves because upstream's post-run hook cannot see them — a provider launch (bex code/bex glm/…) emits immediately before it replaces the process, and root --version emits with completion kind version, a deliberate divergence from upstream, which treats version as a non-event. bex upgrade needed nothing: it is an ordinary command on the imported root and already emitted through upstream's hook (measured, not assumed). GraphQL/MCP telemetry twins remain deliberately absent, matching upstream, which exposes telemetry over REST only.

  • Render's spec-based compute plan names are not accepted (v2.25.0). The CLI's --plan help/picker advertises Render's new 0.1c-256mb … 128c-1024g (Postgres) and 1g (Key Value) names, but that list is documentation-only, not validation — the CLI passes any value through, and bex validates against its own tier catalog (ADR030), returning a named 400 for a name it does not offer. bex's own plan names work; unambiguous aliases onto bex rungs ship in w8/011 (0.1c-256mb→basic-256mb, 0.5c-1g→basic-1gb, KV 256mb/1g; larger Render names still 400). w1/m170 (2026-10-02) adds the same exact-size rule for service compute plans (0.5c-512mb→starter, 1c-2g→standard, 2c-4g→pro, 4c-8g→pro_plus, 4c-16g→pro_max, 8c-32g→pro_ultra) on every plan write and in Blueprints; other compute sizes still 400.

  • autoDeployTrigger: checksPass (deploy only after CI checks pass) is rejected with a named 400 (w5/m53). bex maps its boolean spec.autoDeploy onto Render's autoDeployTrigger commit/off (and the legacy autoDeploy yes/no), accepting either on create/update with autoDeployTrigger taking precedence; it has no GitHub-checks integration, so the checks-gated trigger is a documented non-goal, not a silent no-op.

Regenerate the command tree with render <subcommand> --help. Re-run the graded baseline with scripts/cli-compat.sh verify. Grouping mirrors the CLI's own render --help sections.

The interactive-only Key Value client has a separate, opt-in full-edge verifier: set the production-equivalent BEX_API_URL, HYDRA_PUBLIC_URL, CLI_KEY_ENV, and the runner's explicit BEX_KV_VERIFY_ALLOW_CIDR, then run scripts/cli-compat.sh kv-cli-verify. The unmodified CLI provisions the source-restricted public fixture through keyvalues create --ip-allow-list, and the verifier always deletes the returned opaque id. It then launches the same v2.21.0 CLI under an automated pseudo-terminal with explicit --output interactive; one-shot Redis arguments after -- let the child and TUI exit without human input. This is deliberately excluded from the dev-9 baseline because it requires public Key Value DNS, TLS, and the SNI proxy. Production acceptance on 2026-07-18 exposed and repaired three real edge defects in sequence: the datastore wildcard was Cloudflare-proxied instead of DNS-only, Hetzner PROXY protocol was absent so exact source allowlists saw the load balancer, and redis-cli needed an explicit --sni hostname. The final opaque-id and display-name legs passed end to end; scripts/datastore-dns-cloudflare.sh and the proxy tests retain the deployed prerequisites.

Core

  • deploys — list, create, and cancel deploys
    • deploys list <serviceID> — carries Render's nested {image:{ref,…}} shape
    • deploys create <serviceID> — returns a deploy id; accepts the CLI's clearCache string enum
      • --clear-cache
      • --commit <id> — repository-backed non-cron services only; image-backed services receive a named 400 without creating a deploy (w8/m49)
      • --image <url> — image-backed services only; valid tags/digests accepted, with distinct grammar/private-address/untrusted-registry errors. Overrides preserve the saved image. Bex permits another trusted repository; Render documents a same-repository restriction (w8/m49).
      • --wait
    • deploys cancel <serviceID> <deployID> — resolves and cancels an open deploy
  • [~] jobs — create and manage one-off jobs. Creation is a deliberate non-goal (.pm/DO_NOT_DO.md; gated by w4/m116/t003, 2026-10-02); the history verbs still serve jobs created before the gate. The earlier [x] create grade rested on a dev-9 response shape only — on production every create was refused by Kubernetes RBAC and came back already failed (live 2026-09-17/21/26).
    • jobs list <serviceID> — lists jobs with status/planId/startCommand (history)
    • [-] jobs create <serviceID> — deliberate non-goal: named 410 before any record is written or Kubernetes Job submitted; the unmodified client prints received response code 410 (ONE_OFF_JOBS_UNSUPPORTED): one-off jobs are not supported on bex; … and exits non-zero. GraphQL createJob (extensions.code) and MCP create_job (code-prefixed error) refuse identically. Not yet re-probed live.
      • [-] --start-command <cmd> — refused with the command (above)
      • [-] --plan-id <id> — refused with the command (above)
    • jobs cancel <serviceID> <jobID> — cancels a pending/running pre-gate job; an already-terminal job is a 409
  • keyvalues (alias kv) — manage Render Key Value instances
    • keyvalues create — nested owner/options, opaque red-<xid> id, underscore maxmemoryPolicy; a nonempty CLI --ip-allow-list creates public intent without a bex-only flag
      • payment-required failures remain an ordinary Render API error (HTTP 402 with id/message); the actionable message names create_billing_checkout_session, so the unmodified client does not receive an undecodable body
      • --name
      • --plan <free|starter|standard|pro|pro_plus>
      • [~] --region — accepted but echoes local-capd (single fixed region)
      • --memory-policy <cache|queue|raw policy> — cache→allkeys_lru alias and raw policies round-trip
      • --ip-allow-list cidr=…,description=… — CIDR and description round-trip
      • --workspace <id|name>
      • [~] --project <id|name> — flag parsed; needs an existing project (none creatable via CLI)
      • --environment <id|name> — discovered canonical ID and name+project controls passed on dev-2 (w2/m165); legacy API links remain valid
    • keyvalues list
    • keyvalues get <id|name> — after suspend, status is suspended (Render databaseStatus; w5/061) so text/JSON Status matches hibernation even though the pinned client drops the separate suspended field
    • keyvalues update <id|name> — resolves by opaque id; core fields apply. w4/m116 (2026-09-20): --ip-allow-list had stored the list without publishing the store, so a private-born Key Value stayed unreachable and kv-cli kept targeting the in-cluster .svc host (live sweep 2026-09-17). A nonempty allowlist publishes on every entry point. w2/m166 supersedes the sticky-clear part of w4/m116: the pinned v2.27.0 client and official Render docs define --clear-ip-allow-list as disabling external access. Empty-list updates now withdraw publication; omission preserves it, and an explicit Bex public value wins. The datastore state matrix records legacy and create-default boundaries. The 2026-10-02 live finding proved the old clear admitted fresh authenticated traffic; the milestone acceptance records verification of the repair.
      • --name — rename; opaque red- id stays stable
      • --plan
      • --memory-policy — mutates maxmemoryPolicy on read-back (fixed w7/m45)
      • --ip-allow-list — replaces the allow-list, enables the external route for matching sources, and returns CIDR + description (write w7/m45; publication w4/m116)
      • --clear-ip-allow-list — clears rules and disables new external connections after convergence (array write w7/m45; external-disable semantics w2/m166); internal access remains available
    • keyvalues suspend <id|name>
    • keyvalues resume <id|name>
    • keyvalues delete <id|name> — unchanged CLI contract; durable cleanup remains internal (w2/m61)
  • [~] logs — view logs for services and datastores (single command). (Upstream defect at this pin: with no usable credential — absent, expired-beyond-refresh, or corrupt cli.yaml — the command panics with a Go stack trace and exits 2 in every non-interactive mode instead of a clean login error; see the Real-gaps bullet above. Re-check when the CLI pin moves. The [x] rows below grade the authenticated paths.)
    • query mode — resolves a service by name; empty windows return stable cursors (no parse crash)
    • [~] --tail — streams App or standalone build logs over WebSocket, with all text terms preserved; request/pre-deploy/datastore tails and store-only filters remain unsupported
    • -r, --resources <ids> — required in non-interactive mode; honored
    • --instance <ids> — comma-separated instance IDs; Bex accepts public IDs and legacy raw pod names, and emits public IDs. Bex-only type=predeploy queries via REST, GraphQL, and MCP resolve selectors against the authorized service's existing migration pods before reading logs (w2/039). Omitted selectors include all matching migration pods; unknown or foreign selectors match none, with text/time filters and limits still applied. This live source has no history fallback after TTL cleanup and no tail support. The pinned CLI still rejects --type predeploy; shipped migration output remains available under build as documented below.
    • --start <time>
    • --end <time>
    • --direction <backward|forward>
    • --limit <count>
    • [~] --text <values> — comma-separated CLI values become repeated REST terms and match as an OR, with other filters still ANDed; reversing or duplicating terms does not duplicate lines. Case-insensitive literal substrings only; see the text-search divergence above. The same terms apply to supported tails, while label-value discovery does not evaluate line text
    • --level <levels> — durable-logs supplement: a planted JSON error line is isolated exactly; an unmatched level is an honest empty. (The CLI's own --level enum has no unknown, so bex's honest plaintext bucket is reachable over REST only — upstream flag shape, not a bex gap.) Dev-9 (no Loki) answers 503
    • --type <types> — closed enum app/request/build (client rejects bex-only predeploy); app works live without the store; durable supplement proved app/request; build also carries pre-deploy Job stdout once shipped (w5/m100) so pre_deploy_failed is diagnosable without a type the CLI cannot send
    • --host <hosts> — durable-logs supplement: matches only the probe host; an absent host is an honest empty. Dev-9 (no Loki) answers 503
    • --status-code <codes> — durable-logs supplement: exact 404/200 exclude each other's probe lines, and the 4xx class shorthand matches. Dev-9 (no Loki) answers 503
    • --method <methods> — durable-logs supplement: the GET probes match; a never-sent method is an honest empty. Dev-9 (no Loki) answers 503
    • --path <paths> — durable-logs supplement: matches only the probe path; an absent path is an honest empty. Dev-9 (no Loki) answers 503
    • --task-id <ids> — accepted as a filter
    • --task-run-id <ids> — accepted as a filter
  • postgres (alias pg) — manage Render Postgres databases
    • postgres create — id/name/ipAllowList wire shape correct (description persists). With no allowlist flag, the pinned builder omits ipAllowList; REST defaults to public with explicit 0.0.0.0/0 and ::/0 rules, so readback no longer reports a blocked empty list (w2/038). Explicit REST [] creates private intent unless Bex public:true overrides it; the public API rejects ipAllowList:null and public:null with HTTP 400. Native GraphQL/MCP create defaults stay private. Create matrix and CLI limits
      • payment-required failures use the same CLI-decodable 402 envelope as Service and Key Value creates
      • --name
      • [~] --plan <free|basic_*|pro_*|accelerated_*> — bex's own tier names round-trip (input takes either spelling; REST answers in Render's API spelling basic_256mb, not the Blueprint basic-256mb, since w8/057); v2.25.0's help/picker also advertises Render's spec-based compute plans (0.1c-256mb … 128c-1024g), which map onto bex rungs when unambiguous (w8/011); larger names still 400
      • [~] --region <frankfurt|ohio|oregon|singapore|virginia> — accepted; platform-stamped local-capd
      • --version <int>
      • --disk-size-gb <int> — free is fixed at 1 GB; paid storage is capped at 16,384 GB (w8/m50)
      • --disk-autoscaling — requires a paid plan; free is a named 400 (w8/m50)
      • --connection-pool <none|pgbouncer> — w8/m33 / v2.26.0: CLI validates the enum then sends connectionPool; bex folds it onto its native pooler bool (resolvePooler), so pgbouncer provisions the CNPG Pooler on paid plans and none leaves it off; free enable returns a named 400 (w8/m50) (wire-shape graded; w2/024 alignment)
      • --high-availability — persists to the CR spec (status flips once replicas are ready)
      • --read-replica <name> — requires at least 0.5 CPU and 10 GB storage; maximum five; the existing basic-1gb plan qualifies at 10 GB (w8/m50)
      • --database-name <string> — persisted as the immutable physical database name and live-proven through CNPG SQL identity
      • --database-user <string> — persisted as the immutable owner role and live-proven through CNPG SQL identity; either custom-name flag may be omitted independently
      • [-] --datadog-api-key <string> — deliberate non-goal; named 400, credential never persisted
      • [-] --datadog-site <string> — deliberate non-goal; named 400
      • --workspace <id|name>
      • [~] --project <id|name> — flag parsed; needs an existing project
      • --environment <id|name> — discovered canonical ID and name+project controls passed on dev-2 (w2/m165); legacy API links remain valid
    • postgres list — resolves through the RC3 cursor envelope
    • postgres get <id|name> — resolves by name; every field intact; -o text renders Workspace/Region; v2.26.0's detail output also shows the connection-pool mode, which bex returns as the connectionPool enum (pgbouncer/none); after suspend, status is suspended (w5/061) so text Status stays truthful when the renderer omits the suspended field
    • postgres update <id|name>
      • --name — rename; opaque dpg- id stays stable
      • --plan — rejects unsupported retained replicas/pooler/autoscaling or allocated storage before writes; accepted changes preserve the disk high-water mark (w8/m50)
      • --disk-size-gb — grow succeeds; shrink returns a named 400 and leaves intent/state unchanged (w2/m60 exact PATCH regression)
      • --disk-autoscaling — paid enable/disable; free enable is refused and legacy free disable remains allowed (w8/m50)
      • --connection-pool <none|pgbouncer> — w8/m33 / v2.26.0: PATCH decodes connectionPool via resolvePooler; paid enable and legacy free disable are allowed, free enable is refused (w8/m50)
      • --high-availability
      • --ip-allow-list cidr=…,description=… — replaces the list and enables external access for matching sources; description returns (w2/m166)
      • --clear-ip-allow-list — clears rules and disables primary/pooler/replica external routes after convergence (w2/m166); internal access remains available
      • [-] --datadog-api-key <string> — deliberate non-goal; named 400, credential never persisted
      • [-] --datadog-site <string> — deliberate non-goal; named 400
      • [~] --project <id|name> — flag parsed; needs an existing project
      • [~] --environment <id|name> — shared canonical-ID resolver covered by w2/m165 source/regression checks; update variant not separately live-tested
    • postgres suspend <id|name>
    • postgres resume <id|name>
    • postgres delete <id|name> — unchanged CLI contract; backup purge is terminally observed (w2/m61)
  • restart <resourceID> — restarts by typed id (restart <resourceID> is the CLI's only documented form)
  • services — list services and datastores; bare services lists them
    • -e, --environment-ids <ids> — filter list by environment IDs
    • --include-previews — accepted list flag
    • services create — returns the {service,deployId} record; raw dashboardUrl is …/<type>/<srv-id> (/web/, /cron/, /static/); a cardless paid create uses Render's declared 402 error schema and keeps the checkout instruction in message
      • --name
      • --type — web_service, cron_job, static_site all accepted
      • --runtime — round-trips (build not exercised in dev-9)
      • --repo — round-trips (build not exercised)
      • --branch
      • --image — exact generated image.ownerId:"" passes the composed server and the deployed production API (w5/m49)
      • --plan
      • [~] --region — accepted; bex overwrites to local-capd (CLI enum rejects local-capd itself)
      • --num-instances
      • --build-command — round-trips (build not exercised)
      • --start-command
      • --pre-deploy-command
      • --cron-command — on cron_job, on every runtime. A cron's command is runtime-independent on the wire: the pinned client's cron builder emits it as envSpecificDetails.startCommand for all runtimes (pkg/service/create.go buildCronEnvSpecificDetails, no docker branch) and its clone path reads it back the same single way (pkg/service/clone.go:143,328-334 → AsNativeEnvironmentDetails().StartCommand). bex therefore accepts either spelling on write (dockerCommand wins when both are sent) and emits both on read for a docker cron — dockerCommand for the runtime-keyed readers (dashboard, blueprint generation) and startCommand for the client that re-sends it. Before w9/m165 the docker path dropped the command on create and projected an empty dockerCommand, so services update --cron-command printed a no-op it had not performed and create --from cloned an empty command; verified live on dev-9 2026-09-21 across create → read → update → clone, and guarded by the docker-cron-create/docker-cron-update/docker-cron-clone legs of scripts/cli-services-parity-verify.sh. An image-runtime cron projected no envSpecificDetails at all, so create --from cloned it with no command and every run executed the image entrypoint. Since w1/m167 it emits envSpecificDetails: {startCommand}, and only that; an image cron with no command, and an image web service, still emit no block. lego/cli/image_cron_clone_contract_test.go drives the pinned clone path against the read bex-api is proven to emit.
      • --cron-schedule — on cron_job
      • --health-check-path
      • --auto-deploy
      • [-] --previews — 400 "not supported by this platform" (deliberate non-goal)
      • --publish-directory — on static_site
      • --root-directory — on repo services
      • --env-var KEY=VALUE — exact configured App readback
      • --secret-file NAME:PATH — exact configured OpenBao readback; content never logged
      • --registry-credential <cred> — exact credential metadata plus authenticated private-image pull to Running
      • --ip-allow-list cidr=…,description=… — ordered CIDR and description round-trip
      • --build-filter-path <path>
      • --build-filter-ignored-path <path>
      • --maintenance-mode — requires a paid plan (400 on free)
      • --maintenance-mode-uri <uri>
      • --max-shutdown-delay <seconds>
      • --environment-id <id>
      • [~] --from <serviceID> — clones fine, but needs an explicit --region (CLI re-validates the source's local-capd)
    • services update <service>
      • workload type is intentionally not an update flag — current CLI schema matches Render's immutable-type contract; bex Blueprint and CRD admission reject the same transition
      • --name — rename; the shared API rejects controls/line separators, more than 100 Unicode code points after trimming, and registered resource-ID lookalikes (w8/034). The pinned CLI trims before HTTP and cannot express an empty clear; direct REST/GraphQL/MCP preserve the empty-clear contract. The 100-code-point bound is Bex policy; Render's exact limit is unverified.
      • --plan
      • [~] --runtime — upstream CLI guard; exits before any request (cannot switch runtimes via the CLI)
      • --repo
      • --branch
      • --image — exact generated image.ownerId:"" PATCH passes locally and against the deployed production API (w5/m49)
      • --build-command
      • --start-command
      • --pre-deploy-command
      • --cron-command — exact configured native-cron replacement
      • --cron-schedule
      • --health-check-path
      • --auto-deploy
      • [-] --previews — 400 "not supported by this platform"
      • --publish-directory — on static_site
      • --root-directory — on repo services (image services correctly 400)
      • --registry-credential — distinct credential A→B replacement plus authenticated rollout
      • --ip-allow-list cidr=…,description=… — ordered CIDR and description replacement
      • --build-filter-path <path>
      • --build-filter-ignored-path <path>
      • --maintenance-mode
      • --maintenance-mode-uri <string>
      • --max-shutdown-delay <int>
    • services instances <serviceID> — decodes live pod ids; suspended service returns []; unknown id fails not-found
    • services delete <serviceID> — acknowledgement shape is unchanged; production image deletion and immediate first-build deletion reached GET 404 + official-CLI list absence inside five minutes, with zero cluster/Zot residue (w5/m49)
  • [-] workflows — Render Workflows (deliberate bex non-goal; GET /v1/workflows is a 200 [] stub, everything else 404/405/TTY-blocked)
    • [-] workflows list — returns empty from the stub (exit 0)
    • [-] workflows create — 405 Method Not Allowed
    • [-] workflows init — client-side scaffolding works, no bex call
    • [-] workflows dev — client-side dev server, no bex call
    • [-] workflows start / workflows cancel — shortcuts; 404 / TTY-blocked
    • [-] workflows tasks — tasks list / tasks runs {start,list,show,cancel} all 404 / TTY-blocked
    • [-] workflows versions — versions list / versions release 404
  • workspaces — lists the caller's real tea-… workspace

Auth

  • login — recognizes RENDER_API_KEY (already-authenticated short-circuit); full browser/device flow verified separately in production. w8/m27 compatibility: the official launcher stays unmodified and platform-marked (bex.co/platform-client). The dispatch-time scope matrix (w8/m27, docs/ADR069-security-review-round14.md) requires every human OAuth token — platform clients included — to carry a granular capability; a token with no granular scope is refused with 403, which the CLI surfaces as "you are not allowed to take this action." So bex's own device-flow adapter (internal/cliauth, not a CLI fork) requests openid offline_access bex.read bex.write bex.sensitive on the unmodified CLI's behalf — the exact granular set the Render-CLI Hydra client is provisioned to allow with skip_consent (scripts/auth-bootstrap-client.sh) — so the minted platform-client token satisfies read/write/sensitive/mint op classes. bex.api remains a compatibility alias only and is never requested. TestDeviceGrantScopeCoversEveryOpClass pins the coverage; do not trim the adapter's requested scope back to identity-only. Do not fork the CLI.
  • logout — drives the OAuth revoke path; the credential's client_credentials grant fails afterward
  • whoami — reports the key-minting user's email (Kratos-admin lookup)
  • workspace — manage the CLI's active workspace
    • workspace current — returns the active tenant
    • workspace set <id> — set→persist→read round-trip verified

Session

  • kv-cli [id|name] — automated PTY plus scripts/cli-compat.sh kv-cli-verify proved public rediss:// by opaque id (PING/SET) and display name (GET/DEL) through the unmodified v2.21.0 CLI; see the 2026-07-18 production evidence
  • pgcli [id|name] — a bounded pseudo-terminal crosses the real interactive-only guard, resolves one disposable public bex Postgres by opaque id and exact name, preserves passthrough flags, and runs SELECT 1 AS bex_pgcli_probe; through real pgcli 4.5.0 twice; the negative piped guard, redacted shim handoff, SQL markers, and cleanup proof are recorded in the pgcli CLI artifact. w4/m116/t001 (2026-09-20): pgcli consumes the same external connection string as psql, so it inherits the same sslmode=verify-full trust requirement and the same launcher-side CA provisioning described in the psql row below (pgcli reaches libpq through psycopg, which honors PGSSLROOTCERT identically). Covered by the launcher's tests; not re-probed live against production in that task
  • psql [id|name] — the checksum-verified, unmodified v2.21.0 CLI resolved one disposable public Postgres by opaque id and exact name, passed its client-side public /32 allow-list gate, consumed the external sslmode=require connection string through pg-sni-proxy, and returned SELECT 1 AS bex_psql_probe; through real psql 18.4 twice. Unknown-name, absent-allowlist, endpoint-convergence, credential-redaction, and cleanup behavior are asserted by the live verifier and its hermetic regression (the psql CLI artifact). Re-graded for the verify-full era (w9/063, live probe 2026-09-20): the sslmode=require evidence above predates w4/m95, which deliberately moved a public database's external strings to sslmode=verify-full (lego/backend/internal/postgres/service.go:759,761,775,789). The pinned CLI hands ExternalConnectionString to psql verbatim (pkg/tui/views/psql.go:75,213 — bex cannot override the mode without forking the client), so on a machine with no CA installed bex psql <dpg-id> -c 'SELECT 1 AS bex_psql_probe;' fails with root certificate file … does not exist, and PGSSLROOTCERT=system fails with certificate verify failed (the CNPG CA is private, by design). This is intended behavior, not a regression or a gap: the supported path is to install the CA from the dashboard's Connections panel (connection-info serverCaCertificate) and point PGSSLROOTCERT=/path/to/<id>-ca.pem at it — with that, the same command returned the probe row 1 on 2026-09-20 (bex v0.2.1, pin v2.27.0, production). Render needs no counterpart because it presents publicly-trusted certificates; this is a recorded bex extension. Automated by w4/m116/t001 (2026-09-20): requiring every user to install a CA by hand was not an acceptable out-of-box journey, so the bex launcher now provisions it — the same interception class as the root --version path (lego/cli/main.go, lego/cli/internal/pgtrust/). Before delegating a psql/pgcli invocation that names a database, the launcher reads serverCaCertificate from GET /v1/postgres/{id}/connection-info, writes it to a private (0600) temporary file, and sets PGSSLROOTCERT for the child process, removing the file when the session ends. TLS is never downgraded and the connection string is never rewritten; an explicit PGSSLROOTCERT or an existing ~/.postgresql/root.crt always wins, an internal-only database is untouched, and a 503 (CA not provisioned yet) is refused with a message that names the cause instead of psql's opaque certificate error. The behavior is client-side only, so an unmodified render binary sees a byte-identical request/response contract and keeps the manual-CA journey above. Proven by the launcher's tests (lego/cli/psql_trust_test.go, lego/cli/internal/pgtrust/*_test.go); not yet re-probed live against production from a CA-less machine.
    • -c, --command <SQL> — both id and exact-name selectors returned the asserted probe row through the real non-TTY client path
  • ssh [serviceID|serviceName|instanceID] — live-proven through the public production gateway with the unmodified v2.21.0 CLI: service name, service id → Any instance, and complete instance id all reached the asserted running container; the upstream exact-instance picker bug is documented above, while the direct instance-id form proves exact targeting (ADR035)
    • [-] -e, --ephemeral — deliberate bex non-goal (flag parses)
    • [-] --plan <string> — tied to --ephemeral (non-goal)

Management

  • [~] blueprints — manage Blueprints (infrastructure as code)
    • blueprints validate <render.yaml> — the unmodified v2.21.0 CLI successfully decoded both a valid hello-go/render.yaml response and an invalid autoDeployTrigger: checksPass response from freshly deployed api.bex.co on 2026-08-03 (the latter carried services[0].autoDeployTrigger plus source line/column). w8/m19 production re-grade (2026-08-16): after the m19 image rollout, the same unmodified CLI and the raw multipart response accepted one custom .yaml fixture containing static buildCommand, monorepo dockerContext, and private-image image.creds.fromRegistryCreds.name; the response was valid:true. This closes the CLI validate transport/decoding grade. It does not turn the Blueprint feature's overall partial row into blanket parity: the capability registry still intentionally rejects its documented subset, and the separate live apply legs are not evidence for this CLI command. w8/m33 / v2.25.0: blueprints validate now exits status 1 on an invalid blueprint for every --output mode (previously it could exit 0 while printing valid:false); this is a client-side exit-code change over the same valid:true|false body bex already returns, so the transport grade is unaffected.
    • Bex Blueprint datastore apply/export (w2/m166): an omitted ipAllowList on sync preserves rules and public intent; [] disables external access and nonempty rules enable matching sources. Explicit lists have the same effect at create; allowed omission retains the Bex-native private default. Export writes [] for private resources, 0.0.0.0/0 plus ::/0 for public/empty resources, and existing rules for restricted public resources. Render YAML cannot retain inactive rules separately from publication, so the first apply can normalize storage without changing effective access; its plan must show that change, followed by a no-op plan. These are Bex apply/export semantics, not additional pinned-CLI commands or a blanket live-parity grade. State matrix and export limits.
  • environments <projectID> — decodes the RC15 cursor envelope into real values; unknown project fails not-found
  • projects — lists projects in the active workspace

Additional commands

  • docs — unmodified render docs opens render.com/docs (client-side; no bex call). The bex launcher overrides this command to open the Bex CLI guide on GitHub (lego/cli/internal/branding.DocsURL); that is a launcher branding overlay, not a change to the unmodified-CLI grade above.
  • completion [bash|zsh|fish|powershell] — w2/m79 / v2.24.0: generates shell autocompletion scripts client-side (no bex call); help + local smoke on the unmodified pin
  • [~] ea — early-access surfaces; ea sandboxes create/list/stop (w3/m32) and exec (w3/m33) all ship on the gVisor substrate, ea objects still out of the compatibility target. The pinned CLI's group is plural (Use: "sandboxes", no sandbox alias) — bex ea sandbox … exits 1 with unknown command "sandbox" for "bex ea". Re-diff those names on every pin bump.
    • [-] ea objects list — bex /v1/objects → 404 (--local works client-side)
    • [-] ea objects put — 404 (--local works client-side)
    • [-] ea objects get — 404 (--local works client-side)
    • [-] ea objects delete — 404 (--local works client-side)
    • ea sandboxes create — verified with the real CLI (v2.21.0): POST /v1/sandboxes → 201. The CLI sends only --plan (no template flag), so an empty template resolves to base, and it sends ownerId in the body (bex reads body-or-query). plan/region/timeoutSeconds and the effective {default:"deny-all"} policy round-trip from reserved runtime metadata. Lifetime (w5/m99): omitted or timeoutSeconds: 0 means the documented default/maximum of 86400 (24h), matching the pinned CLI's --timeout help — never an immortal sandbox; the read shape always returns the effective bound. allow-all is a deliberate named 400 (SANDBOX_NETWORK_POLICY_UNSUPPORTED) because bex will not claim an unenforced policy. Runs on the gVisor substrate in the caller's <ws>-sandbox namespace. ID shape (w9/m94, code landed 2026-09-20): the id a create returns is a bex typed id (sbx-<xid>), not the OpenSandbox substrate's UUID. The live hunt of 2026-09-20 caught production returning the bare UUID (c372c97e-…), which the pinned client's ea sandboxes copy arg parser rejects client-side (cmd/sandboxcopy.go: ^(sbx-[A-Za-z0-9]+):(.*)$). bex now mints the public id itself and carries it as durable sandbox metadata; every read path (list/get/stop/exec, REST + GraphQL + MCP) reports that id and dual-accepts a pre-w9/m94 substrate id. Pinned by lego/backend/internal/sandbox/publicid_test.go and, on the client side, lego/cli/sandbox_id_contract_test.go (a bex-shaped id parses; a bare UUID is refused before any request). Live-verified 2026-10-02 (w9/m94 t004): released bex v0.2.1 (pin v2.27.0) against production, workspace bex-canary, human device login: ea sandboxes create --plan=starter --timeout=600 -o json → "id": "sbx-…"; copy ./probe <sbx-id>:/tmp/probe passes the client's arg parser and uploads (and the reverse download round-trips the bytes); exec <sbx-id> -- true → exit 0, -- sh -c 'echo out; echo err >&2; exit 7' → exit 7; list shows the sbx- id; stop --confirm → exit 0, then list is empty and exec/GET answer 404 SANDBOX_NOT_FOUND. REST GET /v1/sandboxes/{id}, GraphQL sandbox/sandboxes, and MCP list_sandboxes all reported the identical sbx- id; a bare-UUID id answers the same non-enumerating 404 SANDBOX_NOT_FOUND, never 500.
    • ea sandboxes exec — re-graded and re-verified live 2026-09-15 (w7/m147): with the pinned launcher against production, exec <id> -- sh -c 'echo out; echo err >&2; exit 7' prints out/err and exits 7, -- true exits 0, an unknown or terminated id exits 1 with received response code 404 (SANDBOX_NOT_FOUND): sandbox not found; the raw handshake mints 201 {executionId: exe-…, expiresAt (+60 s), method: POST, token, uri} and a replayed, altered-command, cross-sandbox, OAuth-bearer, or bare redeem is refused with 409/403/403/401/401 JSON {message} bodies. Since the v2.24.0 pin (w2/m79) the client runs a two-step handshake, not the single POST /exec the earlier evidence below describes: POST /v1/sandboxes/{id}/runs/stream/token?ownerId= with {"command"} → 201 {executionId, expiresAt, method, token, uri}, then POST <uri> with Authorization: Bearer <token>, Accept: text/event-stream, {"command"} → 200 SSE. bex serves both since m147: the mint is a gated write, and the redeem route sits outside the OAuth gate accepting only that single-use, ≤60 s, HMAC connect token bound to workspace + sandbox + execution + command (an OAuth token is refused there; a connect token is refused everywhere else). The SSE terminal events now carry the keys every pin from v2.21.0 to v2.27.0 decodes — event: exit {"exit_code":N} and event: error {"status":404,"message":"sandbox is no longer running"} — where bex previously emitted exitCode and {error,code}, so a failing command exited 0 and an error printed status 0: ; the v2.21.0 proof below only ran exit-0 commands and could not see it. Pinned two ways: lego/cli/sandbox_contract_test.go drives the pinned pkg/sandbox.Repo against lego/cli/testdata/sandbox-exec-contract.json (exit 7 with stdout + stderr; terminated-target message), and lego/backend/internal/sandbox/connect_test.go proves the real mint, redeem, and gateway produce those exact bytes. Legacy single-step POST /v1/sandboxes/{id}/exec still streams for ≤ v2.21 clients and MCP. Prior evidence (v2.21.0, w3/m33): verified on prod with the real CLI (w3/m33): POST /v1/sandboxes/{id}/exec streams the command's stdout/stderr + exit as SSE (event: output/exit). render ea sandbox exec <id> -- sh -c 'uname -r' returned 4.19.0-gvisor from inside the gVisor sandbox (historical command string; at this pin the group is plural — bex ea sandboxes exec — with no sandbox alias). Runs via k8s pods/exec confined to the isolated ssh-gateway (bex-api authorizes + reverse-proxies, never gains pods/exec); execd's gRPC API and the CLI's older two-step token flow are both unused.
    • ea sandboxes copy — bounded implementation (w7/m150): the pinned client mints with POST /v1/sandboxes/{id}/files/{operation}/token?ownerId=&path= and no request body, then streams to the returned PUT/GET URI. The API and isolated gateway now serve both operations with owner/admin and fresh permission checks, path-bound single-use tokens backed by the shared nonce store, 256 MiB wire/decoded limits, at most 10,000 archive entries, and a five-minute deadline. Supported paths must be clean absolute paths; links and special archive entries are refused; directory uploads require a new destination, while single files may replace a regular file. Uploads stage before publication; gzip completion distinguishes a successful download from late execution failure. The shared fixture and pinned-client tests cover the actual wire contract; backend tests compose the real mint/redeem and gateway handlers. Live-verified 2026-10-03 (w7/m150 t005, dev-7): the pinned launcher uploaded and downloaded a binary file, an empty file and a nested directory byte-identically through a real sandbox and the in-cluster gateway. The live refusal matrix (traversal, foreign owner, replay, wrong path or method, oversize, missing parent, existing directory, interrupted upload) behaved as documented, and every fixture was removed. See the exact supported subset and failure behavior. The earlier claim that the client rejects escaping symlinks was too broad: it can create one, but refuses subsequent traversal through it. Sandbox groups remain a separate non-goal.
    • [~] ea sandboxes create --env-var / --env-file — not supported, refused by name (w9/067). The pinned client sends these as a first-class env object (pkg/sandbox/service.go:91) and bex does not plumb environment into a sandbox, so the create answers 400 SANDBOX_ENV_UNSUPPORTED naming the flags — previously it fell through strict decoding as unknown field "env", which names an internal wire key rather than the unsupported feature. Whether bex should support sandbox env at all is an open scope decision, not a defect. --snapshot-id refuses the same way (SANDBOX_SNAPSHOTS_UNSUPPORTED), consistent with its [-] grade below.
    • ea sandbox-groups list — gap (graded 2026-09-15): GET /v1/sandbox-groups?ownerId= → bex 404. Not implemented; same candidate note.
    • [-] ea sandboxes snapshots {create,get,list} — non-goal at this pin (see the v2.27.0 re-baseline bullet above): the snapshot routes are outside the bex ea compat target; help-graded only.
    • ea sandboxes list — verified with the real CLI: GET /v1/sandboxes → 200, returned as the CLI's cursor-paginated [{sandbox, cursor}] envelope. Results are additionally filtered to the caller's durable owner metadata; workspace admins are the explicit override.
    • ea sandboxes stop — verified with the real CLI: POST /v1/sandboxes/{id}/terminate → 204; a foreign/ownerless id returns the same SANDBOX_NOT_FOUND 404 as an absent id.
  • [~] skills — manage Render agent skills for AI coding tools (client-side; no bex dependency)
    • skills install — works; --dry-run reports intended changes without writing
    • skills list — upstream CLI panic (nil-pointer) in every non-TTY output mode
    • skills update — same upstream non-TTY panic; no --dry-run
    • skills remove — works
  • help [command] — CLI builtin, no bex call