Skip to content

feat: architecture hardening — agent contract, pricing truth, wizard engine, timeouts, MCP, verified updates - #57

Merged
hi-lei merged 19 commits into
mainfrom
feat/arch-hardening
Aug 9, 2026
Merged

feat: architecture hardening — agent contract, pricing truth, wizard engine, timeouts, MCP, verified updates#57
hi-lei merged 19 commits into
mainfrom
feat/arch-hardening

Conversation

@hi-lei

@hi-lei hi-lei commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

One feature branch, one commit per fix group (15 commits, +8.2k/−1.3k across 163 files). Originates from a full-repo adversarial review + second-opinion pass; full evidence write-up lives in temp/docs/review-2026-08-09.md (gitignored working copy) — happy to move it into docs/ if we want it tracked.

Every fix below was verified with a failing test first (or a live repro), then fixed, then gated by make build / lint / test (-race).

Test infrastructure (new)

tests/contract/ — hermetic black-box suite: builds the real binary, drives it against an in-process mock Verda API (per-test fixture store, wire-path-faithful). Covers: agent-mode JSON purity (no ANSI on stdout), INTERACTIVE_PROMPT_BLOCKED exit 2 (<5s, hang-proof), CONFIRMATION_REQUIRED gates actually blocking deletes, error classification (401→exit 3, 500→exit 4), stdout/stderr separation, C1 pricing end-to-end, debug redaction (incl. form-encoded OAuth fallback), transfer-timeout regression pins. Runs in default make test; staging-gated tests/integration/ untouched. make test now runs with -race (CI inherits).

Commits (chronological)

Commit Scope
2e21055 fix(agent-mode) Prompt safety net was dead (factory built before flag parse; --agent could hang on a real TUI). Now resolved lazily + full swallow sweep (nilerr sites propagating real errors) + --yes gates added where missing (volume create/delete, ssh-key delete) + structured success output + non-TTY (ErrNoTerminal) clean errors
2ecc63a test(contract) the harness above
16679db fix(vm) --agent vm create hung ≤5min: --wait default was evaluated pre-flag-parse
1a0ce4c fix(pricing) price_per_hour is a TOTAL (verified live on staging, on-demand discriminator + fixture c1-ondemand-instance.json): status dashboard no longer multiplies by GPU/vCPU count (was 8× overstatement); one canonical volume-price helper (730h) replaces 5 divergent copies; unknown storage types now error instead of $0; dead --location removed; money-path tests assert production helpers instead of re-implementing sums
45e0f54 fix(wizard) MessageBus data race (RWMutex; go test -race red→green); rewind-guard self-defeat (infinite backward loop possible); cancel sentinel contract (ErrCancelled ⊃ ErrInterrupted/context.Canceled, silent exit 0 on cancel, quiet main.go); step Validate renders inline instead of silently redrawing; exit-confirmation dialogs removed from all 8 wizards per house rule; spinner/progress/editor/pager Ctrl+C contract aligned (no self-SIGINT; ops abort via ctx); two latent races exposed by -race fixed at root (pkg/version pflag init, resume test fake)
e224c5f fix(http) dropped client-level 30s timeout cap (--timeout 120s silently clamped); data-plane transfers (registry push/copy, objectstorage sync/mv) no longer die at 30s; interactive confirmations off the bounded ctx (6 sites); bare-context API calls swept (vm action, update --verify)
7b00260 fix(debug) --debug redaction now content-type aware — form-encoded OAuth fallback no longer leaks client_secret; JSON key list covers secret_access_key/service_account_key/jupyter_token etc; contract test pins positive + negative assertions
cd7e9cc fix(registry) source-registry creds resolved per host (was: docker.io keychain for every source — private ghcr/ECR/GCR broken AND Hub creds leaked to foreign hosts)
3fb5f68 fix(mcp) lazy-init race under worker pool (sync.Once, race repro red→green); destructive/billing tools require confirm: true (create_vm, create_volume, vm_action shutdown/force_shutdown/hibernate/delete); vm_action is honest now (accepted by default, wait:true actually polls, failed transitions are errors); zero schema validation in mcp-go worked around with strict handler-side arg checks (no more "500"→0→50GB default, string ssh_key_ids→all-keys); estimate validates storage_type; vol/instance pollers treat error/not_found as failures
6db023c fix(update) self-update verifies checksums fail-closed against the goreleaser-published sums before touching the binary (--skip-verify escape hatch, named in the error); install.sh verifies too (VERDA_INSTALL_SKIP_VERIFY=1 / VERDA_INSTALL_BASE_URL for tests); agent-mode update emits structured JSON; manual six-scenario installer verification recorded in commit
f0cb18e fix(vm) template fills only flags you didn't pass (--from t --location X finally works as documented); template wizard "decide at deploy time" location no longer silently persists FIN-01; contract step filters undeployable periods (was: 13 steps then guaranteed failure); delete-volume semantics unified three ways (interactive explicit-empty / agent mirrors batch --with-volumes / no more silent OS-volume deletion contradiction); {location} hostname expands with the effective location; agent vm action honors --wait honestly
29cb468 fix(objectstorage) resume-adoption pins covered-bytes tail size — a mismatched file can no longer silently complete an object with another file's bytes
c366554 fix(options) env credentials beat stored profile (as documented; was silently reversed) — flag > env > file matrix tested
81b85ed fix(cli) standalone prompts now render to stderr (stdio pollution fix; tables stay on stdout); agent-mode usage errors get exit 2 / VALIDATION_ERROR per contract
86d656a chore 296 stale tmp-test dirs swept + t.TempDir(); doc drift (make-test-doesn't-lint claims, README --target, doc index rows); CI pins (trivy-action@master→v0.36.0, git-cliff==2.13.1, goimports v0.48.0)

Behavior changes reviewers should sign off on

  • Wizard Ctrl+C exits immediately everywhere (exit-confirm dialogs gone) — house rule applied.
  • --agent vm create/action return after issuance by default; --wait opts back in.
  • verda update fails closed without network-reachable checksums (--skip-verify to bypass).
  • MCP tools create_vm/create_volume/vm_action require confirm:trueMCP client configs/prompts may need a nudge.
  • Interactive prompts now render on stderr (pipeline-clean stdout).
  • cost estimate --location removed (never implemented; pricing is currency-scoped).

Deferred (recorded in commit messages / review doc)

  • cosign signature verification of updates (checksums now; sigstore needs a dep decision — documented in cmd/update/CLAUDE.md).
  • goimports local-prefix typo fix deferred (would regroup ~100 files; noted in .golangci.yaml/Makefile comments).
  • MultiSelect hint-bar gaps (action.go/batch.go), checkpoint-write cadence, S3>5GiB multipart copy-object, objectstorage lock GC — LOW items queued for a follow-up.
  • SDK OAuth calls carry no ctx (upstream SDK issue candidate).

Test plan

  • make build ✓, make lint 0 issues ✓
  • make test (-race) green, multiple consecutive full runs; 31 packages incl. contract suite
  • Contract suite hermetic: no network/creds, parallel-safe, event-driven (no sleeps)
  • Live staging smoke: read paths + full create→inspect→delete lifecycle (used to settle C1 empirically)
  • Every CRITICAL/HIGH from the review has either a regression test, a wire-pin in the contract suite, or a documented live verification

hi-lei added 19 commits August 9, 2026 14:35
…tion

- Dynamic prompter: Prompter()/Status() resolve agent mode at call time
  (the factory is built before flag parsing); agent mode returns a fail-fast
  agentPrompter (INTERACTIVE_PROMPT_BLOCKED) and never a spinner.
- ErrCancelled + propagation sweep: wizard engine returns sentinel
  wizard.ErrCancelled; commands map prompt cancel (Ctrl+C/Esc) to clean nil
  exits while propagating real prompt/engine errors instead of swallowing
  them. Bubble Tea prompts fail fast with tui.ErrNoTerminal when stdin is
  not a TTY (pipe/redirect) instead of redrawing forever.
- --yes gates: volume create/delete (single and --all), ssh-key delete,
  startup-script delete and template delete require --yes in agent mode
  (CONFIRMATION_REQUIRED); --yes skips confirmation interactively.
- Structured outputs + tests: agent-mode JSON results for create/delete;
  regression tests for lazy prompter resolution, fail-fast prompts and the
  --yes gates; README updates for the new flags.
…ode guarantees)

Add tests/contract/, a Go test package with no build tags that builds the
real verda binary once (TestMain, go build into a temp dir) and drives it
against an in-process mock of the Verda API. No network, no real
credentials, parallel-safe (each test gets a fresh mock server).

Harness
- tests/contract/mockapi: per-test httptest server with an isolated fixture
  store reusing the SDK wire types. Covers POST /oauth2/token (JSON + the
  SDK's form-encoded 400 retry), GET /instance-types, GET/POST /instances,
  PUT /instances (actions), GET/POST /volumes, GET/DELETE /volumes/{id},
  GET/POST/DELETE ssh-keys, GET /scripts, /locations, /instance-availability,
  /balance; unknown routes answer 404 JSON. FailRoute() overrides any exact
  path with a status code; created instances report "provisioning" once then
  "running" from their first read, so --wait pollers converge without sleeps.
- Run harness: env isolation via VERDA_HOME+tempdir, mock credentials via
  VERDA_CLIENT_ID/VERDA_CLIENT_SECRET, --base-url flag per invocation, cwd
  pinned to a temp dir (no config.yaml poisoning), stdin is /dev/null so any
  prompt regression hangs loudly instead of passing, 30s per-command
  deadline; TestMain warm-up run absorbs the macOS dyld/Gatekeeper
  first-launch penalty (~60s on cold binary content) once instead of letting
  parallel tests eat per-command timeouts.

Contracts covered
- --agent vm list -o json: stdout is pure JSON (valid parse, zero ANSI
  bytes), exit 0, stderr empty.
- --agent settings theme with stdin /dev/null: INTERACTIVE_PROMPT_BLOCKED
  with choices, exit 2, wall-clock < 5s (catches blocking regressions).
- --agent volume delete: CONFIRMATION_REQUIRED without --yes (mock state
  untouched), structured delete result with --yes.
- Error classification per docs/agent-errors.md: 401 -> AUTH_ERROR exit 3,
  500 -> API_ERROR exit 4, both with details.status.
- Table mode: instance table on stdout, nothing on stderr.
- C1 pricing contract: the 8-GPU catalog type is priced at exactly 8x its
  1-GPU sibling and CPU.4V.16G carries the staging ground-truth totals
  (0.0279 on-demand / 0.0098 spot); vm create/describe/list must pass
  price_per_hour through as the wire TOTAL — a re-multiplication regression
  breaks exact equality.
- --debug redaction: token exchange leaks neither client_secret nor the
  issued token. The form-encoded fallback subtest reproduces review
  H1 (verified failing: secret printed verbatim) and is skipped pending the
  fix PR.

Wire-up: no build tag, so the suite runs under "make test"
(go test ./...) and CI's Test job unchanged; tests/integration (staging)
untouched; -short skips the binary build.
The --wait default was evaluated via f.AgentMode() at command-tree
construction, but the factory is built before flags are parsed (cmd.go),
so the default was always true: --agent vm create polled instance status
up to the 5m --wait-timeout instead of returning after issuance.

runCreate now computes the effective wait at runtime: agent mode returns
the issued instance immediately unless --wait was passed explicitly.

Tests:
- unit: agent-mode create with default --wait performs zero
  GET /instances/{id} polls; explicit --wait polls (create_test.go)
- contract: TestAgentVMCreateReturnsAfterIssuance asserts the same at
  the wire level via a new mockapi InstanceGetCount; mock flips
  instances to running on first read so polling alone is not observable
Verified live on staging (temp/docs/c1-ondemand-instance.json, review C1):
price_per_hour on both the instances and instance-types endpoints is the
TOTAL hourly price of the instance. cmdutil.InstanceTotalHourlyCost
multiplied it by GPU/vCPU count again, so the status dashboard overstated
burn rate Nx (an 8-GPU instance reported 8x its real cost).

- status: burn rate is the plain sum of instance price_per_hour totals;
  delete InstanceTotalHourlyCost/InstanceBillableUnits/
  InstanceTypeBillableUnits
- volume pricing: single source of truth in cmdutil
  (VolumeHourlyPrice/VolumeMonthlyPrice/HoursInMonth), replacing five
  divergent copies (wizard summary, vm create display, cost estimate,
  cost running, MCP estimate); MCP used a /30/24 month with per-GB
  ceiling, now the house formula
- MCP estimate monthly horizon 24*30 -> 730
- MCP get_running_costs now aggregates attached-volume costs, mirroring
  cost running
- cost estimate: unknown --storage-type fails listing valid types (was
  silent /bin/bash); same guard on volume create --type; drop the never-read
  --location flag (API prices currency-only) from the command and the
  MCP estimate_cost tool
- tests: TOTAL-semantics fixtures (CPU.4V.16G=0.0279/0.0098 staging
  ground truth), exact-equality 8-GPU burn regression test, money-path
  tests assert production computeTotals instead of inline sums
- docs: root CLAUDE.md and vm/volume CLAUDE.md now state TOTAL semantics
  (they contradicted each other, which is how the bug kept getting
  re-derived)
…lidation

Wizard engine hardening batch (review 2026-08-09, PR-C):

- Race (C3): MessageBus is now mutex-guarded. stepLoop writes
  (Broadcast / store-change) while the composite tea program reads
  (RenderAll) once per frame on its own goroutine; -race flagged it.
  make test now runs go test -race so it cannot regress (CI Test job
  inherits it via make test).

- Rewind guard self-defeat (HIGH): transition() zeroed rewindCount on
  every reset, so the maxRewindsPerStep guard never fired and a loader
  that kept returning empty choices bounced the user back forever (only
  Ctrl+C escaped). The count now resets only on stateCompleted — the
  transition that actually made progress. Regression test drives a
  deterministically-empty loader through both rewind paths
  (rewindToDependency and the rewindOne fallback) and asserts the guard
  fires after exactly maxRewindsPerStep+1 loader calls.

- Cancel sentinel contract: Run never returns bare ErrCancelled.
  Ctrl+C returns ErrCancelled wrapping tui.ErrInterrupted, Esc at the
  first step returns ErrCancelled wrapping context.Canceled, so
  cmdutil.IsPromptInterrupt/IsPromptBack/IsPromptCancel classify both.
  main.go now maps IsPromptCancel to a silent exit 0 (before the
  agent-error branch), which also covers loader-time cancels arriving
  as "step %q: context canceled". Real engine/loader failures still
  propagate. errors.Is matrix pinned in cmd/util.

- Inline Validate: step.Validate is wired into the TextInput prompt
  model (renders "✗ err" inline, blocks submit, input preserved —
  textinput.go model support). Other prompt types have no model-level
  hook, so a failed Validate now prints "✗ err" above the re-drawn
  prompt instead of silently redrawing and losing the answer.

- Remove WithExitConfirmation everywhere (house rule wins): option,
  exitConfirm/interruptCancel state and confirmExit machinery deleted
  from the engine, all 8 production call sites cleaned, two
  exit-confirm integration tests dropped (Ctrl+C-now-exits is covered
  by TestIntegration_CtrlC_Exits). The "Exit wizard?" dialog
  contradicted the repo's Ctrl+C-is-terminal rule.

- Spinner/progress Ctrl+C (H/M): the spinner no longer re-raises
  SIGINT into its own process (nondeterministic: could kill the
  process post-teardown or be swallowed while the op ran on). Models
  mark interruption and quit cleanly (final message intact); handles
  expose Interrupted(). cmdutil.WithSpinner/RunWithSpinner take ctx-taking
  closures: the op runs on a derived context the watcher cancels on
  interrupt, so Ctrl+C aborts the guarded work without killing the
  process. Editor/pager: Ctrl+C now returns tui.ErrInterrupted instead
  of collapsing into Esc/context.Canceled, consistent with
  select/confirm/textinput.

- Docs: dropped stale ./pkg/tui/examples/wizard-views reference
  (examples are not vendored), swept exit-confirmation mentions
  (serverless CLAUDE.md, .ai/skills/new-command.md, move_wizard.go).
- Latent races exposed by the new -race gate, fixed at the root:
  pkg/version no longer registers --version into the process-global
  pflag.CommandLine (cobra's updateParentsPflags merges it into every
  executed tree and its lazy sort cache raced across parallel tests);
  resumeFakeAPI in objectstorage tests is now mutex-guarded against the
  upload worker pool.
review H2: http.Client.Timeout covers the entire body read, so it clamped
every request to opts.Timeout (flag values >30s silently capped at the
30s construction-time default) and killed multi-GB transfers mid-body.

- factory: build the shared client without Timeout; dial/TLS bounds stay
  on http.DefaultTransport (per cc: lazy client still caps whole-body
  reads, so drop it entirely rather than making it lazy).
- precondition sweep: vm action GetByID and update --verify now bound
  their fetches with WithTimeout(cmd.Context(), Options().Timeout) --
  they previously relied on the client cap (and verify used
  context.Background(), so Ctrl+C could not abort it). doctor audited:
  checkAPIReachable self-bounds; CheckVersion self-bounds (2s).
- registry: push/copy transfers run on cmd.Context() (Ctrl+C); the
  bubbletea view's cancel now unwinds a WithCancel transfer ctx. Tags /
  Head / dry-run manifest reads keep the --timeout-bound ctx.
- registry copy/delete overwrite confirmations and objectstorage rm/rb/
  abort-uploads/bucket-picker prompts run on cmd.Context() -- prompt
  think-time is never timeout-bounded; post-prompt mutations re-bind a
  fresh ctx so the pause can't drain the delete budget.
- objectstorage sync/mv: mirror cp's data-plane precedent (cmd.Context()
  for transfer bodies); enumeration re-bounds around the list calls.
- contract suite: mockapi gains HangRoute (blocks until client cancel);
  TestTimeoutControlPlaneFailsFast pins the --timeout bound for control
  plane; TestTimeoutTransferNotClamped copies through a source registry
  whose blob pulls outlast --timeout 900ms and must still succeed.
…rage

review H1 (verified live): the SDK retries /oauth2/token form-encoded
when the API rejects the JSON attempt, and --debug printed that body
verbatim -- client_secret=... in clear text exactly when users capture
logs for a bug report.

- redactSensitiveBody picks the redactor by Content-Type:
  application/x-www-form-urlencoded bodies redact the secret form keys
  (client_secret, access_token, refresh_token, id_token, password,
  token); everything else keeps the JSON redactor (harmless no-op on
  non-JSON bytes).
- JSON key list extended with the remaining secret-bearing SDK/API
  fields: secret_access_key, service_account_key,
  value_or_reference_to_secret, jupyter_token.
- unit tests cover both content types, escaped-quote values, spacing
  variants, and content-type dispatch; the contract subtest previously
  skipped for H1 (mock ForceFormTokenFallback) now runs and asserts the
  redacted marker is present, not merely that the secret is absent.
review H3: keychainAuth resolved authn.DefaultAuthKey (index.docker.io)
for ANY source, so (a) private ghcr/ECR/GCR/ACR sources never received
their docker-config credentials and (b) a Docker Hub login was presented
to whatever source registry a copy pulled from -- scope-confusion secret
leak (ggcr's basicTransport attaches the resolved basic credential to
every in-host request, even without an auth challenge).

keychainAuth now carries the parsed source host (already normalized to
index.docker.io for Hub refs by ggcr's parser) and Resolve is keyed on
it; a miss still falls back to anonymous so public pulls keep working.
Both callers (flag path and copy wizard) pass the parsed srcRef through
buildSourceAuth.

Coverage (fails pre-fix, verified by temporarily reverting the Resolve
argument):
- TestCopy_DockerConfigResolvesCredsForSourceHost: a private gated
  source (Basic challenge, exact credential required) copies
  successfully with host-keyed creds; the keychain is only ever
  resolved with the source host.
- TestCopy_DockerConfigNeverSendsHubCredsToForeignHost: with only a Hub
  entry in the keychain, an anonymous public copy succeeds and the
  recorded wire traffic never carries the Hub credential.
- server: lazy client init via sync.Once — mcp-go dispatches tool calls on
  a worker pool, so the check-then-set on Server.client raced on the first
  parallel batch (list_vms + get_balance). Repro TestLazyClientInitConcurrent
  is red under -race pre-fix (16 factory calls), green after; factory errors
  latch (fix credentials, restart server).
- confirm gates mirroring the CLI agent contract: create_vm/create_volume
  (billing) and vm_action shutdown/force_shutdown/hibernate/delete
  (destructive) require confirm:true, else the tool fails with
  CONFIRMATION_REQUIRED in the agent-error JSON envelope, before any API call.
- vm_action honesty: default reports status accepted (was: completed without
  ever looking); wait:true polls via cmdutil.PollInstanceStatus and reports
  completed only when the instance reaches the expected status; failed
  transitions and timeouts are tool errors. delete is never polled.
- wait.go: PollInstanceStatus errors on instance error/not_found (was:
  done+nil, so vm ... --wait exited 0 on failure); PollVolumeStatus stops
  immediately on canceled/deleted/error instead of burning the full timeout.
- strict argument validation: mcp-go performs no schema validation, so all
  handlers type-check args explicitly; wrong types and out-of-set enums fail
  with VALIDATION_ERROR; estimate_cost with an unknown storage_type errors
  listing catalog types instead of pricing it $0; a bare-string ssh_key_ids
  no longer fans out to all account keys; string "500" no longer coerces
  to the 50GB default.
- docs: new cmd/mcp CLAUDE.md+README.md (tool reference incl. confirm
  contract and accepted/completed semantics), agent-errors.md MCP section,
  README MCP safety contract.
- tests: race repro, confirm gates, wait semantics, table-driven strict-arg
  coverage against tests/contract/mockapi (gained a /volume-types route),
  wait.go failure-propagation tests.
- runUpdate now verifies the downloaded ARCHIVE bytes against the
  release's verda_<VER>_SHA256SUMS (goreleaser checksum pipe output,
  exact-name match via new findArchiveChecksum/verifyArchiveChecksum in
  verify.go) before extracting or replacing anything. Hash mismatch,
  sums download failure, or a missing sums asset all abort with the
  binary untouched and the error names --skip-verify as the escape
  hatch. Binary sums (verda_<VER>_binary_SHA256SUMS) stay reserved for
  'update --verify' of an installed binary.
- new --skip-verify flag documents the deliberate bypass.
- agent/json mode: update outcome is now written via WriteStructured
  as updateResult{version, previousVersion, path, updated,
  checksumVerified} instead of plain text; human/table output unchanged.
- scripts/install.sh fetches the same SHA256SUMS and checks only the
  selected asset line (awk exact field match) with sha256sum -c or
  shasum -a 256 -c before installing; mismatch/fetch failure aborts with
  VERDA_INSTALL_SKIP_VERIFY=1 named as the escape hatch. New
  VERDA_INSTALL_BASE_URL override enables local testing.
- tests: update_test.go runs four fixture-server scenarios (verified
  happy path, tampered archive aborts with old binary intact, sums
  endpoint 500 aborts, --skip-verify proceeds incl. JSON contract);
  verify_test.go gains findMatchingChecksum boundary edges,
  findArchiveChecksum and verifyArchiveChecksum units.
- cosign bundle verification of the sums files is documented as the
  next step in cmd/update/CLAUDE.md (sigstore dep footprint), not
  implemented here.
…mantics

- H4: applyTemplate now fills ONLY flags the user did not pass (cobra
  Flags().Changed is the authority, including aliases); the same rule gates
  image/ssh-key/startup-script name resolution. README contract
  '--from gpu-training --location FIN-03' now lands in FIN-03.
- H5: template-wizard location 'None (decide at deploy time)' uses the
  locationDecideLater sentinel so the engine's Default substitution no longer
  persists FIN-01; the Setter translates it to an unset location and the
  saved template stays locationless.
- H6: the contract step filters GetInstancePeriods codes through
  normalizeContract, so undeployable duration-shaped long-term periods are
  never offered (they failed at deploy time).
- H7: single interactive delete always passes an explicit empty volume_ids
  when nothing is selected (nil invoked the API default of deleting the OS
  volume, contradicting the keep-billing warning). Agent single delete
  mirrors the batch contract: --yes required, volumes deleted only with
  --with-volumes, batch-shaped JSON output.
- cc: template hostname patterns keep {location} for re-expansion against
  the effective deploy location picked in the wizard.
- cc/batch-6: agent-mode vm action reports status accepted by default and
  polls via cmdutil.PollInstanceStatus only on explicit --wait (failed
  transitions become errors), matching the MCP vm_action contract.
When ls-uploads adopts a server-side multipart upload (no local checkpoint),
the only guard was that the file covered the pre-tail bytes: a 4 MiB file
adopting a 1-part 5 MiB upload would complete a 5 MiB object from the other
file's bytes — silent corruption (review H9).

When the chosen source file's implied part map is fully covered by the
server-side parts, the tail part size must equal fileSize-(maxPart-1)*partSize;
a mismatch refuses the adoption with a clear error before any mutation.
Part sizes are the only available signal: S3 ListParts exposes multipart
ETags, not content hashes. cp/mv are unaffected — they resume via local
checkpoints already pinned to file size+mtime and never adopt checkpointless
uploads (grep-verified: only resumeServerUpload calls listAllParts outside a
checkpointed resume).
The 'explicitly set' check snapshotted the flag-bound fields before viper/env
resolution, so an explicitly selected profile (--auth.profile / VERDA_PROFILE)
overwrote env-supplied VERDA_CLIENT_ID/SECRET (and VERDA_AUTH_* via viper's
AutomaticEnv binding) with the stored profile's values — silently targeting
the wrong account in CI, contrary to the documented 'flags/env always win'
contract.

The profile now only fills fields still unset after flag > config/viper > env
resolution, uniformly for auto-resolved and explicitly selected profiles. An
explicit profile keeps its remaining jobs: choosing the section, pinning its
verda_base_url, and surfacing profile-load errors.

Matrix tests cover flag > config > env > credentials-file on both profile
selection styles plus the VERDA_AUTH_* spelling. auth/README.md and
docs/commands.md now describe the corrected precedence (the old 'explicit
profile overrides env' example was the bug-as-documentation).
Stream wiring (review MEDIUM 'all standalone prompts render to os.Stdout'):
- The factory built its prompter with tui.Default() and no IO, so a command
  that prompted while stdout was piped sprayed ANSI into the pipe and showed
  nothing to the user. NewFactory now takes the IOStreams and the registered
  Default/DefaultStatus builders honor their ioOpts (previously ignored).
- New bubbletea stream split: prompts/spinner/progress/pager scroller render
  on ErrOut; Table and pager print-through (data) stay on Out, per the
  house rule. Spinner/progress go silent when the output is not a terminal
  instead of writing frames into pipes/capture buffers.
- agentPrompter behavior (INTERACTIVE_PROMPT_BLOCKED fail-fast) unchanged.

Agent error contract (docs/agent-errors.md promised exit 2 for bad input):
- UsageErrorf returns a *UsageError; ClassifyError maps it to VALIDATION_ERROR
  with exit 2, dropping the human --help hint from the envelope. Converted the
  flag-misuse sites: volume delete --status/--all combinations, vm action
  --status/--hostname filters and --with-volumes on non-delete, batch --all
  combinations. Non-agent UX keeps the same message (+ help hint), exit 1.
- Contract suite extended: interactive path with piped stdin fails fast with
  a clean stderr error and byte-empty stdout; agent usage errors assert exit 2
  with the VALIDATION_ERROR envelope.
- tests: options and auth test helpers now use t.TempDir() instead of
  os.MkdirTemp(".") — the worktree had accumulated 296 stale gitignored
  tmp-test-* dirs (removed with this change).
- root CLAUDE.md: correct the three 'make test includes lint' claims (reality:
  make test = go test -race; make lint is separate; pre-commit runs both) and
  add the missing per-command doc rows (objectstorage, serverless, doctor) to
  the index table. AGENTS.md's done-checklist line said the same — fixed.
- root README.md: 'verda update --version v1.0.0' -> '--target v1.0.0'
  (--version is the print-version flag).
- CI pins: aquasecurity/trivy-action@master -> v0.36.0 (latest release),
  pip install git-cliff -> ==2.13.1 (changelog + release workflows),
  goimports@latest -> golang.org/x/tools v0.48.0 (ci.yml).
- goimports local-prefix typo (github/verda-cloud/verda-cli, missing .com):
  deliberately deferred — fixing it regroups imports in ~100 files; one-line
  deferral comment in .golangci.yaml and Makefile instead.
CI's GoSec lane runs gosec with --no-config (test files included) and
flagged the new test fixtures/harness; CI lint (pinned golangci-lint
v2.5.0) caught a goconst the local v2.11 didn't:
- #nosec annotations with justification on fixture reads/writes
  (update_test, wizard_template_test) and the contract TestMain
  subprocess launches (binary path is harness-built under t.TempDir)
- create.go validateKind: reuse kindCPU/kindGPU constants
CI pins golangci-lint v2.5.0; its gosec reports G204 at the launch
statement (CombinedOutput/Run), not only at exec construction like the
local v2.11 toolchain. Annotate both generations' sites.
… 1.1.0

- agent wait semantics documented: create/action return accepted after
  issuance; --wait polls to completed (with failure surfacing)
- ssh-key delete, startup-script delete, volume create rows now show
  the --yes agent requirement (were: 'confirm first' / ungated docs)
- add missing volume delete row (direct command, --yes gated)
- manifest version 1.0.0 -> 1.1.0 so 'verda skills status' flags
  update_available to previously-installed agents
All three follow the Agent Skills directory convention
(<name>/SKILL.md under a per-agent global dir), matching our existing
verda-cloud + verda-reference frontmatter (OpenCode-validated:
lowercase-hyphen name matching the directory):

- kimi-code -> ~/.kimi-code/skills/ (also honored at )
- opencode  -> ~/.config/opencode/skills/
- pi        -> ~/.pi/agent/skills/

Manifest 1.1.0 -> 1.2.0 so skills status flags the update. Verified live:
'verda skills install kimi-code --force' installs v1.2.0 and both
SKILL.md files land with frontmatter intact.
@hi-lei
hi-lei merged commit 592e334 into main Aug 9, 2026
12 checks passed
@hi-lei
hi-lei deleted the feat/arch-hardening branch August 9, 2026 17:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant