Skip to content

Hotfix/v1.8.0 - #58

Merged
hi-lei merged 18 commits into
mainfrom
hotfix/v1.8.0
Aug 13, 2026
Merged

Hotfix/v1.8.0#58
hi-lei merged 18 commits into
mainfrom
hotfix/v1.8.0

Conversation

@hi-lei

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

Copy link
Copy Markdown
Collaborator

Description

Type of Change

  • feat: New feature
  • fix: Bug fix
  • refactor: Code refactoring
  • docs: Documentation
  • test: Tests
  • chore: Maintenance
  • ci: CI/CD changes

Checklist

  • I have performed a self-review of my own code
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally (make test)
  • Pre-commit checks pass (make pre-commit)
  • My changes generate no new warnings or errors

hi-lei and others added 18 commits August 12, 2026 15:05
The API omits created_at on /v1/ssh-keys and sends fingerprint: null.
Marshaling the SDK struct turned those into 0001-01-01T00:00:00Z and "",
so an age-based reaper ("older than 2h => delete") read every key as
ancient and deleted keys belonging to running jobs.

Add CLI-owned view types that map a zero time.Time to nil so omitempty
drops the field, and render absent values as "-" in table output. One
shared type backs both the CLI and the MCP tools, so the two JSON
contracts cannot drift.

Absent is safe; wrong is dangerous. This hides the damage — the API gap
itself still needs a backend ticket.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing in the tree ever read VERDA_S3_*: the only credential source was
the INI file, so CI could not use object storage without writing secrets
to disk. API auth already honored VERDA_CLIENT_ID/_SECRET, which made
the documented precedence true for API auth and false for S3.

Add options.ResolveS3Credentials: load the profile, then overlay
VERDA_S3_ACCESS_KEY/_SECRET_KEY/_ENDPOINT/_REGION/_AUTH_MODE per field.
Flags still win (NewClient's firstNonEmpty), so the order is
flags > env > file. The merge is per field rather than wholesale, so one
variable cannot discard a working profile, and an empty variable counts
as unset. A missing file or unresolvable path (no HOME in a container)
stops being fatal once env alone is complete; HasCredentials() still
gates, so a partial set fails loudly.

show uses the same resolver -- otherwise it would report "not
configured" for an env-only setup that ls handles fine -- and names the
applied variables under env_overrides:, never their values.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vm delete and volume delete both take an optional positional id plus
--id, but ssh-key delete and startup-script delete were cobra.NoArgs, so
`verda ssh-key delete <uuid>` failed with `unknown command`. Scripts had
to special-case these two commands.

Switch both to MaximumNArgs(1) and copy the established shape. --id
keeps working unchanged. Passing both an argument and --id is a usage
error rather than a silent pick — vm's shortcut lets the positional
overwrite --id, which hides a typo that targets the wrong resource.

The agent-mode --yes guard still fires before any API call, whichever
way the id arrived.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pre-existing violation, not introduced here: "delete" appears 12 times in
cmd/vm (1 in batch.go, 11 in tests). goconst counts across the package
but only reports on non-test files, so it lands on batch.go:278.
golangci-lint's cache had been hiding it; any edit to the package
surfaces it and fails make lint.

Reported separately from the help-text change it blocked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Text only, no behavior change.

instance-types said "List all available instance types", which reads as
live stock and is the reading users act on. It is a catalog; point at
verda availability for what can actually be deployed.

--os-volume-name and --os-volume-on-spot-discontinue now state that they
require --os-volume-size (enforced at create.go:351, previously only
discoverable by hitting the error).

--description states the 100-character cap, which until now surfaced
only as an API 400. No client-side validation added on purpose: a
client-side cap that drifts from the API is worse than the 400.

--with-volumes states that without it the OS volume survives detached
and keeps billing. Updated in both places it is defined (shortcuts.go
and action.go) so the two surfaces cannot disagree.

Also documents the new positional delete id in the ssh-key and
startup-script READMEs, completing aa68b72.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Coverage audit of the four fixes found three gaps, all closed here:

object-storage show read credentials through its own call site, and no
test exercised the env path there — an env-only setup reporting
"not configured" would have gone unnoticed. Three tests: env-only is
reported configured, env_overrides names variables and never values
(with per-field merge asserted), and nothing-configured still says so.

Every S3 verb (ls/cp/mv/sync/rm/mb/rb/presign) shares
buildClientDefault, but every existing test swaps in a fake client, so
the real builder's env path was never executed. Two tests call it
directly: env-only credentials build a client, and no credentials keep
the configure hint.

MCP add_ssh_key returns the created key to the agent — the second place
a zero timestamp reaches a reaper — and only list_ssh_keys was covered.

Verified non-vacuous by mutation: disabling the env overlay fails 10
tests (4 new), and making the view emit a zero time fails 7 across
cmd/util, cmd/mcp and cmd/sshkey, including the new add test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same defect as be842d4, found by auditing the surfaces rather than the
diff, and worse: /v1/instances omits created_at too (the captured staging
payload temp/docs/c1-ondemand-instance.json has no time-valued key at
all), and verda.Instance.CreatedAt has no omitempty. So `vm list -o json`
dated every instance to 0001-01-01, and an age-based reaper reading that
deletes running instances, not SSH keys.

Six surfaces, all agent-facing: vm list, vm describe, vm create --agent,
MCP list_vms, MCP describe_vm, and serverless batchjob list (hidden
feature; fixed so it does not ship with the defect).

InstanceView mirrors all 24 verda.Instance fields explicitly rather than
embedding the SDK struct: embedding plus a shadowed CreatedAt is correct
in JSON but leaks `createdat: 0001-01-01T00:00:00Z` in YAML, because
yaml.v3 inlines the embedded struct and ignores json tags. Verified both
ways before choosing. TestInstanceViewCoversSDKFields compares json tag
sets by reflection so the SDK cannot grow a field the view silently
drops.

Tests fail before and pass after: 8 tests across cmd/util, cmd/vm,
cmd/mcp and cmd/serverless catch a mutation that makes the view emit the
zero time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TestInstanceViewCoversSDKFields compares json tag names, which cannot see
a field InstanceView declares but NewInstanceView never assigns: it
marshals as a zero value and the guard passes — the very defect class
these views exist to prevent, and exactly where a hand-mirrored 24-field
constructor goes wrong.

Fill every verda.Instance field with a distinctive non-zero value by
reflection, marshal SDK struct and view, compare the decoded maps
key-by-key. Plus a zero-instance check that created_at is the only key
dropped, so no other field can silently leave the agent contract.

Verified necessary, not redundant: deleting the Hostname assignment from
NewInstanceView fails TestInstanceViewCopiesEveryValue with
`key "hostname": view has "", SDK has "s21"` while the tag-name guard
still passes.

Hole identified by session 862d36ab's cross-check of c27e9df; probes
adopted as permanent tests and credited in the source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reported 100-character cap is not real. Verified live against
staging: `vm create --description <101 chars>` was accepted and the full
101-character value came back in the instance payload. My earlier help
text stated a limit the API does not enforce, which is worse than saying
nothing — it would push users to truncate valid input.

Reverts that clause from 03d45e5 and records the measurement inline so
nobody re-adds it from the original report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… 400

F3 exposed a structural problem, not a missing special case. The agent
error contract existed twice: cmdutil.AgentError + ClassifyError on the
CLI side, and a private argError twin in the MCP server whose renderer
only understood its own type. Every API failure therefore reached agents
over MCP as a bare string with no code, while docs/agent-errors.md
claimed MCP "reuses this contract".

Collapse it onto one type. MCP's argument-error constructors now return
*cmdutil.AgentError (keeping MCP's "argument" wording), and
toolErrorResult renders every failure through ClassifyError. An API 404
now reaches an agent as NOT_FOUND over MCP exactly as on the CLI, and a
code added to the classifier appears on both surfaces without touching
either renderer.

With one funnel, the ssh-key case is a mapping rather than a hook:
classifyAPIError recognizes the 400 whose text cannot be shown to a user.
POST /instances rejects a request that omits ssh_key_ids with "SSH keys
can be an array of UUID's, a single UUID string, null value or not
defined" -- while the field was not defined. Verified live on staging
with the request body captured via --debug: no ssh_key_ids was sent, so
the CLI was right and the server contradicts itself. Users now get
SSH_KEY_REQUIRED, exit 2, naming the flag and how to list ids, with the
server's wording preserved verbatim in details.api_message.

Live: `vm create` without --ssh-key exits 2 with the new envelope; no
resource created. Also fixes two tests that passed silently when their
errors.As assertion failed.

Contract change: MCP tool errors that used to be plain text now carry the
envelope. Documented, with the remaining 45 handler call sites that
bypass toolErrorResult tracked as follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Non-agent mode read err.Error() directly, so the classifier's work only
reached --agent callers: a human running `vm create` without --ssh-key
still saw the API's self-contradictory "SSH keys can be ... null value or
not defined", while an agent got SSH_KEY_REQUIRED plus remediation. Now
main classifies once and renders twice — envelope for agents, the same
classified message as text for humans. Verified live: the human path
prints the actionable message.

Human failures stay exit 1. Routing ae.ExitCode there would give scripts
2/3/4/5/6 without --agent, which is the better interface but changes what
`$?` means for existing callers; that is a separate decision.

MCP now ships server instructions in the initialize response, so every
client learns the confirm gate, the error envelope and the accepted vs
completed rule before its first tool call, with no client-side change.
This is the answer to "how do callers learn the new error shape": they
are told, rather than asked to upgrade. Old consumers keep working — the
envelope contains the same human message and isError is unchanged.

Also tightens the comments added across this branch to the repo's style
(design and invariants, one line, no change history — that belongs here),
and drops a test comment repeating the claim that /v1/instances omits
created_at, which live verification disproved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pager launched an alt-screen Bubble Tea program regardless of
destination, so `verda volume trash | head` blocked forever waiting for
keys nobody could send. terminalHeight falls back to 24 for a pipe, which
makes the trap data-dependent: a short trash list took the print-through
path and looked fine, while a full one hung. Automation without --agent
was the only victim, since agent mode leaves Status nil and never reaches
the pager.

Guard on rendersToTerminal and print through, matching Spinner and
progress, which already do exactly this. volume trash is the only caller
today, and the wizard docs advertise Pager to future ones.

Test fails before by timing out after 5s and passes instantly after;
live, the piped repro now returns the listing immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
volume trash never consulted f.OutputFormat() or f.AgentMode(), so
`--agent -o json` returned an ANSI-styled table: 40 escape-carrying lines
into a redirected stdout. Checking the package turned up the same two
defects spread wider than the reported finding.

Zero-timestamp class, as in be842d4 and c27e9df: Volume.CreatedAt,
VolumeInTrash.CreatedAt and DeletedAt carry no omitempty, and four sites
marshal those types raw (list, describe, create, and trash which had no
structured branch at all). DeletedAt is the one that matters most — it
drives the 96-hour recovery countdown, so a fabricated date misreports how
long a volume can still be restored. trash.go already guarded the expiry
calculation with !DeletedAt.IsZero() while formatting the date
unconditionally two lines above.

Add VolumeView and VolumeInTrashView with both guards that earned their
keep on InstanceView: json-tag drift and value-copy by reflection. Wire
all four sites, render an absent timestamp as "-" via TimeColumn, and gate
styling on the expression already used in cp.go, container_list.go and
vm/list.go rather than inventing a second one. renderVolumeSummary gets
the same gate, since describe and create share it.

First trash_test.go in the package. All four tests fail under mutation
(structured branch removed, styling forced on). Live: `volume trash
--agent -o json` is valid JSON with 0 ANSI lines, table mode redirected is
also 0, both exit 0.

Closes F2 (order 007). volume trash has no restore path in the CLI or the
SDK, so being read by a human or a script is its only purpose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dashboard is the billing source of truth. The CLI aligns with catalog
prices so planning matches, but it claimed more than that: `verda cost`
advertised "billing", `cost running` said it would "calculate the cost"
of running instances, and the summary line read "Total Burn" as though it
were a charge. That total is a plain sum of catalog price_per_hour plus
volume base_hourly_cost — it cannot see credits, discounts, contract terms
or partial hours, so any of those makes it disagree with the console, and a
CLI that disagrees about money reads as a billing bug.

Reword to estimates, point at the dashboard as the authority, and label
the summary "Est. Burn" with a one-line footer. No arithmetic changed.

Test pins the wording, since positioning text regresses silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keeps the prices, including the trashed-volume rate, but never lets them
read as the amount charged. cmdutil.PriceDisclaimer is a single constant so
the wording cannot drift between surfaces, and callers style it — cmdutil
stays presentation-free.

Applied where a price appears: cost estimate, cost running, instance-types,
volume create, volume trash, and the vm create summary, which is the moment
a user commits to spend. Structured output is untouched; the disclaimer is
human-facing, and agents get it through the MCP instructions instead.

Those instructions gain a PRICING paragraph, because that is where the risk
of a wrong number actually lives: a model relaying figures can misread or
miscalculate them. It tells the agent never to present a figure as the
amount charged, to say so when it sums or converts, and to point at the web
console.

The CLI's own figures are catalog data plus arithmetic, not model output, so
the disclaimer says "may be inaccurate" rather than claiming they are
AI-generated — a false statement there would undercut the warning it makes.

Terminology follows the web console, matching how billing authority is
described. Older strings still say "Verda dashboard" for where to create
access keys; unifying those is a separate pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…utput

Two elements are load-bearing — it is an estimate, and the web console
decides charges — so the rest went: 107 characters down to 57. It sits under
tables and summaries, where a long line competes with the numbers it
qualifies.

Tests now assert the disclaimer is absent from JSON, in cost estimate and
volume trash, so nobody adds it to the machine contract later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Issue #4 was closed against `verda locations`, which builds its table with
plain Fprintf and could never have shown the defect. Measured on the built
binary redirected to a file: instance-types 68 escape-carrying lines, status
12, cost estimate 11, availability 7, cost balance 1. Structurally, 21 files
style output on the data stream with no terminal check, so `verda
instance-types | jq` and `> file` both received escapes.

The per-command gate would have been 21 edits against a defect that is not
per command. lipgloss v2 emits escapes from Style.Render unconditionally —
a Style cannot know its destination — and delegates stripping to a writer.
So wrap Out and ErrOut once in NewStdIOStreams with colorprofile.NewWriter,
which detects what the destination can display and downsamples or strips to
match. Existing IsStdoutTerminal checks still choose layout; this decides
color. Tests build IOStreams directly and keep raw buffers, so none of them
change meaning.

Verified live: those five commands now write 0 escapes with content intact.
The color-preserved direction has no PTY in this sandbox, so it is covered by
a test that forces a color-capable profile instead.

Hardening, since one wrap is easy to undo by accident:
tests/contract/ansi_purity_test.go runs the real binary with stdout piped
across 11 table-rendering commands plus 4 -o json paths, asserting no
escapes, a zero exit and non-empty output so a failed run cannot pass
vacuously. Forcing TrueColor onto the pipe fails 7 of them — the exact
commands that were leaking. cmd/util/iostreams_test.go pins the wiring
itself, both profile directions, and the premise that Render always styles.

colorprofile moves from indirect to direct in go.mod: same v0.4.2 already in
the tree via lipgloss, no new module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fillNonZero increments the counter on entry, so *n >= 1 at the uint
branch and the int -> uint64 conversion cannot wrap. gosec (G115) cannot
see that, and `make security` mirrors the CI gate, so the finding would
fail the release PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hi-lei
hi-lei merged commit 6ecf036 into main Aug 13, 2026
12 checks passed
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