feat(ssh): add identityContent for vault-resolved private keys (#522) #23
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "worktree-522"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Add an optional
identityContentfield to@swamp/sshtransport auth that accepts inline PEM private key content (e.g. fromvault.get()), enabling vault-stored SSH keys to work with@swamp/sshwithout external scripting or pre-loaded agents.identityContentis set, the extension writes the PEM body to a temp file (mode0600), passes it as-i <tmpfile>, and removes the file in afinallyblockidentityFile— setting both is a validation error, setting neither uses SSH agent (unchanged behavior)z.meta({ sensitive: true })so swamp redacts it from logs/reportsexec,script,copy, andopenmethods;forwardexcluded (operates via ControlMaster, never passes-i)Why this is correct
The problem:
vault.get()resolves to PEM key content, not a file path.identityFileexpects a path. Users storing SSH keys in vaults (1Password, HashiCorp Vault, AWS SM) had to either pre-loadssh-agent(fragile in CI) or write a custom extension model duplicating@swamp/ssh's execution logic.The fix: A new field that accepts the content directly. The temp-file materialization happens in the operations layer (above the runner), so the runner never sees
identityContent— it only sees the resolvedidentityFilepath. Clean layering, no runner changes needed.Key design decisions:
.refine()lives onTransportSchema(the discriminated union), not onSshTransportSchema(the member) — putting it on the member would break Zod's discriminated union introspectionTransportOverrideSchemaalso validates mutual exclusion so per-host overrides can't set bothDeno.makeTempFilewith aswamp-ssh-key-prefix, cleaned up infinallyeven whenthrowOnHostFailuresfiresChanges
schemas.tsidentityContentonSshTransportSchema+TransportOverrideSchema,.meta({ sensitive: true }), mutual-exclusion refinehosts.tsidentityContentinmergeSshTransportviapick()operations.tsmaterializeTempKeys/cleanupTempKeyshelpers, applied to exec/script/copy/open withfinallycleanupssh.ts2026.06.01.2, upgrade entrymanifest.yaml2026.06.01.2, updated auth modes descriptionREADME.mdschemas_test.tshosts_test.tsssh_test.tsTest plan
runner_spawn_test.tsfailures unrelated to this change)deno check,deno lint,deno fmt,deno install --frozenall cleanecho hello-from-identityContent→ exit 0), script (VAL=42; echo value=$VAL→ exit 0), copy (scp + cat round-trip → content matches), temp file cleanup (noswamp-ssh-key-*files remain)Closes #522
Co-Authored-By: Blake Irvin blakeirvin@me.com
Add an optional `identityContent` field to `@swamp/ssh` transport auth that accepts inline PEM private key content (e.g. from `vault.get()`). When set, the extension writes the PEM body to a temporary file (mode 0600), passes it as `-i <tmpfile>` for the SSH session, and removes the file in a `finally` block when the method completes. This enables users who store SSH private keys in a vault (1Password, HashiCorp Vault, AWS Secrets Manager, etc.) to use `@swamp/ssh` directly without writing the key to disk externally or pre-loading an ssh-agent — both of which break the self-contained model principle. `identityFile` and `identityContent` are mutually exclusive. Setting neither is fine (SSH agent auth). The field is marked with `z.meta({ sensitive: true })` so swamp redacts it from logs and reports. Temp-key materialization is applied to exec, script, copy, and open. The forward method is excluded — it operates via an already-open ControlMaster socket and never passes `-i`. Verified end-to-end against a real openssh-server in Docker: exec, script, and copy all succeed with a vault-style PEM key, and temp files are cleaned up after each method returns. Co-Authored-By: Blake Irvin <blakeirvin@me.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>Code Review
Blocking Issues
Temp key files orphaned on partial
materializeTempKeysfailure (ssh/extensions/models/_lib/operations.ts:310-314)tempPaths.set(host.name, tmpFile)is called afterDeno.writeTextFile. IfwriteTextFilethrows (e.g. disk full), the created temp file is never registered intempPaths, socleanupTempKeyswill never remove it. If the failure occurs on host N, temp files for hosts 1 through N-1 are already intempPathsbut are also never cleaned up:materializeTempKeysthrows before returning, the destructuring assignment in the caller never completes, and thetry/finallyblock that callscleanupTempKeysis never entered. All orphaned files may contain private key material.Fix: move
tempPaths.set(host.name, tmpFile)to beforeDeno.writeTextFile, and add a catch block insidematerializeTempKeysthat callscleanupTempKeys(tempPaths)then re-throws on failure.Suggestions
sanitizeResources: falsewithout an explanatory comment (ssh/extensions/models/ssh_test.ts:387,421,452,486,515,578,630)CLAUDE.md requires a comment alongside
sanitizeResources: false. All sevenidentityContenttests use the flag without explanation. A brief inline comment would satisfy the project rule.Everything else looks correct:
identityContentmarked.meta({ sensitive: true })so swamp redacts it from logs.mode: 0o600.identityContentcleared toundefinedin the patched host before reaching argv builders -- no key material in recordedargv.cleanupTempKeyscalled infinallyin all four methods that materialize keys (exec, script, copy, open).check,close, andforwardcorrectly skip materialization.identityFile/identityContentin bothTransportSchemaandTransportOverrideSchema.anytypes in hand-written code; all named exports; no default exports.model/gcp/are auto-generated output (codegen/ changes are present in the same commit).openControlMaster path.Code Review
Blocking Issues
.tsfiles changed without correspondingcodegen/changes.The CHANGED FILES list includes nine
model/gcp/compute/extensions/models/*.tsfiles(
backendservices.ts,forwardingrules.ts,globalforwardingrules.ts,instances.ts,regionbackendservices.ts,regionzones.ts,rollouts.ts,routers.ts,zones.ts) andmodel/gcp/vault/extensions/models/matters_exports.ts, alongside their manifests — but nocodegen/files appear in the CHANGED FILES list. Per CLAUDE.md, files undermodel/areauto-generated and must never be hand-edited. Two legitimate explanations exist: (a) these are
purely version/upgrade-entry bumps produced by the
bump-versionsscript, in which case the.tsbodies contain only version and upgrade changes, or (b) these came from a codegenregeneration run, in which case the missing
codegen/diff is a problem. If the.tschangescontain anything beyond
version/upgrades/manifest entries without a correspondingcodegen/change, this is a blocking CLAUDE.md violation. Verify that the GCP model.tschanges are limited to version/upgrade entries (consistent with a
bump-versionsrun) beforemerging.
Suggestions
ForwardSpecPatternallows out-of-range port numbers.schemas.ts:275:const ForwardSpecPattern = /^\d{1,5}:[^:]+:\d{1,5}$/permits values up to99999 for both local and remote ports, which exceeds the maximum valid port of 65535. OpenSSH
will reject the value at runtime, so this is not a security issue, but adding an explicit
numeric range check (or tightening the regex to
(?:[1-9]\d{0,3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5]))would surface the error at schema parse time with a clear message instead of failing deep in
the SSH handshake.
Mixed-fleet test assumes a specific request-ordering across parallel hosts.
ssh_test.ts:563-584: the test inspectsrequests[0]for web-1 andrequests[1]for web-2.Because the mock executor resolves synchronously (
Promise.resolve(...)) the ordering isdeterministic in practice, but the assumption is subtle and not documented. A comment or
ordering assertion on
requests.map(r => r....)keyed by the host address/argv would make theintent explicit and prevent accidental breakage if the executor is ever changed to be async.