fix(ssh): resolve bare-string host selectors by name then tag (#485) #15

Merged
stack72 merged 1 commit from fix/485-ssh-host-selector into main 2026-05-29 14:43:23 +00:00
Owner

Summary

Fixes #485. The @swamp/ssh hosts selector treated every bare string as a CEL expression, so a plain host name or tag silently matched nothing and tag:x was a CEL parse error:

swamp model method run fleet exec --input hosts=controlplane-fsn1-0 ...  # ❌ Selector matched no hosts
swamp model method run fleet exec --input hosts=controlplane ...         # ❌ Selector matched no hosts
swamp model method run fleet exec --input hosts=tag:controlplane ...     # ❌ Invalid selector expression: Unexpected character: ':'

Approach

Stop treating unmarked strings as code — the root cause. New selector grammar:

Form Meaning
all every host (unchanged)
[a, b] exact name list (unchanged)
name:<host> exact host name
tag:<tag> hosts carrying the tag
cel:<expr> CEL predicate (explicit)
bare <str> exact host name, then tag

A bare CEL expression (no cel: prefix) still resolves but is deprecated — it logs an agent-readable warning naming the exact cel: form and will error in a future version. Plain-identifier no-matches throw a precise, copy-pasteable error:

Selector 'ghost' matched no host by name or tag. Use hosts=name:ghost,
hosts=tag:ghost, hosts:json=["ghost"], or a predicate hosts='cel:...'.

The fix lands at the single resolveSelection funnel, so it covers all seven selector-taking methods (exec/script/copy/open/check/close/forward).

Changes

  • _lib/selectors.tsparseSelector (prefix classification), bare name→tag resolution, pinned looksLikeCel heuristic, deprecation warning. selectHosts keeps its EffectiveHost[] return.
  • _lib/operations.tsresolveSelection exported + form-aware no-match error.
  • _lib/schemas.ts — agent-facing .describe() on the selector (the primary machine-readable contract).
  • ssh.ts — model version bump to 2026.05.29.1 with a matching upgrades entry (identity — no globalArguments schema change).
  • manifest.yaml — version + description.
  • README.md — every selector teaching site updated.
  • New tests: selectors_485_test.ts (regression guard, written red-first), operations_test.ts (diagnostics via a structural fake), plus prefix/bare/collision/heuristic/deprecation cases in selectors_test.ts; existing bare-CEL cases migrated to cel:.

Verification

  • Reproduction baseline: selectors_485_test.ts was committed red (6/6 failing) before the fix and is now green.
  • Full gate (from ssh/): deno check / lint / fmt --check clean, 204 tests pass, deno install --frozen clean.
  • Live end-to-end against the real swamp CLI via swamp extension source add (local source in a throwaway repo):
    • old published behavior reproduced (all three failure modes);
    • new behavior correct for bare name, bare tag, name:/tag:/cel: prefixes, and the precise no-match error;
    • old→new upgrade auto-migrates an instance pinned at 2026.05.19.1 up to 2026.05.29.1 on first run (upgrades entry fires; globalArgs preserved; validate 5/5);
    • the legacy bare-CEL deprecation warning surfaces as a WRN line with a clean, copy-pasteable cel: suggestion.

Notes

  • Breaking-ish: a bare CEL selector now warns (still works) and will error in a future major. Migration is the cel: prefix, named in the warning text.
  • The looksLikeCel heuristic is structurally safe: a valid host name (HostNamePattern) can never contain a CEL-trigger character, so names are never misread as CEL.

🤖 Generated with Claude Code

## Summary Fixes #485. The `@swamp/ssh` `hosts` selector treated **every bare string as a CEL expression**, so a plain host name or tag silently matched nothing and `tag:x` was a CEL parse error: ```bash swamp model method run fleet exec --input hosts=controlplane-fsn1-0 ... # ❌ Selector matched no hosts swamp model method run fleet exec --input hosts=controlplane ... # ❌ Selector matched no hosts swamp model method run fleet exec --input hosts=tag:controlplane ... # ❌ Invalid selector expression: Unexpected character: ':' ``` ## Approach Stop treating unmarked strings as code — the root cause. New selector grammar: | Form | Meaning | |------|---------| | `all` | every host *(unchanged)* | | `[a, b]` | exact name list *(unchanged)* | | `name:<host>` | exact host name | | `tag:<tag>` | hosts carrying the tag | | `cel:<expr>` | CEL predicate (explicit) | | bare `<str>` | **exact host name, then tag** | A bare **CEL** expression (no `cel:` prefix) still resolves but is **deprecated** — it logs an agent-readable warning naming the exact `cel:` form and will error in a future version. Plain-identifier no-matches throw a precise, copy-pasteable error: ``` Selector 'ghost' matched no host by name or tag. Use hosts=name:ghost, hosts=tag:ghost, hosts:json=["ghost"], or a predicate hosts='cel:...'. ``` The fix lands at the single `resolveSelection` funnel, so it covers all seven selector-taking methods (`exec`/`script`/`copy`/`open`/`check`/`close`/`forward`). ## Changes - `_lib/selectors.ts` — `parseSelector` (prefix classification), bare name→tag resolution, pinned `looksLikeCel` heuristic, deprecation warning. `selectHosts` keeps its `EffectiveHost[]` return. - `_lib/operations.ts` — `resolveSelection` exported + form-aware no-match error. - `_lib/schemas.ts` — agent-facing `.describe()` on the selector (the primary machine-readable contract). - `ssh.ts` — model `version` bump to `2026.05.29.1` **with a matching `upgrades` entry** (identity — no globalArguments schema change). - `manifest.yaml` — version + description. - `README.md` — every selector teaching site updated. - New tests: `selectors_485_test.ts` (regression guard, written red-first), `operations_test.ts` (diagnostics via a structural fake), plus prefix/bare/collision/heuristic/deprecation cases in `selectors_test.ts`; existing bare-CEL cases migrated to `cel:`. ## Verification - **Reproduction baseline**: `selectors_485_test.ts` was committed red (6/6 failing) before the fix and is now green. - **Full gate** (from `ssh/`): `deno check` / `lint` / `fmt --check` clean, **204 tests pass**, `deno install --frozen` clean. - **Live end-to-end** against the real `swamp` CLI via `swamp extension source add` (local source in a throwaway repo): - old published behavior reproduced (all three failure modes); - new behavior correct for bare name, bare tag, `name:`/`tag:`/`cel:` prefixes, and the precise no-match error; - **old→new upgrade auto-migrates** an instance pinned at `2026.05.19.1` up to `2026.05.29.1` on first run (`upgrades` entry fires; globalArgs preserved; `validate` 5/5); - the legacy bare-CEL **deprecation warning surfaces** as a `WRN` line with a clean, copy-pasteable `cel:` suggestion. ## Notes - **Breaking-ish**: a bare CEL selector now warns (still works) and will error in a future major. Migration is the `cel:` prefix, named in the warning text. - The `looksLikeCel` heuristic is structurally safe: a valid host name (`HostNamePattern`) can never contain a CEL-trigger character, so names are never misread as CEL. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(ssh): resolve bare-string host selectors by name then tag (#485)
All checks were successful
CI / workflows/s3-bootstrap - lockfile up to date (pull_request) Has been skipped
CI / cve/dirtyfrag - fmt (pull_request) Has been skipped
CI / cve/mini-shai-hulud - check (pull_request) Has been skipped
CI / cve/dirtyfrag - lint (pull_request) Has been skipped
CI / cve/dirtyfrag - test (pull_request) Has been skipped
CI / cve/mini-shai-hulud - fmt (pull_request) Has been skipped
CI / cve/mini-shai-hulud - lint (pull_request) Has been skipped
CI / cve/mini-shai-hulud - test (pull_request) Has been skipped
CI / ssh - lockfile up to date (pull_request) Successful in 1m9s
CI / ssh - lint (pull_request) Successful in 1m11s
CI / ssh - fmt (pull_request) Successful in 1m12s
CI / ssh - check (pull_request) Successful in 1m12s
CI / cve/dirtyfrag - lockfile up to date (pull_request) Has been skipped
CI / cve/mini-shai-hulud - lockfile up to date (pull_request) Has been skipped
CI / ssh - test (pull_request) Successful in 1m20s
CI / model/digitalocean - check (pull_request) Has been skipped
CI / aws models - lockfiles up to date (pull_request) Has been skipped
CI / model/hetzner-cloud - check (pull_request) Has been skipped
CI / gcp models - sample check (pull_request) Has been skipped
CI / gcp models - lockfiles up to date (pull_request) Has been skipped
CI / cloudflare models - sample check (pull_request) Has been skipped
CI / cloudflare models - lockfiles up to date (pull_request) Has been skipped
CI / codegen - check (pull_request) Has been skipped
CI / codegen - lint (pull_request) Has been skipped
CI / codegen - lockfile up to date (pull_request) Has been skipped
CI / Dependency Audit (pull_request) Successful in 8m1s
CI / Adversarial Code Review (pull_request) Has been skipped
CI / CI Security Review (pull_request) Has been skipped
CI / Claude Code Review (pull_request) Successful in 2m51s
CI / Merge Gate (pull_request) Successful in 30s
e859808b77
The `hosts` selector treated every bare string as a CEL expression, so a
plain host name or tag (`controlplane`) silently matched nothing and
`tag:controlplane` was a CEL parse error.

Stop treating unmarked strings as code:
- bare string resolves to an exact host name, then a tag
- add explicit `name:` / `tag:` / `cel:` prefixes
- a bare CEL expression still resolves but is deprecated — it logs an
  agent-readable warning naming the `cel:` form and will error later
- plain-identifier no-matches throw a precise "no host named or tagged X —
  use name:/tag:/cel:" error instead of the generic message

The fix lands at the single `resolveSelection` funnel, covering all seven
selector-taking methods. Adds an agent-facing `.describe()` on the selector
schema, a model version bump with a matching `upgrades` entry (identity —
no globalArguments change), and README updates at every selector site.

Verified end-to-end against the real swamp CLI via a local extension
source: old→new upgrade auto-migrates instances 2026.05.19.1 → 2026.05.29.1
and the deprecation warning surfaces on legacy bare-CEL input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Owner

Code Review

Blocking Issues

None.

Suggestions

  1. manifest.yaml usage example uses the deprecated bare-CEL form. The embedded code block uses --input hosts='"prod" in host.tags' (no cel: prefix), which will generate a deprecation warning when users copy-paste it. Update to --input hosts='cel:"prod" in host.tags' to stay consistent with README and the new preferred form. Same file's "Highlights" bullet still describes only three forms ("all", name list, bare CEL) and doesn't mention name: / tag: / cel: at all — minor, but the manifest description is the first thing shown by swamp extension info.

  2. No-op try/catch in compile() (selectors.ts:127–135). The inner try-catch only re-throws, which is identical to no try-catch. The comment is accurate but the wrapper adds no value and slightly obscures intent. The actual soft-miss handling lives in evalCelSelector's catch. Could be collapsed to just return result === true.


The fix is correct and well-scoped. selectHosts now resolves bare strings as name-first-then-tag with a deprecated fallback to CEL when the string looksLikeCel, exactly matching the commit description and README. The noMatchMessage wording in operations.ts mirrors the resolution order precisely, which is important for agent-readable diagnostics. Regression suite in selectors_485_test.ts covers the exact fleet from the issue report, and selectors_test.ts has thorough coverage of all new prefix forms, collision precedence, and the legacy deprecation warning path.

## Code Review ### Blocking Issues None. ### Suggestions 1. **`manifest.yaml` usage example uses the deprecated bare-CEL form.** The embedded code block uses `--input hosts='"prod" in host.tags'` (no `cel:` prefix), which will generate a deprecation warning when users copy-paste it. Update to `--input hosts='cel:"prod" in host.tags'` to stay consistent with README and the new preferred form. Same file's "Highlights" bullet still describes only three forms (`"all"`, name list, bare CEL) and doesn't mention `name:` / `tag:` / `cel:` at all — minor, but the manifest description is the first thing shown by `swamp extension info`. 2. **No-op try/catch in `compile()` (`selectors.ts:127–135`).** The inner try-catch only re-throws, which is identical to no try-catch. The comment is accurate but the wrapper adds no value and slightly obscures intent. The actual soft-miss handling lives in `evalCelSelector`'s catch. Could be collapsed to just `return result === true`. --- The fix is correct and well-scoped. `selectHosts` now resolves bare strings as name-first-then-tag with a deprecated fallback to CEL when the string `looksLikeCel`, exactly matching the commit description and README. The `noMatchMessage` wording in `operations.ts` mirrors the resolution order precisely, which is important for agent-readable diagnostics. Regression suite in `selectors_485_test.ts` covers the exact fleet from the issue report, and `selectors_test.ts` has thorough coverage of all new prefix forms, collision precedence, and the legacy deprecation warning path.
stack72 deleted branch fix/485-ssh-host-selector 2026-05-29 14:43:23 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
swamp-club/swamp-extensions!15
No description provided.